Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf84cec96f | ||
|
|
f0e7b8e11c | ||
|
|
90c7cb65d3 | ||
|
|
70583b9b2f | ||
|
|
f426bb903a | ||
|
|
c41f99ea52 | ||
|
|
a6ea6ba83f | ||
|
|
b480de5bff | ||
|
|
a8043e6827 | ||
|
|
4b4c9e2e6d | ||
|
|
dc9da52651 | ||
|
|
dc1add9dbb | ||
|
|
b5ae5c2be1 | ||
|
|
aa2a88f655 | ||
|
|
f8737e3fda | ||
|
|
d489a34190 | ||
|
|
9d1e31ffbb | ||
|
|
150e53e878 | ||
|
|
7591738c73 | ||
|
|
352e59498b | ||
|
|
d8f26fb5cd | ||
|
|
10216355c1 | ||
|
|
d8e031888e | ||
|
|
114d979397 | ||
|
|
5a500118ae | ||
|
|
ff89a4fa21 | ||
|
|
04495afbfd | ||
|
|
96f1d5c896 | ||
|
|
f1db919b9d | ||
|
|
46f5b95828 | ||
|
|
d748527a4c | ||
|
|
89859d39d1 | ||
|
|
ade627a5d0 | ||
|
|
4642c77e18 | ||
|
|
7d0870c3fb | ||
|
|
c3d1f83b96 | ||
|
|
fc0ea8824d | ||
|
|
9772785f81 | ||
|
|
733a033274 | ||
|
|
414094bd38 | ||
|
|
d8fb382ce9 | ||
|
|
e8f68a92f8 | ||
|
|
8f3df7b68d | ||
|
|
5f0b7ac799 | ||
|
|
8cdad3863d | ||
|
|
229def0afc | ||
|
|
5569a066ff | ||
|
|
ea31791388 | ||
|
|
adade27de4 | ||
|
|
a3f3e188e1 | ||
|
|
031315bdb6 | ||
|
|
00c028c8c8 | ||
|
|
bddf3250c5 | ||
|
|
41486bd59b | ||
|
|
d6d27fb062 | ||
|
|
317a2f40bd | ||
|
|
949fe40d5b | ||
|
|
2be80d4203 | ||
|
|
9ebaa15099 | ||
|
|
aee355fada |
+5
-2
@@ -23,6 +23,9 @@ fixtures/** filter=lfs diff=lfs merge=lfs -text
|
|||||||
|
|
||||||
# The manual's pictures live in LFS for the same reason the models do: a
|
# The manual's pictures live in LFS for the same reason the models do: a
|
||||||
# screenshot or a GIF changes wholesale when the interface it shows changes,
|
# screenshot or a GIF changes wholesale when the interface it shows changes,
|
||||||
# and every re-recording would otherwise stay in every clone for good. CI's
|
# and every re-recording would otherwise stay in every clone for good. The
|
||||||
# pulls exclude the directory; nothing built or tested reads it.
|
# desktop and benchmark legs exclude the directory, since nothing they build
|
||||||
|
# or test reads it; the Android and Windows legs fetch it, because the APK
|
||||||
|
# and the installer carry the manual (docs/manual/index.html) with its
|
||||||
|
# pictures, and their packagers refuse a pointer.
|
||||||
docs/manual/media/** filter=lfs diff=lfs merge=lfs -text
|
docs/manual/media/** filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ name: Build and test
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, master, develop]
|
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:
|
pull_request:
|
||||||
branches: [main, master, develop]
|
branches: [main, master, develop]
|
||||||
|
|
||||||
@@ -96,7 +100,9 @@ jobs:
|
|||||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
git config --local lfs.url \
|
git config --local lfs.url \
|
||||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||||
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
|
# The manual's pictures too: the APK carries the manual, and
|
||||||
|
# assemble-apk.sh refuses a pointer where a picture should be.
|
||||||
|
git lfs pull --exclude="fixtures/**"
|
||||||
ls -lR models/
|
ls -lR models/
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
@@ -154,6 +160,16 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --workspace --release
|
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
|
- name: Disk after
|
||||||
if: always()
|
if: always()
|
||||||
run: df -h /workspace 2>/dev/null || df -h .
|
run: df -h /workspace 2>/dev/null || df -h .
|
||||||
@@ -213,7 +229,9 @@ jobs:
|
|||||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
git config --local lfs.url \
|
git config --local lfs.url \
|
||||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||||
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
|
# The manual's pictures too: the APK carries the manual, and
|
||||||
|
# assemble-apk.sh refuses a pointer where a picture should be.
|
||||||
|
git lfs pull --exclude="fixtures/**"
|
||||||
ls -lR models/
|
ls -lR models/
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
@@ -406,7 +424,9 @@ jobs:
|
|||||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
git config --local lfs.url \
|
git config --local lfs.url \
|
||||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||||
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
|
# The manual's pictures too: the installer carries the manual, and
|
||||||
|
# package.sh refuses a pointer where a picture should be.
|
||||||
|
git lfs pull --exclude="fixtures/**"
|
||||||
ls -l models/face models/scene
|
ls -l models/face models/scene
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
@@ -460,6 +480,11 @@ jobs:
|
|||||||
WANT=$(find models/face models/scene models/inpaint -maxdepth 1 -type f ! -name README.md | wc -l)
|
WANT=$(find models/face models/scene models/inpaint -maxdepth 1 -type f ! -name README.md | wc -l)
|
||||||
GOT=$(ls "$INST/models" | wc -l)
|
GOT=$(ls "$INST/models" | wc -l)
|
||||||
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT model files, installed $GOT"; exit 1; }
|
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT model files, installed $GOT"; exit 1; }
|
||||||
|
# The manual, and every picture it shows, counted the same way.
|
||||||
|
[ -f "$INST/manual/index.html" ] || { echo "FAIL: no manual installed"; exit 1; }
|
||||||
|
WANT=$(ls docs/manual/media | wc -l)
|
||||||
|
GOT=$(ls "$INST/manual/media" | wc -l)
|
||||||
|
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT manual pictures, installed $GOT"; exit 1; }
|
||||||
wine reg query 'HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom' 2>/dev/null \
|
wine reg query 'HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom' 2>/dev/null \
|
||||||
| grep -q DisplayVersion || { echo "FAIL: no uninstall registry key"; exit 1; }
|
| grep -q DisplayVersion || { echo "FAIL: no uninstall registry key"; exit 1; }
|
||||||
wine "$INST/darkroom.exe" --version 2>/dev/null | grep -q '^darkroom-desktop ' \
|
wine "$INST/darkroom.exe" --version 2>/dev/null | grep -q '^darkroom-desktop ' \
|
||||||
@@ -519,3 +544,47 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
exit $FAILED
|
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/*
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ jobs:
|
|||||||
# threshold: zero requirements parsed, zero files scanned, a ratio above
|
# threshold: zero requirements parsed, zero files scanned, a ratio above
|
||||||
# 100%, or any orphan tag all fail the build. A misconfigured run must not
|
# 100%, or any orphan tag all fail the build. A misconfigured run must not
|
||||||
# report a plausible-looking 0%.
|
# report a plausible-looking 0%.
|
||||||
|
# Every picture the manual shows is made by a scene in
|
||||||
|
# tools/manual/scenes.py, and every picture a scene makes is shown.
|
||||||
|
# Two files read; no app, no display.
|
||||||
|
- name: Manual pictures have scenes
|
||||||
|
run: tools/manual/record.sh --check
|
||||||
|
|
||||||
- name: Traceability gate
|
- name: Traceability gate
|
||||||
run: cargo run -q -p traceability -- check
|
run: cargo run -q -p traceability -- check
|
||||||
|
|
||||||
@@ -91,10 +97,19 @@ jobs:
|
|||||||
# they will conclude the application is broken rather than the page.
|
# they will conclude the application is broken rather than the page.
|
||||||
#
|
#
|
||||||
# This also fails on a malformed tag, so a typo costs a gesture its
|
# This also fails on a malformed tag, so a typo costs a gesture its
|
||||||
# desktop half loudly rather than silently.
|
# desktop half loudly rather than silently — and on a key a Slint
|
||||||
|
# handler binds that no tag names, or a key a tag names that no handler
|
||||||
|
# binds (tools/traceability/src/keymap.rs).
|
||||||
- name: Regenerate the gesture vocabulary and check it is committed
|
- name: Regenerate the gesture vocabulary and check it is committed
|
||||||
run: cargo run -q -p traceability -- gestures-check
|
run: cargo run -q -p traceability -- gestures-check
|
||||||
|
|
||||||
|
# The manual's page, which the packages carry and the help sheet links
|
||||||
|
# into. Blocking for the gesture book's reason: it is shown to the user,
|
||||||
|
# and a page that disagrees with the README is a manual describing an
|
||||||
|
# application that no longer exists.
|
||||||
|
- name: Regenerate the manual page and check it is committed
|
||||||
|
run: cargo run -q -p traceability -- manual-check
|
||||||
|
|
||||||
# Advisory, not blocking: not every file implements a requirement, and a
|
# Advisory, not blocking: not every file implements a requirement, and a
|
||||||
# tag on every function is noise that rots faster than it helps. Tag the
|
# tag on every function is noise that rots faster than it helps. Tag the
|
||||||
# unit that decides.
|
# unit that decides.
|
||||||
|
|||||||
+15
-1
@@ -26,7 +26,7 @@ fi
|
|||||||
# The artefacts are generated from the tree, so regenerating them because one
|
# The artefacts are generated from the tree, so regenerating them because one
|
||||||
# was itself edited would be circular.
|
# was itself edited would be circular.
|
||||||
case "$(tr -d '[:space:]' <<< "${staged}")" in
|
case "$(tr -d '[:space:]' <<< "${staged}")" in
|
||||||
docs/dev/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs)
|
docs/dev/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs | docs/manual/index.html)
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -65,3 +65,17 @@ for f in docs/gestures.md ui/dr-ui/src/gesture_book.rs; do
|
|||||||
echo "pre-commit: regenerated ${f} and staged it"
|
echo "pre-commit: regenerated ${f} and staged it"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# The manual's page, when its source is part of the commit. Rendered from
|
||||||
|
# nothing but the README, so there is no reason to pay for it otherwise.
|
||||||
|
if grep -qx 'docs/manual/README.md' <<< "${staged}"; then
|
||||||
|
if ! out="$(cargo run -q -p traceability -- manual 2>&1)"; then
|
||||||
|
echo "pre-commit: the manual would not render" >&2
|
||||||
|
echo "${out}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! git diff --quiet -- docs/manual/index.html; then
|
||||||
|
git add docs/manual/index.html
|
||||||
|
echo "pre-commit: regenerated docs/manual/index.html and staged it"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|||||||
Generated
+40
-29
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-android"
|
name = "darkroom-android"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1234,7 +1234,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-desktop"
|
name = "darkroom-desktop"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1408,7 +1408,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-bench"
|
name = "dr-bench"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-catalog",
|
"dr-catalog",
|
||||||
@@ -1425,7 +1425,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-catalog"
|
name = "dr-catalog"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-face",
|
"dr-face",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1440,7 +1440,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-decode"
|
name = "dr-decode"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1454,7 +1454,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-export"
|
name = "dr-export"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-gpu",
|
"dr-gpu",
|
||||||
@@ -1473,7 +1473,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-face"
|
name = "dr-face"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1486,7 +1486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-film"
|
name = "dr-film"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1495,7 +1495,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-gpu"
|
name = "dr-gpu"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1513,7 +1513,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-inference-engine"
|
name = "dr-inference-engine"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -1528,7 +1528,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ingest"
|
name = "dr-ingest"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1540,7 +1540,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-lens"
|
name = "dr-lens"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lensfun",
|
"lensfun",
|
||||||
"log",
|
"log",
|
||||||
@@ -1548,7 +1548,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pano"
|
name = "dr-pano"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
@@ -1562,7 +1562,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pipeline"
|
name = "dr-pipeline"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -1571,7 +1571,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-plat"
|
name = "dr-plat"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-native-keyring-store",
|
"android-native-keyring-store",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1587,7 +1587,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-preset-xmp"
|
name = "dr-preset-xmp"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-pipeline",
|
"dr-pipeline",
|
||||||
"log",
|
"log",
|
||||||
@@ -1597,7 +1597,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-segment"
|
name = "dr-segment"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1610,7 +1610,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync"
|
name = "dr-sync"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1624,7 +1624,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-folder"
|
name = "dr-sync-folder"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-sync",
|
"dr-sync",
|
||||||
@@ -1636,7 +1636,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-nextcloud"
|
name = "dr-sync-nextcloud"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1658,7 +1658,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-thumbs"
|
name = "dr-thumbs"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"jpeg-encoder",
|
"jpeg-encoder",
|
||||||
@@ -1670,7 +1670,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-types"
|
name = "dr-types"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1679,7 +1679,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ui"
|
name = "dr-ui"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1704,6 +1704,7 @@ dependencies = [
|
|||||||
"dr-types",
|
"dr-types",
|
||||||
"dr-xmp",
|
"dr-xmp",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
"i-slint-backend-testing",
|
||||||
"jni 0.22.4",
|
"jni 0.22.4",
|
||||||
"log",
|
"log",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
@@ -1717,12 +1718,13 @@ dependencies = [
|
|||||||
"slint-build",
|
"slint-build",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"url",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-xmp"
|
name = "dr-xmp"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -2780,6 +2782,18 @@ dependencies = [
|
|||||||
"i-slint-renderer-skia",
|
"i-slint-renderer-skia",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "i-slint-backend-testing"
|
||||||
|
version = "1.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521e901e3d47ab829c0ef500c63155776208707cd93259e6a7803ed627fa2786"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"i-slint-common",
|
||||||
|
"i-slint-core",
|
||||||
|
"vtable",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "i-slint-backend-winit"
|
name = "i-slint-backend-winit"
|
||||||
version = "1.17.1"
|
version = "1.17.1"
|
||||||
@@ -2960,8 +2974,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "i-slint-renderer-skia"
|
name = "i-slint-renderer-skia"
|
||||||
version = "1.17.1"
|
version = "1.17.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7b6eed7f3f0a9a3d3ca6e8b9d4ca233371d989351fdb2a7ab88ec368b99e7b57"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
@@ -7023,9 +7035,10 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traceability"
|
name = "traceability"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
@@ -7923,8 +7936,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-hal"
|
name = "wgpu-hal"
|
||||||
version = "29.0.4"
|
version = "29.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_system_properties",
|
"android_system_properties",
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
|
|||||||
+17
-1
@@ -27,9 +27,12 @@ members = [
|
|||||||
"tools/bench",
|
"tools/bench",
|
||||||
"tools/traceability",
|
"tools/traceability",
|
||||||
]
|
]
|
||||||
|
# Patched copies of upstream crates, not our code: see third_party/README.md.
|
||||||
|
# Excluded so `--workspace` does not test, lint or format them as ours.
|
||||||
|
exclude = ["third_party"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
@@ -127,6 +130,10 @@ url = "2.5"
|
|||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
# The manual's HTML rendering (tools/traceability). Already in the tree as
|
||||||
|
# Slint's Markdown parser, so this adds a dependency edge and no crate; only
|
||||||
|
# the HTML writer is needed, not the command-line front end.
|
||||||
|
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
|
||||||
base64 = "0.23"
|
base64 = "0.23"
|
||||||
|
|
||||||
# Display-server clients, for FR-DSP-8's per-display profile acquisition.
|
# Display-server clients, for FR-DSP-8's per-display profile acquisition.
|
||||||
@@ -262,3 +269,12 @@ opt-level = 0
|
|||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|
||||||
|
# Two upstream crates carry a local patch so that the Android build can draw
|
||||||
|
# with wgpu on a rotated display (technical-debt.md TD-1). Both are exact
|
||||||
|
# copies of the version the lockfile already resolves, plus that patch;
|
||||||
|
# third_party/README.md says what was changed and how to carry it forward
|
||||||
|
# when Slint or wgpu moves.
|
||||||
|
[patch.crates-io]
|
||||||
|
wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" }
|
||||||
|
i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" }
|
||||||
|
|||||||
@@ -76,12 +76,12 @@ controls, its place in the chain and its tests.
|
|||||||
|
|
||||||
## Where it stands
|
## Where it stands
|
||||||
|
|
||||||
**0.14.0**, twenty-one tagged releases in. 188 numbered requirements in
|
**0.15.0**, twenty-three tagged releases in. 190 numbered requirements in
|
||||||
scope, 82% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
scope, 84% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
||||||
the rest are written down rather than merely absent.
|
the rest are written down rather than merely absent.
|
||||||
|
|
||||||
**Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and
|
**Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and
|
||||||
survey culling, AI denoise, tiled and progressive rendering, HDR merge and
|
survey culling, AI denoise, tiled rendering, HDR merge and
|
||||||
focus stacking, most of the Android platform integration beyond running,
|
focus stacking, most of the Android platform integration beyond running,
|
||||||
and the Flatpak's library chooser. The performance targets are half
|
and the Flatpak's library chooser. The performance targets are half
|
||||||
verified: the per-commit benchmark suite §8 requires exists for everything
|
verified: the per-commit benchmark suite §8 requires exists for everything
|
||||||
|
|||||||
@@ -141,6 +141,23 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- The manual (dr_ui::manual): a WebView over the copy the APK
|
||||||
|
carries in assets/manual. See ManualActivity.java for why it is
|
||||||
|
not the browser.
|
||||||
|
|
||||||
|
Not exported: nothing outside this app has a reason to start it,
|
||||||
|
and dr_ui starts it by class name, which needs no intent filter.
|
||||||
|
Its own task entry is not wanted either — it is a page over the
|
||||||
|
app, and Back returns to the photograph it was opened from.
|
||||||
|
configChanges so a rotation reflows the page rather than
|
||||||
|
reloading it at the top. -->
|
||||||
|
<activity
|
||||||
|
android:name="paris.tourolle.darkroom.ManualActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="DarkRoom manual"
|
||||||
|
android:theme="@style/ManualTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
|
||||||
|
|
||||||
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
|
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
|
||||||
between apps since API 24 — handing one out raises
|
between apps since API 24 — handing one out raises
|
||||||
FileUriExposedException in *this* process — so an exported JPEG
|
FileUriExposedException in *this* process — so an exported JPEG
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package paris.tourolle.darkroom;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.ActivityNotFoundException;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.webkit.WebResourceRequest;
|
||||||
|
import android.webkit.WebSettings;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
import android.webkit.WebViewClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The manual that ships in the APK, shown in a WebView.
|
||||||
|
*
|
||||||
|
* <h2>Why an activity of our own rather than the browser</h2>
|
||||||
|
*
|
||||||
|
* <p>The desktop hands the manual to the system browser. Android leaves no
|
||||||
|
* way to do the same: the page is an asset inside the APK, which is not a
|
||||||
|
* file; an unpacked copy in app-private storage is a file no browser may
|
||||||
|
* read; a {@code file:} URI handed to another app is refused since API 24;
|
||||||
|
* and a {@code content:} URI serves the page but leaves the browser to fetch
|
||||||
|
* every picture by a relative URL against the provider, which browsers do not
|
||||||
|
* reliably do. A WebView reads {@code file:///android_asset/} straight from
|
||||||
|
* the APK, pictures and section anchor included, and nothing is unpacked.
|
||||||
|
*
|
||||||
|
* <h2>What it is not</h2>
|
||||||
|
*
|
||||||
|
* <p>A browser. JavaScript stays off (the page has none), and a link that
|
||||||
|
* leaves the manual — the design documents are on the forge — goes to the
|
||||||
|
* user's browser rather than opening inside this view, so the only thing ever
|
||||||
|
* shown here is the page the APK carries.
|
||||||
|
*
|
||||||
|
* <p>Started by {@code dr_ui::manual} with {@code Intent.setClassName}, so the
|
||||||
|
* name here and there must agree; a test in lib.rs checks the manifest
|
||||||
|
* declares it.
|
||||||
|
*/
|
||||||
|
public final class ManualActivity extends Activity {
|
||||||
|
/** The section to open at, a heading's anchor. Absent opens the top. */
|
||||||
|
public static final String EXTRA_ANCHOR = "anchor";
|
||||||
|
|
||||||
|
private static final String PAGE = "file:///android_asset/manual/index.html";
|
||||||
|
|
||||||
|
private WebView web;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle saved) {
|
||||||
|
super.onCreate(saved);
|
||||||
|
setTitle("DarkRoom manual");
|
||||||
|
|
||||||
|
web = new WebView(this);
|
||||||
|
WebSettings settings = web.getSettings();
|
||||||
|
settings.setJavaScriptEnabled(false);
|
||||||
|
// Pinch to zoom into a screenshot, which is 1600 pixels wide and drawn
|
||||||
|
// at the width of a phone.
|
||||||
|
settings.setBuiltInZoomControls(true);
|
||||||
|
settings.setDisplayZoomControls(false);
|
||||||
|
web.setWebViewClient(new WebViewClient() {
|
||||||
|
@Override
|
||||||
|
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
|
||||||
|
Uri uri = request.getUrl();
|
||||||
|
if ("file".equals(uri.getScheme())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
startActivity(new Intent(Intent.ACTION_VIEW, uri));
|
||||||
|
} catch (ActivityNotFoundException e) {
|
||||||
|
// No browser on the device: the link does nothing, which
|
||||||
|
// is all it could do.
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setContentView(web);
|
||||||
|
|
||||||
|
if (saved != null) {
|
||||||
|
web.restoreState(saved);
|
||||||
|
} else {
|
||||||
|
String anchor = getIntent().getStringExtra(EXTRA_ANCHOR);
|
||||||
|
web.loadUrl(anchor == null || anchor.isEmpty() ? PAGE : PAGE + "#" + anchor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onSaveInstanceState(Bundle out) {
|
||||||
|
super.onSaveInstanceState(out);
|
||||||
|
web.saveState(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back walks back through the sections visited, then leaves. */
|
||||||
|
@Override
|
||||||
|
public void onBackPressed() {
|
||||||
|
if (web.canGoBack()) {
|
||||||
|
web.goBack();
|
||||||
|
} else {
|
||||||
|
super.onBackPressed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDestroy() {
|
||||||
|
web.destroy();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Day or night as the system is; see values/themes.xml. -->
|
||||||
|
<resources>
|
||||||
|
<style name="ManualTheme" parent="@android:style/Theme.DeviceDefault.DayNight" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
The manual's theme (ManualActivity). Light below API 29, which has no
|
||||||
|
day-night theme in the platform; values-v29 follows the system from there.
|
||||||
|
The WebView takes prefers-color-scheme from whether this theme is light, and
|
||||||
|
the manual's stylesheet takes its colours from that.
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<style name="ManualTheme" parent="@android:style/Theme.DeviceDefault.Light" />
|
||||||
|
</resources>
|
||||||
@@ -581,6 +581,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `dr_ui::manual` starts the manual by class name. A name the manifest
|
||||||
|
/// does not declare is an `ActivityNotFoundException` on the device and a
|
||||||
|
/// Manual button that does nothing, so the three spellings — dr_ui's, the
|
||||||
|
/// manifest's and the Java file's — are checked to be one.
|
||||||
|
#[test]
|
||||||
|
fn the_manual_activity_dr_ui_starts_is_declared() {
|
||||||
|
let manifest = manifest();
|
||||||
|
let wanted = dr_ui::manual::ANDROID_ACTIVITY;
|
||||||
|
let element = manifest
|
||||||
|
.split("<activity")
|
||||||
|
.skip(1)
|
||||||
|
.find(|a| attribute(a, "android:name").as_deref() == Some(wanted))
|
||||||
|
.unwrap_or_else(|| panic!("the manifest declares no activity {wanted}"));
|
||||||
|
assert_eq!(
|
||||||
|
attribute(element, "android:exported").as_deref(),
|
||||||
|
Some("false"),
|
||||||
|
"the manual activity has no reason to be startable by another app"
|
||||||
|
);
|
||||||
|
let java = include_str!("../android/java/paris/tourolle/darkroom/ManualActivity.java");
|
||||||
|
let (package, class) = wanted.rsplit_once('.').expect("unqualified class name");
|
||||||
|
assert!(java.contains(&format!("package {package};")));
|
||||||
|
assert!(java.contains(&format!("class {class} ")));
|
||||||
|
assert!(
|
||||||
|
java.contains(&format!(
|
||||||
|
"EXTRA_ANCHOR = \"{}\"",
|
||||||
|
dr_ui::manual::ANDROID_EXTRA_ANCHOR
|
||||||
|
)),
|
||||||
|
"ManualActivity reads the section from a different extra than dr_ui writes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_provider_hands_out_one_file_at_a_time_and_nothing_by_itself() {
|
fn the_provider_hands_out_one_file_at_a_time_and_nothing_by_itself() {
|
||||||
let manifest = manifest();
|
let manifest = manifest();
|
||||||
|
|||||||
@@ -25,3 +25,6 @@ winresource = "0.1"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
# The manual's recording hook (dr-ui's `automation`); tools/manual/record.sh
|
||||||
|
# builds with it, nothing else does.
|
||||||
|
automation = ["dr-ui/automation"]
|
||||||
|
|||||||
+171
-14
@@ -49,6 +49,12 @@ pub struct Judgement {
|
|||||||
/// 0..=5. Zero means *unrated*, which is a state in its own right.
|
/// 0..=5. Zero means *unrated*, which is a state in its own right.
|
||||||
pub rating: u8,
|
pub rating: u8,
|
||||||
pub flag: FlagState,
|
pub flag: FlagState,
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
/// The colour label, or `None`. Not part of [`Judgement::is_judged`]:
|
||||||
|
/// a label sorts photographs into piles of the photographer's own
|
||||||
|
/// meaning — "to print", "send to Anna" — and says nothing about whether
|
||||||
|
/// a frame has been culled, which is the question "unjudged" asks.
|
||||||
|
pub label: Option<ColourLabel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Judgement {
|
impl Judgement {
|
||||||
@@ -267,14 +273,6 @@ pub fn align_default_version_uuids(conn: &Connection) -> Result<usize, CatalogEr
|
|||||||
Ok(moved)
|
Ok(moved)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The default version's row id for an image, creating one if it has none.
|
|
||||||
///
|
|
||||||
/// Every write path goes through this rather than assuming a version exists.
|
|
||||||
/// An image can arrive without one in two ways that are not worth trying to
|
|
||||||
/// prevent: a row inserted by a build predating this module, and a scan whose
|
|
||||||
/// version pass was interrupted between the image insert and the commit.
|
|
||||||
/// Failing a rating because of either would be the wrong answer — the user
|
|
||||||
/// pressed a key and expects a star.
|
|
||||||
/// TRACES: FR-CAT-13
|
/// TRACES: FR-CAT-13
|
||||||
/// How `versions.label` encodes a colour label, and back.
|
/// How `versions.label` encodes a colour label, and back.
|
||||||
///
|
///
|
||||||
@@ -304,6 +302,14 @@ pub fn label_from_code(code: Option<i64>) -> Option<ColourLabel> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The default version's row id for an image, creating one if it has none.
|
||||||
|
///
|
||||||
|
/// Every write path goes through this rather than assuming a version exists.
|
||||||
|
/// An image can arrive without one in two ways that are not worth trying to
|
||||||
|
/// prevent: a row inserted by a build predating this module, and a scan whose
|
||||||
|
/// version pass was interrupted between the image insert and the commit.
|
||||||
|
/// Failing a rating because of either would be the wrong answer — the user
|
||||||
|
/// pressed a key and expects a star.
|
||||||
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
|
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
|
||||||
let existing: Option<i64> = conn
|
let existing: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -387,6 +393,88 @@ pub fn set_flag_many(
|
|||||||
apply_many(conn, images, |conn, id| set_flag(conn, id, flag))
|
apply_many(conn, images, |conn, id| set_flag(conn, id, flag))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
/// Set or clear the colour label for one image.
|
||||||
|
pub fn set_label(
|
||||||
|
conn: &Connection,
|
||||||
|
image: ImageId,
|
||||||
|
label: Option<ColourLabel>,
|
||||||
|
) -> Result<(), CatalogError> {
|
||||||
|
let version = default_version_id(conn, image)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE versions SET label = ?2 WHERE id = ?1",
|
||||||
|
rusqlite::params![version, label.map(label_code)],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
/// Set or clear a label on many images in one transaction — one keystroke
|
||||||
|
/// over a selection is one commit, as for [`set_rating_many`].
|
||||||
|
pub fn set_label_many(
|
||||||
|
conn: &Connection,
|
||||||
|
images: &[ImageId],
|
||||||
|
label: Option<ColourLabel>,
|
||||||
|
) -> Result<usize, CatalogError> {
|
||||||
|
apply_many(conn, images, |conn, id| set_label(conn, id, label))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
/// What a label key does to a set of images: Lightroom's toggle.
|
||||||
|
///
|
||||||
|
/// Pressing the key for the label every one of them already carries takes it
|
||||||
|
/// off; otherwise every one of them gets it. Decided over the whole set
|
||||||
|
/// rather than per image, so a selection that was half red comes out all red
|
||||||
|
/// rather than inverted — the photographer pressed "red", and a key that
|
||||||
|
/// turned half of them red and the other half plain would be two answers to
|
||||||
|
/// one question.
|
||||||
|
pub fn toggled_label(
|
||||||
|
current: impl IntoIterator<Item = Option<ColourLabel>>,
|
||||||
|
pressed: ColourLabel,
|
||||||
|
) -> Option<ColourLabel> {
|
||||||
|
let mut any = false;
|
||||||
|
for label in current {
|
||||||
|
any = true;
|
||||||
|
if label != Some(pressed) {
|
||||||
|
return Some(pressed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if any {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(pressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-5 | FR-CAT-6
|
||||||
|
/// How the library divides by colour label, for the filter chips' counts.
|
||||||
|
///
|
||||||
|
/// Index 0 is unlabelled and index `n` the label whose code is `n`. One
|
||||||
|
/// grouped statement — the same shape as [`rating_histogram`], and for the
|
||||||
|
/// same reason it LEFT JOINs: an image without a version row is unlabelled,
|
||||||
|
/// not missing.
|
||||||
|
pub fn label_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
|
||||||
|
let mut out = [0usize; 6];
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT coalesce(v.label, 0) AS l, count(*)
|
||||||
|
FROM images i
|
||||||
|
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
||||||
|
GROUP BY l",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||||
|
for (code, count) in rows.flatten() {
|
||||||
|
// A code this build does not know counts as unlabelled, which is how
|
||||||
|
// `label_from_code` reads it everywhere else.
|
||||||
|
let slot = if label_from_code(Some(code)).is_some() {
|
||||||
|
code as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
out[slot] += count as usize;
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared bulk wrapper, so the two axes cannot drift in their commit
|
/// Shared bulk wrapper, so the two axes cannot drift in their commit
|
||||||
/// behaviour — a partially-committed rating and a fully-committed flag from
|
/// behaviour — a partially-committed rating and a fully-committed flag from
|
||||||
/// the same keystroke would be hard to explain and harder to notice.
|
/// the same keystroke would be hard to explain and harder to notice.
|
||||||
@@ -411,21 +499,22 @@ fn apply_many(
|
|||||||
/// An image with no version reads as unrated and unflagged rather than as an
|
/// An image with no version reads as unrated and unflagged rather than as an
|
||||||
/// error: that is exactly what it is.
|
/// error: that is exactly what it is.
|
||||||
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
|
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
|
||||||
let row: Option<(i64, i64)> = conn
|
let row: Option<(i64, i64, Option<i64>)> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT rating, flag FROM versions
|
"SELECT rating, flag, label FROM versions
|
||||||
WHERE image_id = ?1
|
WHERE image_id = ?1
|
||||||
ORDER BY is_default DESC, id ASC
|
ORDER BY is_default DESC, id ASC
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
[image.0 as i64],
|
[image.0 as i64],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
|
|
||||||
Ok(match row {
|
Ok(match row {
|
||||||
Some((rating, flag)) => Judgement {
|
Some((rating, flag, label)) => Judgement {
|
||||||
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
||||||
flag: flag_from_code(flag),
|
flag: flag_from_code(flag),
|
||||||
|
label: label_from_code(label),
|
||||||
},
|
},
|
||||||
None => Judgement::default(),
|
None => Judgement::default(),
|
||||||
})
|
})
|
||||||
@@ -452,7 +541,7 @@ pub fn judgements(
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(",");
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT image_id, rating, flag FROM versions
|
"SELECT image_id, rating, flag, label FROM versions
|
||||||
WHERE image_id IN ({placeholders}) AND is_default = 1"
|
WHERE image_id IN ({placeholders}) AND is_default = 1"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -467,15 +556,17 @@ pub fn judgements(
|
|||||||
r.get::<_, i64>(0)?,
|
r.get::<_, i64>(0)?,
|
||||||
r.get::<_, i64>(1)?,
|
r.get::<_, i64>(1)?,
|
||||||
r.get::<_, i64>(2)?,
|
r.get::<_, i64>(2)?,
|
||||||
|
r.get::<_, Option<i64>>(3)?,
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
for (image, rating, flag) in rows.flatten() {
|
for (image, rating, flag, label) in rows.flatten() {
|
||||||
out.insert(
|
out.insert(
|
||||||
ImageId(image as u64),
|
ImageId(image as u64),
|
||||||
Judgement {
|
Judgement {
|
||||||
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
||||||
flag: flag_from_code(flag),
|
flag: flag_from_code(flag),
|
||||||
|
label: label_from_code(label),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -686,6 +777,72 @@ mod tests {
|
|||||||
assert_eq!(distinct, 200);
|
assert_eq!(distinct, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_label_round_trips_and_clears() {
|
||||||
|
// TRACES: FR-CAT-5
|
||||||
|
let cat = with_images(1);
|
||||||
|
let id = ids(&cat)[0];
|
||||||
|
set_label(cat.connection(), id, Some(ColourLabel::Green)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
judgement(cat.connection(), id).unwrap().label,
|
||||||
|
Some(ColourLabel::Green)
|
||||||
|
);
|
||||||
|
set_label(cat.connection(), id, None).unwrap();
|
||||||
|
assert_eq!(judgement(cat.connection(), id).unwrap().label, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_label_is_not_a_judgement() {
|
||||||
|
// "Unjudged" is the cull's resume point; a label is a pile of the
|
||||||
|
// photographer's own, and labelling a frame must not hide it there.
|
||||||
|
let cat = with_images(1);
|
||||||
|
let id = ids(&cat)[0];
|
||||||
|
set_label(cat.connection(), id, Some(ColourLabel::Red)).unwrap();
|
||||||
|
assert!(!judgement(cat.connection(), id).unwrap().is_judged());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn labelling_a_selection_is_one_commit_and_reaches_every_image() {
|
||||||
|
// TRACES: FR-CAT-5
|
||||||
|
let cat = with_images(4);
|
||||||
|
let all = ids(&cat);
|
||||||
|
assert_eq!(
|
||||||
|
set_label_many(cat.connection(), &all, Some(ColourLabel::Blue)).unwrap(),
|
||||||
|
4
|
||||||
|
);
|
||||||
|
let found = judgements(cat.connection(), &all).unwrap();
|
||||||
|
assert!(all
|
||||||
|
.iter()
|
||||||
|
.all(|id| found[id].label == Some(ColourLabel::Blue)));
|
||||||
|
assert_eq!(
|
||||||
|
label_histogram(cat.connection()).unwrap(),
|
||||||
|
[0, 0, 0, 0, 4, 0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_label_key_toggles_only_when_every_image_already_has_it() {
|
||||||
|
// TRACES: FR-CAT-5
|
||||||
|
use ColourLabel::*;
|
||||||
|
assert_eq!(toggled_label([Some(Red), Some(Red)], Red), None);
|
||||||
|
assert_eq!(toggled_label([Some(Red), None], Red), Some(Red));
|
||||||
|
assert_eq!(toggled_label([Some(Blue)], Red), Some(Red));
|
||||||
|
assert_eq!(toggled_label([], Red), Some(Red));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_label_histogram_sums_to_the_library() {
|
||||||
|
// TRACES: FR-CAT-6
|
||||||
|
// Images without a version row count as unlabelled rather than
|
||||||
|
// vanishing, as the rating histogram's do.
|
||||||
|
let cat = with_images(3);
|
||||||
|
let first = ids(&cat)[0];
|
||||||
|
set_label(cat.connection(), first, Some(ColourLabel::Purple)).unwrap();
|
||||||
|
let h = label_histogram(cat.connection()).unwrap();
|
||||||
|
assert_eq!(h, [2, 0, 0, 0, 0, 1]);
|
||||||
|
assert_eq!(h.iter().sum::<usize>(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_rating_round_trips() {
|
fn a_rating_round_trips() {
|
||||||
let cat = with_images(1);
|
let cat = with_images(1);
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//! TRACES: FR-RAW-2
|
||||||
|
//! The seam a second decoder plugs into.
|
||||||
|
//!
|
||||||
|
//! D2 keeps LibRaw as the fallback for bodies rawler does not cover. Adding
|
||||||
|
//! it later should be a new `impl Decoder`, not an edit to every caller that
|
||||||
|
//! reads a header, cuts a thumbnail or opens a photograph for export — which
|
||||||
|
//! is what the free functions alone would have made it. So the callers take a
|
||||||
|
//! `&dyn Decoder`, and only the places that start a job name [`default`].
|
||||||
|
//!
|
||||||
|
//! Bytes in, always. Nothing here takes a path or a `SourceRef`: resolving a
|
||||||
|
//! file to bytes is `Storage`'s job at the caller, so the same decoder serves a
|
||||||
|
//! local file, an Android document and a range fetched from Nextcloud. The
|
||||||
|
//! decoder's part in that is to say how much of a file it needs
|
||||||
|
//! ([`Decoder::header_bytes`]) and where its preview sits
|
||||||
|
//! ([`Decoder::locate_preview`]); the storage layer fetches exactly that.
|
||||||
|
//!
|
||||||
|
//! What stays a free function is what is not a decoder's to vary: recognising
|
||||||
|
//! a JPEG ([`crate::probe`]), decoding one ([`crate::decode_jpeg`]) and
|
||||||
|
//! checking one is whole ([`crate::is_complete_jpeg`]). A second RAW decoder
|
||||||
|
//! would not read a JPEG differently.
|
||||||
|
|
||||||
|
use dr_types::Orientation;
|
||||||
|
|
||||||
|
use crate::{DecodeError, Metadata, Preview, PreviewLocation, PreviewSize, RawImage};
|
||||||
|
|
||||||
|
/// TRACES: FR-RAW-2
|
||||||
|
/// A RAW decoder, over bytes.
|
||||||
|
///
|
||||||
|
/// Object-safe so a caller can hold `&dyn Decoder` without becoming generic,
|
||||||
|
/// `Send + Sync` because the callers that need one most — the thumbnail
|
||||||
|
/// lanes, the export worker — run off the UI thread, and `Debug` so a job
|
||||||
|
/// description that carries one can still be printed.
|
||||||
|
pub trait Decoder: Send + Sync + std::fmt::Debug {
|
||||||
|
/// How much of the start of a file [`Self::metadata`] and
|
||||||
|
/// [`Self::locate_preview`] need. A caller reading over a network fetches
|
||||||
|
/// this range and no more.
|
||||||
|
fn header_bytes(&self) -> u64;
|
||||||
|
|
||||||
|
/// Capture metadata, from a header or a whole file, without touching
|
||||||
|
/// sensor data.
|
||||||
|
fn metadata(&self, bytes: &[u8]) -> Result<Metadata, DecodeError>;
|
||||||
|
|
||||||
|
/// How the stored pixels are turned, from a header. `None` where the file
|
||||||
|
/// does not say, which callers take as upright.
|
||||||
|
fn orientation(&self, header: &[u8]) -> Option<Orientation>;
|
||||||
|
|
||||||
|
/// Where the embedded preview best suited to a thumbnail sits in the file,
|
||||||
|
/// from its header, so a remote caller can fetch that range alone.
|
||||||
|
fn locate_preview(&self, header: &[u8], file_len: u64) -> Option<PreviewLocation>;
|
||||||
|
|
||||||
|
/// The embedded preview at the size asked for, falling through the ladder
|
||||||
|
/// to the next size where the file lacks it.
|
||||||
|
fn preview(&self, bytes: &[u8], size: PreviewSize) -> Result<Preview, DecodeError>;
|
||||||
|
|
||||||
|
/// Sensor data, for develop and export. The expensive path.
|
||||||
|
fn decode(&self, bytes: &[u8]) -> Result<RawImage, DecodeError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-RAW-2
|
||||||
|
/// The decoder the application ships: rawler for sensor data and the
|
||||||
|
/// previews it knows, DarkRoom's own container walk for headers and ranges.
|
||||||
|
///
|
||||||
|
/// Its methods are the crate's free functions, unchanged. They stay public
|
||||||
|
/// for the tools and examples that read one file and have no caller to keep
|
||||||
|
/// decoder-agnostic.
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct Rawler;
|
||||||
|
|
||||||
|
impl Decoder for Rawler {
|
||||||
|
fn header_bytes(&self) -> u64 {
|
||||||
|
crate::HEADER_BYTES
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata(&self, bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
||||||
|
crate::metadata(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn orientation(&self, header: &[u8]) -> Option<Orientation> {
|
||||||
|
crate::orientation(header)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn locate_preview(&self, header: &[u8], file_len: u64) -> Option<PreviewLocation> {
|
||||||
|
crate::locate_preview(header, file_len)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preview(&self, bytes: &[u8], size: PreviewSize) -> Result<Preview, DecodeError> {
|
||||||
|
crate::extract_preview(bytes, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(&self, bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
||||||
|
crate::decode(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-RAW-2
|
||||||
|
/// The decoder a job uses unless it was handed another.
|
||||||
|
///
|
||||||
|
/// Named by the places that start work — a thread, a UI handler — and by
|
||||||
|
/// nothing below them. Returning `&'static dyn Decoder` rather than `Rawler`
|
||||||
|
/// is the point: a caller that only has this cannot reach past the trait.
|
||||||
|
pub fn default() -> &'static dyn Decoder {
|
||||||
|
static RAWLER: Rawler = Rawler;
|
||||||
|
&RAWLER
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The default is the shipped decoder, reached through the trait: same
|
||||||
|
/// header budget, and the same answer to bytes neither can read.
|
||||||
|
#[test]
|
||||||
|
fn the_default_is_rawler_behind_the_trait() {
|
||||||
|
let d = default();
|
||||||
|
assert_eq!(d.header_bytes(), crate::HEADER_BYTES);
|
||||||
|
let junk = [0u8; 64];
|
||||||
|
assert_eq!(d.metadata(&junk).is_err(), crate::metadata(&junk).is_err());
|
||||||
|
assert!(d.decode(&junk).is_err());
|
||||||
|
assert_eq!(d.orientation(&junk), crate::orientation(&junk));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,14 +11,20 @@
|
|||||||
//!
|
//!
|
||||||
//! Fusing them would force a full decode where a header read suffices, which
|
//! Fusing them would force a full decode where a header read suffices, which
|
||||||
//! is exactly why Lightroom stalls ~2 s per image during culling.
|
//! is exactly why Lightroom stalls ~2 s per image during culling.
|
||||||
|
//!
|
||||||
|
//! Callers reach these through the [`Decoder`] trait rather than by name, so a
|
||||||
|
//! second decoder can be put behind them without changing any of them
|
||||||
|
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
|
||||||
|
|
||||||
pub mod base_curve;
|
pub mod base_curve;
|
||||||
|
mod decoder;
|
||||||
mod error;
|
mod error;
|
||||||
mod locate;
|
mod locate;
|
||||||
mod preview;
|
mod preview;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
|
||||||
pub use base_curve::BaseCurve;
|
pub use base_curve::BaseCurve;
|
||||||
|
pub use decoder::{default, Decoder, Rawler};
|
||||||
pub use error::DecodeError;
|
pub use error::DecodeError;
|
||||||
pub use locate::{
|
pub use locate::{
|
||||||
defects, is_complete_jpeg, jpeg_metadata, locate_preview, tiff_metadata, BadLine, BadPixel,
|
defects, is_complete_jpeg, jpeg_metadata, locate_preview, tiff_metadata, BadLine, BadPixel,
|
||||||
|
|||||||
@@ -1898,6 +1898,62 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-20
|
||||||
|
#[test]
|
||||||
|
fn a_keystone_reshapes_the_frame_without_exposing_a_corner() {
|
||||||
|
// The shader half of perspective correction, end to end. A top-bright
|
||||||
|
// frame with a full vertical keystone spreads its top across the
|
||||||
|
// output, so the bright half reaches further down than the middle;
|
||||||
|
// and since the frame is mapped onto a trapezoid *inside* the source,
|
||||||
|
// no corner is left without a pixel behind it.
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
let mut pass = AdjustPass::new(&ctx);
|
||||||
|
let img = split_image(&ctx, true);
|
||||||
|
|
||||||
|
let plain = EditGraph::default_chain().compose();
|
||||||
|
let tex = pass.render(&img, &plain, 32, 32).expect("render");
|
||||||
|
let below_middle = read_pixel(&ctx, tex, 16, 19)[0];
|
||||||
|
assert!(
|
||||||
|
below_middle < 90,
|
||||||
|
"unkeyed, row 19 is the dark half: {below_middle}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut g = EditGraph::default_chain();
|
||||||
|
g.set_param(
|
||||||
|
dr_pipeline::framing::ID,
|
||||||
|
dr_pipeline::framing::KEYSTONE_V,
|
||||||
|
100.0,
|
||||||
|
);
|
||||||
|
g.set_param(
|
||||||
|
dr_pipeline::framing::ID,
|
||||||
|
dr_pipeline::framing::KEYSTONE_H,
|
||||||
|
100.0,
|
||||||
|
);
|
||||||
|
let shader = g.compose();
|
||||||
|
let tex = pass.render(&img, &shader, 32, 32).expect("render");
|
||||||
|
|
||||||
|
for (x, y) in [(0, 0), (31, 0), (0, 31), (31, 31)] {
|
||||||
|
assert_ne!(
|
||||||
|
read_pixel(&ctx, tex, x, y),
|
||||||
|
[0, 0, 0, 255],
|
||||||
|
"corner ({x},{y}) has no source pixel behind it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut g = EditGraph::default_chain();
|
||||||
|
g.set_param(
|
||||||
|
dr_pipeline::framing::ID,
|
||||||
|
dr_pipeline::framing::KEYSTONE_V,
|
||||||
|
100.0,
|
||||||
|
);
|
||||||
|
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
|
||||||
|
let keyed = read_pixel(&ctx, tex, 16, 19)[0];
|
||||||
|
assert!(
|
||||||
|
keyed > 128,
|
||||||
|
"the spread top half must reach row 19: {keyed}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dragging_the_crop_does_not_recompile() {
|
fn dragging_the_crop_does_not_recompile() {
|
||||||
// The cache contract for framing, which is what makes an interactive
|
// The cache contract for framing, which is what makes an interactive
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ impl FocusPeakPass {
|
|||||||
// TEXTURE_BINDING to be sampled by the compositor.
|
// TEXTURE_BINDING to be sampled by the compositor.
|
||||||
// RENDER_ATTACHMENT is not used by anything here and is required
|
// RENDER_ATTACHMENT is not used by anything here and is required
|
||||||
// anyway: Slint rejects an imported texture without it. COPY_SRC
|
// anyway: Slint rejects an imported texture without it. COPY_SRC
|
||||||
// is for `read_overlay` and its two callers.
|
// is for `read_overlay` and the tests that call it.
|
||||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||||
| wgpu::TextureUsages::RENDER_ATTACHMENT
|
| wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||||
@@ -490,18 +490,11 @@ impl FocusPeakPass {
|
|||||||
/// TRACES: AC-8
|
/// TRACES: AC-8
|
||||||
/// Copy the overlay to the CPU, as RGBA8 rows with no padding.
|
/// Copy the overlay to the CPU, as RGBA8 rows with no padding.
|
||||||
///
|
///
|
||||||
/// **Two callers, and neither is the desktop display path.** The tests
|
/// **The tests below are the only caller, and never the display path.** An
|
||||||
/// below are one: an overlay is a claim about which pixels are sharp, and
|
/// overlay is a claim about which pixels are sharp, and there is no way to
|
||||||
/// there is no way to check that claim without looking at the pixels. The
|
/// check that claim without looking at the pixels. On screen, on desktop
|
||||||
/// other is the Android develop view, which reads the *frame* back for the
|
/// and Android alike, the overlay reaches the compositor as a texture and
|
||||||
/// reasons `technical-debt.md` TD-1 records — wgpu's Android swapchain
|
/// ARCH §6.1 holds. (Android read it back here until TD-1 was paid off.)
|
||||||
/// tears a portrait window, so Slint is not drawing with wgpu there and no
|
|
||||||
/// texture can be handed over. An overlay that stayed on the device on a
|
|
||||||
/// platform where the picture underneath it does not would simply never be
|
|
||||||
/// seen.
|
|
||||||
///
|
|
||||||
/// On desktop nothing calls this, and ARCH §6.1 holds on the path that
|
|
||||||
/// matters: the overlay reaches the compositor as a texture.
|
|
||||||
pub fn read_overlay(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
pub fn read_overlay(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
||||||
let Some(layer) = self.layers[self.current].as_ref() else {
|
let Some(layer) = self.layers[self.current].as_ref() else {
|
||||||
return Err(GpuError::Readback("no overlay has been rendered".into()));
|
return Err(GpuError::Readback("no overlay has been rendered".into()));
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! module doc said for eight releases that it held no pipeline and no masks.
|
//! module doc said for eight releases that it held no pipeline and no masks.
|
||||||
//! It holds both now, plus demosaic, detail, segmentation masks, two
|
//! It holds both now, plus demosaic, detail, segmentation masks, two
|
||||||
//! histograms and focus peaking. The zero-copy claim is still the one that
|
//! histograms and focus peaking. The zero-copy claim is still the one that
|
||||||
//! matters, and TD-1 records the one platform where it does not hold.
|
//! matters, and since TD-1 was paid off it holds on Android too.
|
||||||
//!
|
//!
|
||||||
//! Deliberately free of UI dependencies (ARCH §6.5a). The texture is handed
|
//! Deliberately free of UI dependencies (ARCH §6.5a). The texture is handed
|
||||||
//! out as a `wgpu::Texture`; who composites it is not this crate's concern.
|
//! out as a `wgpu::Texture`; who composites it is not this crate's concern.
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ pub struct MaskPass {
|
|||||||
combine_layout: wgpu::BindGroupLayout,
|
combine_layout: wgpu::BindGroupLayout,
|
||||||
combine_union: wgpu::RenderPipeline,
|
combine_union: wgpu::RenderPipeline,
|
||||||
combine_subtract: wgpu::RenderPipeline,
|
combine_subtract: wgpu::RenderPipeline,
|
||||||
|
combine_intersect: wgpu::RenderPipeline,
|
||||||
/// Where a part is drawn before it is joined.
|
/// Where a part is drawn before it is joined.
|
||||||
///
|
///
|
||||||
/// One texture for the whole stack rather than one per layer, because
|
/// One texture for the whole stack rather than one per layer, because
|
||||||
@@ -651,6 +652,16 @@ impl MaskPass {
|
|||||||
"mask-combine-subtract",
|
"mask-combine-subtract",
|
||||||
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc),
|
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc),
|
||||||
);
|
);
|
||||||
|
// TRACES: FR-DEV-19a
|
||||||
|
// `dst * src`: what the mask had, kept only in proportion to how much
|
||||||
|
// of it this part also covers. The same three vertices and the same
|
||||||
|
// scratch, so a third set operation is a third blend state and
|
||||||
|
// nothing more — which is what `Join::apply` states on the CPU and
|
||||||
|
// `the_joins_match_their_definition` holds this to.
|
||||||
|
let combine_intersect = combine(
|
||||||
|
"mask-combine-intersect",
|
||||||
|
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::Src),
|
||||||
|
);
|
||||||
|
|
||||||
// The same, with the deposit thrown away: coverage is only ever taken
|
// The same, with the deposit thrown away: coverage is only ever taken
|
||||||
// off what earlier strokes on this layer put down. There is no negative
|
// off what earlier strokes on this layer put down. There is no negative
|
||||||
@@ -681,6 +692,7 @@ impl MaskPass {
|
|||||||
combine_layout,
|
combine_layout,
|
||||||
combine_union,
|
combine_union,
|
||||||
combine_subtract,
|
combine_subtract,
|
||||||
|
combine_intersect,
|
||||||
scratch: None,
|
scratch: None,
|
||||||
array: None,
|
array: None,
|
||||||
allocations: 0,
|
allocations: 0,
|
||||||
@@ -1376,6 +1388,7 @@ impl MaskPass {
|
|||||||
pass.set_pipeline(match join {
|
pass.set_pipeline(match join {
|
||||||
Join::Union => &self.combine_union,
|
Join::Union => &self.combine_union,
|
||||||
Join::Subtract => &self.combine_subtract,
|
Join::Subtract => &self.combine_subtract,
|
||||||
|
Join::Intersect => &self.combine_intersect,
|
||||||
});
|
});
|
||||||
pass.set_bind_group(0, &bind_group, &[]);
|
pass.set_bind_group(0, &bind_group, &[]);
|
||||||
pass.draw(0..3, 0..1);
|
pass.draw(0..3, 0..1);
|
||||||
|
|||||||
@@ -790,6 +790,119 @@ fn the_order_parts_are_joined_in_is_the_mask() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-19a
|
||||||
|
/// The truth table `docs/dev/mask-editing.md` §13 asks for: one base, one
|
||||||
|
/// part that half-covers it, joined each of the three ways. The base is the
|
||||||
|
/// left half of the frame and the part a dab in the middle, so the four
|
||||||
|
/// quarters of the table are four pixels.
|
||||||
|
#[test]
|
||||||
|
fn a_part_unioned_subtracted_and_intersected_gives_the_three_fields() {
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("no adapter; skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let field = split_field(&ctx);
|
||||||
|
let joined = |join: Join| {
|
||||||
|
let mut layer = brighten(MaskSource::Regions {
|
||||||
|
signature: 1,
|
||||||
|
level: 2,
|
||||||
|
ids: vec![0],
|
||||||
|
});
|
||||||
|
assert!(layer.push_part(MaskPart::painted("p2", join)));
|
||||||
|
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||||
|
let mut stack = MaskStack::new();
|
||||||
|
stack.push(layer);
|
||||||
|
render(&ctx, &stack, Some(&field))
|
||||||
|
};
|
||||||
|
|
||||||
|
// (base, part): left outside the dab, left inside, right inside, right
|
||||||
|
// outside.
|
||||||
|
let cells = [(4, 16), (14, 16), (18, 16), (27, 16)];
|
||||||
|
let lit = |pixels: &[u8]| cells.map(|(x, y)| luma_at(pixels, x, y) > 200);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
lit(&joined(Join::Union)),
|
||||||
|
[true, true, true, false],
|
||||||
|
"union: either"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lit(&joined(Join::Subtract)),
|
||||||
|
[true, false, false, false],
|
||||||
|
"subtract: the base without the dab"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lit(&joined(Join::Intersect)),
|
||||||
|
[false, true, false, false],
|
||||||
|
"intersect: only where both are"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-19a
|
||||||
|
/// Intersection on soft coverage is the product `Join::apply` defines, on
|
||||||
|
/// either side of the join: a gradient intersected with a region it fills is
|
||||||
|
/// the gradient there and nothing elsewhere, and a region intersected with a
|
||||||
|
/// gradient is the gradient wherever the region is.
|
||||||
|
#[test]
|
||||||
|
fn the_joins_match_their_definition() {
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("no adapter; skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let field = split_field(&ctx);
|
||||||
|
let ramp = || MaskSource::Linear {
|
||||||
|
centre: (0.5, 0.5),
|
||||||
|
angle: 0.0,
|
||||||
|
width: 1.0,
|
||||||
|
};
|
||||||
|
let right_half = || MaskSource::Regions {
|
||||||
|
signature: 1,
|
||||||
|
level: 2,
|
||||||
|
ids: vec![1],
|
||||||
|
};
|
||||||
|
let draw = |layer: MaskLayer| {
|
||||||
|
let mut stack = MaskStack::new();
|
||||||
|
stack.push(layer);
|
||||||
|
render(&ctx, &stack, Some(&field))
|
||||||
|
};
|
||||||
|
|
||||||
|
let alone = draw(brighten(ramp()));
|
||||||
|
|
||||||
|
let mut ramp_then_region = brighten(ramp());
|
||||||
|
assert!(ramp_then_region.push_part(MaskPart::new("p2", Join::Intersect, right_half())));
|
||||||
|
let ramp_then_region = draw(ramp_then_region);
|
||||||
|
|
||||||
|
let mut region_then_ramp = brighten(whole_frame());
|
||||||
|
assert!(region_then_ramp.push_part(MaskPart::new("p2", Join::Intersect, ramp())));
|
||||||
|
let region_then_ramp = draw(region_then_ramp);
|
||||||
|
|
||||||
|
for x in 0..SIZE {
|
||||||
|
let y = SIZE / 2;
|
||||||
|
let want = luma_at(&alone, x, y);
|
||||||
|
// dst · 1 = dst on the right; dst · 0 = 0 on the left. Pixels
|
||||||
|
// within two of the seam are left out: the region's own edge
|
||||||
|
// is soft there, so neither side of the table is 0 or 1.
|
||||||
|
let got = luma_at(&ramp_then_region, x, y);
|
||||||
|
if x.abs_diff(SIZE / 2) <= 2 {
|
||||||
|
// The seam.
|
||||||
|
} else if x > SIZE / 2 {
|
||||||
|
assert!(
|
||||||
|
got.abs_diff(want) <= 1,
|
||||||
|
"x={x}: the ramp survives where the region is ({got} vs {want})"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_eq!(got, 128, "x={x}: and nothing survives where it is not");
|
||||||
|
}
|
||||||
|
// 1 · src = src everywhere.
|
||||||
|
let got = luma_at(®ion_then_ramp, x, y);
|
||||||
|
assert!(
|
||||||
|
got.abs_diff(want) <= 1,
|
||||||
|
"x={x}: a full base intersected with the ramp is the ramp ({got} vs {want})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- seeing the mask (FR-DEV-19c) ------------------------------------------
|
// --- seeing the mask (FR-DEV-19c) ------------------------------------------
|
||||||
|
|
||||||
/// A radial that covers the middle of the frame and nothing near the corners.
|
/// A radial that covers the middle of the frame and nothing near the corners.
|
||||||
|
|||||||
@@ -27,6 +27,33 @@
|
|||||||
//! chain exactly the space it documents: normalised, centred, `r == 1` at the
|
//! chain exactly the space it documents: normalised, centred, `r == 1` at the
|
||||||
//! corner. Neither stage needs to know the other exists.
|
//! corner. Neither stage needs to know the other exists.
|
||||||
//!
|
//!
|
||||||
|
//! # Perspective sits inside framing (FR-DEV-20)
|
||||||
|
//!
|
||||||
|
//! A keystone correction is composition too — straightening converging
|
||||||
|
//! verticals reframes the photograph — so it is a step *of* framing rather
|
||||||
|
//! than a stage beside it, and inherits framing's `Compose` attribute, its
|
||||||
|
//! place in the sidecar and its exclusion from a default paste. Expanded, the
|
||||||
|
//! chain reads:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! output pixel → crop → straighten → perspective → orientation → warp (lens) → sample
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! After the straightening, because the angle is a nudge applied to the
|
||||||
|
//! corrected picture: the verticals are made parallel and *then* the whole is
|
||||||
|
//! levelled. Before the stored orientation, because "vertical" means vertical
|
||||||
|
//! in the photograph as it is shown — a portrait frame the camera stored on
|
||||||
|
//! its side must converge along its displayed height, not along the sensor's
|
||||||
|
//! rows. And before the lens warp, which still sees the whole frame it
|
||||||
|
//! corrects, for the reason given above.
|
||||||
|
//!
|
||||||
|
//! The correction maps the output frame onto a trapezoid **inside** the
|
||||||
|
//! source rather than pulling the source edges in. So a keystone on its own
|
||||||
|
//! never exposes an empty corner, and the crop the user drew is still valid
|
||||||
|
//! after it; only in combination with a straightening angle does the
|
||||||
|
//! inscribed crop have anything to account for — see
|
||||||
|
//! [`Framing::max_inscribed_crop`].
|
||||||
|
//!
|
||||||
//! # Why sampling changes with the angle
|
//! # Why sampling changes with the angle
|
||||||
//!
|
//!
|
||||||
//! At 90° steps and flips, output pixels land exactly on source pixels, so
|
//! At 90° steps and flips, output pixels land exactly on source pixels, so
|
||||||
@@ -58,6 +85,13 @@ pub const CROP_X: ParamId = ParamId("crop_x");
|
|||||||
pub const CROP_Y: ParamId = ParamId("crop_y");
|
pub const CROP_Y: ParamId = ParamId("crop_y");
|
||||||
pub const CROP_W: ParamId = ParamId("crop_w");
|
pub const CROP_W: ParamId = ParamId("crop_w");
|
||||||
pub const CROP_H: ParamId = ParamId("crop_h");
|
pub const CROP_H: ParamId = ParamId("crop_h");
|
||||||
|
/// TRACES: FR-DEV-20
|
||||||
|
/// Vertical keystone. Positive spreads the top of the frame — the correction
|
||||||
|
/// for a building photographed looking up, whose verticals lean together.
|
||||||
|
pub const KEYSTONE_V: ParamId = ParamId("keystone_v");
|
||||||
|
/// TRACES: FR-DEV-20
|
||||||
|
/// Horizontal keystone. Positive spreads the right-hand side of the frame.
|
||||||
|
pub const KEYSTONE_H: ParamId = ParamId("keystone_h");
|
||||||
|
|
||||||
/// Widest straightening the control offers, in degrees either way.
|
/// Widest straightening the control offers, in degrees either way.
|
||||||
///
|
///
|
||||||
@@ -66,12 +100,28 @@ pub const CROP_H: ParamId = ParamId("crop_h");
|
|||||||
/// the edits actually are.
|
/// the edits actually are.
|
||||||
pub const MAX_STRAIGHTEN: f32 = 45.0;
|
pub const MAX_STRAIGHTEN: f32 = 45.0;
|
||||||
|
|
||||||
|
/// The keystone sliders' travel either way.
|
||||||
|
///
|
||||||
|
/// A plain amount rather than degrees of tilt: the angle a camera was tilted
|
||||||
|
/// by depends on a focal length the correction does not know, and a number
|
||||||
|
/// that claimed to be one would be wrong for every lens but one.
|
||||||
|
pub const MAX_KEYSTONE: f32 = 100.0;
|
||||||
|
|
||||||
|
/// How far a full keystone narrows the far edge of the frame, as a fraction
|
||||||
|
/// of its width: at `MAX_KEYSTONE` the source trapezoid's short side is half
|
||||||
|
/// its long one.
|
||||||
|
///
|
||||||
|
/// Enough for a tall building from its own pavement, and short of the point
|
||||||
|
/// where the stretched edge is so magnified that the correction reads as a
|
||||||
|
/// fault of its own.
|
||||||
|
const KEYSTONE_REACH: f64 = 0.5;
|
||||||
|
|
||||||
/// The parameters the framing widget owns — every one of them.
|
/// The parameters the framing widget owns — every one of them.
|
||||||
///
|
///
|
||||||
/// In the order the widget expects: the rect first, then the angle it is
|
/// In the order the widget expects: the rect first, then the angle it is
|
||||||
/// straightened by, then the exact reorientations.
|
/// straightened by, then the exact reorientations.
|
||||||
static FRAMING_PARAMS: [ParamId; 8] = [
|
static FRAMING_PARAMS: [ParamId; 10] = [
|
||||||
CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, ROTATION, FLIP_H, FLIP_V,
|
CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, KEYSTONE_V, KEYSTONE_H, ROTATION, FLIP_H, FLIP_V,
|
||||||
];
|
];
|
||||||
|
|
||||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
@@ -117,6 +167,30 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
|||||||
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
|
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
|
||||||
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
|
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
|
||||||
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
|
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
|
||||||
|
// TRACES: FR-DEV-20
|
||||||
|
// Perspective. Last so every sidecar written before these existed
|
||||||
|
// reads exactly as it did: a missing parameter is its default, and
|
||||||
|
// the default is no correction.
|
||||||
|
ParamDescriptor::scalar(
|
||||||
|
"keystone_v",
|
||||||
|
"param.keystone_v",
|
||||||
|
-MAX_KEYSTONE,
|
||||||
|
MAX_KEYSTONE,
|
||||||
|
0.0,
|
||||||
|
Unit::None,
|
||||||
|
Scale::Linear,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
ParamDescriptor::scalar(
|
||||||
|
"keystone_h",
|
||||||
|
"param.keystone_h",
|
||||||
|
-MAX_KEYSTONE,
|
||||||
|
MAX_KEYSTONE,
|
||||||
|
0.0,
|
||||||
|
Unit::None,
|
||||||
|
Scale::Linear,
|
||||||
|
0,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -311,6 +385,86 @@ fn finite(v: f32, fallback: f32) -> f32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-20
|
||||||
|
/// A plane projective map, row-major, acting on `(x, y, 1)`.
|
||||||
|
///
|
||||||
|
/// Held in `f64` because it is built by solving for four corners and then
|
||||||
|
/// inverted for [`Framing::output_at`]; the shader gets `f32` copies of the
|
||||||
|
/// forward map only.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
struct Homography([[f64; 3]; 3]);
|
||||||
|
|
||||||
|
impl Homography {
|
||||||
|
/// The map taking the square `[-0.5, 0.5]²` onto the quadrilateral whose
|
||||||
|
/// corners are `q`, listed top-left, top-right, bottom-right, bottom-left.
|
||||||
|
///
|
||||||
|
/// Heckbert's closed form for the unit square, composed with the shift
|
||||||
|
/// from the centred square onto it.
|
||||||
|
fn square_to_quad(q: [(f64, f64); 4]) -> Self {
|
||||||
|
let [(x0, y0), (x1, y1), (x2, y2), (x3, y3)] = q;
|
||||||
|
let (dx1, dx2, dx3) = (x1 - x2, x3 - x2, x0 - x1 + x2 - x3);
|
||||||
|
let (dy1, dy2, dy3) = (y1 - y2, y3 - y2, y0 - y1 + y2 - y3);
|
||||||
|
let den = dx1 * dy2 - dx2 * dy1;
|
||||||
|
let (g, h) = if den.abs() < 1e-12 {
|
||||||
|
(0.0, 0.0)
|
||||||
|
} else {
|
||||||
|
((dx3 * dy2 - dx2 * dy3) / den, (dx1 * dy3 - dx3 * dy1) / den)
|
||||||
|
};
|
||||||
|
// Unit square (u, v) -> quad.
|
||||||
|
let unit = [
|
||||||
|
[x1 - x0 + g * x1, x3 - x0 + h * x3, x0],
|
||||||
|
[y1 - y0 + g * y1, y3 - y0 + h * y3, y0],
|
||||||
|
[g, h, 1.0],
|
||||||
|
];
|
||||||
|
// Centred square -> unit square is `u = x + 0.5`, so fold the shift
|
||||||
|
// into the constant column.
|
||||||
|
let mut m = unit;
|
||||||
|
for row in &mut m {
|
||||||
|
row[2] += 0.5 * (row[0] + row[1]);
|
||||||
|
}
|
||||||
|
Self(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where `(x, y)` lands, or `None` past the line the map sends to
|
||||||
|
/// infinity — a point with no image, which the caller treats as outside
|
||||||
|
/// the source.
|
||||||
|
fn apply(&self, (x, y): (f64, f64)) -> Option<(f64, f64)> {
|
||||||
|
let m = &self.0;
|
||||||
|
let w = m[2][0] * x + m[2][1] * y + m[2][2];
|
||||||
|
if w <= 1e-9 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((
|
||||||
|
(m[0][0] * x + m[0][1] * y + m[0][2]) / w,
|
||||||
|
(m[1][0] * x + m[1][1] * y + m[1][2]) / w,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inverse map, by the adjugate. Scale is irrelevant to a projective
|
||||||
|
/// map, so the determinant is only divided out to keep `w` positive and
|
||||||
|
/// near one — which is what [`Self::apply`]'s horizon test relies on.
|
||||||
|
fn inverse(&self) -> Self {
|
||||||
|
let m = &self.0;
|
||||||
|
let c = |r0: usize, c0: usize, r1: usize, c1: usize| {
|
||||||
|
m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0]
|
||||||
|
};
|
||||||
|
let adj = [
|
||||||
|
[c(1, 1, 2, 2), -c(0, 1, 2, 2), c(0, 1, 1, 2)],
|
||||||
|
[-c(1, 0, 2, 2), c(0, 0, 2, 2), -c(0, 0, 1, 2)],
|
||||||
|
[c(1, 0, 2, 1), -c(0, 0, 2, 1), c(0, 0, 1, 1)],
|
||||||
|
];
|
||||||
|
let det = m[0][0] * adj[0][0] + m[0][1] * adj[1][0] + m[0][2] * adj[2][0];
|
||||||
|
let det = if det.abs() < 1e-12 { 1.0 } else { det };
|
||||||
|
let mut inv = adj;
|
||||||
|
for row in &mut inv {
|
||||||
|
for v in row.iter_mut() {
|
||||||
|
*v /= det;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self(inv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3 | FR-DEV-3d
|
/// TRACES: FR-DEV-3 | FR-DEV-3d
|
||||||
/// Crop, straighten, rotation and flips for one image.
|
/// Crop, straighten, rotation and flips for one image.
|
||||||
///
|
///
|
||||||
@@ -325,6 +479,10 @@ pub struct Framing {
|
|||||||
quarter_turns: u8,
|
quarter_turns: u8,
|
||||||
flip_h: bool,
|
flip_h: bool,
|
||||||
flip_v: bool,
|
flip_v: bool,
|
||||||
|
/// TRACES: FR-DEV-20
|
||||||
|
/// Vertical and horizontal keystone, each `-MAX_KEYSTONE..=MAX_KEYSTONE`.
|
||||||
|
keystone_v: f32,
|
||||||
|
keystone_h: f32,
|
||||||
/// TRACES: FR-DEV-3h
|
/// TRACES: FR-DEV-3h
|
||||||
/// How the file's pixels were stored, from its EXIF orientation.
|
/// How the file's pixels were stored, from its EXIF orientation.
|
||||||
///
|
///
|
||||||
@@ -376,6 +534,8 @@ impl Default for Framing {
|
|||||||
quarter_turns: 0,
|
quarter_turns: 0,
|
||||||
flip_h: false,
|
flip_h: false,
|
||||||
flip_v: false,
|
flip_v: false,
|
||||||
|
keystone_v: 0.0,
|
||||||
|
keystone_h: 0.0,
|
||||||
baseline: dr_types::Orientation::NORMAL,
|
baseline: dr_types::Orientation::NORMAL,
|
||||||
crop: CropRect::default(),
|
crop: CropRect::default(),
|
||||||
view: CropRect::default(),
|
view: CropRect::default(),
|
||||||
@@ -396,7 +556,7 @@ impl Framing {
|
|||||||
/// How framing would like to be presented.
|
/// How framing would like to be presented.
|
||||||
///
|
///
|
||||||
/// **This is what stops a frontend having to name this stage.** Rendered
|
/// **This is what stops a frontend having to name this stage.** Rendered
|
||||||
/// generically these eight parameters are eight bad controls: four crop
|
/// generically these ten parameters are ten bad controls: four crop
|
||||||
/// edges the photographer would have to type coordinates into, a "rotate"
|
/// edges the photographer would have to type coordinates into, a "rotate"
|
||||||
/// slider running 0..3, and two switches. Every one of them is a worse
|
/// slider running 0..3, and two switches. Every one of them is a worse
|
||||||
/// control than the gesture it stands for — a crop is dragged on the
|
/// control than the gesture it stands for — a crop is dragged on the
|
||||||
@@ -407,10 +567,10 @@ impl Framing {
|
|||||||
/// a second frontend would have had to learn the same special case, and
|
/// a second frontend would have had to learn the same special case, and
|
||||||
/// nothing in the capability output said why. Now the preference is
|
/// nothing in the capability output said why. Now the preference is
|
||||||
/// declared, the demand says what the widget needs, and a frontend that
|
/// declared, the demand says what the widget needs, and a frontend that
|
||||||
/// cannot meet it falls back to the eight sliders — tedious, but complete,
|
/// cannot meet it falls back to the ten sliders — tedious, but complete,
|
||||||
/// which is the guarantee the whole hint mechanism rests on.
|
/// which is the guarantee the whole hint mechanism rests on.
|
||||||
///
|
///
|
||||||
/// The widget owns **all eight** parameters rather than only the rect: a
|
/// The widget owns **all ten** parameters rather than only the rect: a
|
||||||
/// frontend that takes this on is taking on the whole framing control
|
/// frontend that takes this on is taking on the whole framing control
|
||||||
/// surface, and leaving rotation and the flips behind would scatter them
|
/// surface, and leaving rotation and the flips behind would scatter them
|
||||||
/// into the generated panel underneath a crop control that already exists.
|
/// into the generated panel underneath a crop control that already exists.
|
||||||
@@ -476,6 +636,52 @@ impl Framing {
|
|||||||
(self.flip_h, self.flip_v)
|
(self.flip_h, self.flip_v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-20
|
||||||
|
/// The vertical and horizontal keystone, as the sliders show them.
|
||||||
|
pub fn keystone(&self) -> (f32, f32) {
|
||||||
|
(self.keystone_v, self.keystone_h)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a perspective correction is applied at all.
|
||||||
|
pub fn has_keystone(&self) -> bool {
|
||||||
|
self.keystone_v != 0.0 || self.keystone_h != 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-20
|
||||||
|
/// The perspective map, from the straightened output frame to the upright
|
||||||
|
/// source frame, both measured as the centred square `[-0.5, 0.5]²`.
|
||||||
|
///
|
||||||
|
/// **Measured in fractions of the frame, not in the aspect-scaled space
|
||||||
|
/// the rest of the prologue works in**, so the map is the same for every
|
||||||
|
/// frame shape and the uniforms need no image size. The prologue divides
|
||||||
|
/// `p.x` by the frame's aspect on the way in and multiplies it back on
|
||||||
|
/// the way out.
|
||||||
|
///
|
||||||
|
/// The output frame's corners go to a trapezoid inside the source: a
|
||||||
|
/// positive vertical keystone brings the top corners in, so the top of
|
||||||
|
/// the source is spread across the full width of the output and lines
|
||||||
|
/// that converged upward come out parallel. Nothing is ever mapped from
|
||||||
|
/// outside the source, which is why a keystone alone needs no crop.
|
||||||
|
fn keystone_map(&self) -> Option<Homography> {
|
||||||
|
if !self.has_keystone() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let amount = |v: f32| f64::from(v / MAX_KEYSTONE).clamp(-1.0, 1.0) * KEYSTONE_REACH;
|
||||||
|
let (tv, th) = (amount(self.keystone_v), amount(self.keystone_h));
|
||||||
|
// How much of each edge survives: the top and bottom rows' widths,
|
||||||
|
// the left and right columns' heights.
|
||||||
|
let top = 1.0 - tv.max(0.0);
|
||||||
|
let bottom = 1.0 + tv.min(0.0);
|
||||||
|
let right = 1.0 - th.max(0.0);
|
||||||
|
let left = 1.0 + th.min(0.0);
|
||||||
|
Some(Homography::square_to_quad([
|
||||||
|
(-0.5 * top, -0.5 * left),
|
||||||
|
(0.5 * top, -0.5 * right),
|
||||||
|
(0.5 * bottom, 0.5 * right),
|
||||||
|
(-0.5 * bottom, 0.5 * left),
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
/// Add quarter turns, wrapping. The rotate-left/right buttons.
|
/// Add quarter turns, wrapping. The rotate-left/right buttons.
|
||||||
pub fn rotate_quarters(&mut self, turns: i32) {
|
pub fn rotate_quarters(&mut self, turns: i32) {
|
||||||
self.quarter_turns = (i32::from(self.quarter_turns) + turns).rem_euclid(4) as u8;
|
self.quarter_turns = (i32::from(self.quarter_turns) + turns).rem_euclid(4) as u8;
|
||||||
@@ -544,6 +750,7 @@ impl Framing {
|
|||||||
pub fn is_active(&self) -> bool {
|
pub fn is_active(&self) -> bool {
|
||||||
let (turns, flip_h, flip_v) = self.effective();
|
let (turns, flip_h, flip_v) = self.effective();
|
||||||
self.angle != 0.0
|
self.angle != 0.0
|
||||||
|
|| self.has_keystone()
|
||||||
// Effective, not the user's: a file stored sideways needs the
|
// Effective, not the user's: a file stored sideways needs the
|
||||||
// prologue emitted even on an untouched image, or it renders
|
// prologue emitted even on an untouched image, or it renders
|
||||||
// through the identity map and lies on its side.
|
// through the identity map and lies on its side.
|
||||||
@@ -572,6 +779,7 @@ impl Framing {
|
|||||||
/// edited, the file was merely read correctly.
|
/// edited, the file was merely read correctly.
|
||||||
pub fn edits_image(&self) -> bool {
|
pub fn edits_image(&self) -> bool {
|
||||||
self.angle != 0.0
|
self.angle != 0.0
|
||||||
|
|| self.has_keystone()
|
||||||
|| self.quarter_turns != 0
|
|| self.quarter_turns != 0
|
||||||
|| self.flip_h
|
|| self.flip_h
|
||||||
|| self.flip_v
|
|| self.flip_v
|
||||||
@@ -591,7 +799,9 @@ impl Framing {
|
|||||||
/// warp being active forces interpolation regardless, which is the
|
/// warp being active forces interpolation regardless, which is the
|
||||||
/// composer's call to make rather than this stage's.
|
/// composer's call to make rather than this stage's.
|
||||||
pub fn needs_interpolation(&self) -> bool {
|
pub fn needs_interpolation(&self) -> bool {
|
||||||
self.angle != 0.0
|
// A keystone stretches the frame by a different amount at every row,
|
||||||
|
// so it lands between pixels everywhere but on its centre line.
|
||||||
|
self.angle != 0.0 || self.has_keystone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_param(&mut self, id: ParamId, value: f32) {
|
pub fn set_param(&mut self, id: ParamId, value: f32) {
|
||||||
@@ -629,6 +839,8 @@ impl Framing {
|
|||||||
}
|
}
|
||||||
.normalised()
|
.normalised()
|
||||||
}
|
}
|
||||||
|
KEYSTONE_V => self.keystone_v = finite(value, 0.0).clamp(-MAX_KEYSTONE, MAX_KEYSTONE),
|
||||||
|
KEYSTONE_H => self.keystone_h = finite(value, 0.0).clamp(-MAX_KEYSTONE, MAX_KEYSTONE),
|
||||||
_ => log::warn!("framing: unknown parameter {id}"),
|
_ => log::warn!("framing: unknown parameter {id}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -643,6 +855,8 @@ impl Framing {
|
|||||||
CROP_Y => self.crop.y,
|
CROP_Y => self.crop.y,
|
||||||
CROP_W => self.crop.width,
|
CROP_W => self.crop.width,
|
||||||
CROP_H => self.crop.height,
|
CROP_H => self.crop.height,
|
||||||
|
KEYSTONE_V => self.keystone_v,
|
||||||
|
KEYSTONE_H => self.keystone_h,
|
||||||
_ => 0.0,
|
_ => 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -707,8 +921,22 @@ impl Framing {
|
|||||||
///
|
///
|
||||||
/// The standard largest-inscribed-rectangle result for a rotated
|
/// The standard largest-inscribed-rectangle result for a rotated
|
||||||
/// rectangle of the same aspect ratio.
|
/// rectangle of the same aspect ratio.
|
||||||
|
///
|
||||||
|
/// TRACES: FR-DEV-20
|
||||||
|
/// **With a keystone the closed form no longer applies**: the area with a
|
||||||
|
/// source pixel behind it is the source rectangle pulled back through the
|
||||||
|
/// perspective map and then turned, a quadrilateral no textbook result
|
||||||
|
/// describes. That case is searched instead — see
|
||||||
|
/// [`Self::inscribed_by_search`]. A keystone alone never needs a crop, so
|
||||||
|
/// the search returns the whole frame for it, exactly.
|
||||||
pub fn max_inscribed_crop(&self, width: u32, height: u32) -> CropRect {
|
pub fn max_inscribed_crop(&self, width: u32, height: u32) -> CropRect {
|
||||||
if self.angle == 0.0 || width == 0 || height == 0 {
|
if width == 0 || height == 0 {
|
||||||
|
return CropRect::default();
|
||||||
|
}
|
||||||
|
if self.has_keystone() {
|
||||||
|
return self.inscribed_by_search(width, height);
|
||||||
|
}
|
||||||
|
if self.angle == 0.0 {
|
||||||
return CropRect::default();
|
return CropRect::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,6 +978,123 @@ impl Framing {
|
|||||||
.normalised()
|
.normalised()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-20
|
||||||
|
/// The largest centred crop with a source pixel behind every point, found
|
||||||
|
/// by search rather than by formula.
|
||||||
|
///
|
||||||
|
/// The area that has a source pixel behind it is convex — the source
|
||||||
|
/// rectangle pulled back through a projective map whose horizon lies
|
||||||
|
/// outside it, then turned — and a rectangle lies inside a convex region
|
||||||
|
/// exactly when its four corners do. For a given width the tallest
|
||||||
|
/// rectangle that fits is therefore found by bisection, and the area
|
||||||
|
/// `width × tallest(width)` is unimodal in the width (a positive concave
|
||||||
|
/// function times a line), so a golden-section search finds the best
|
||||||
|
/// width. Every rectangle returned has been tested corner by corner, so
|
||||||
|
/// the answer errs inside, never outside.
|
||||||
|
///
|
||||||
|
/// A few hundred corner tests, on a gesture's release, is nothing next to
|
||||||
|
/// the render that follows it.
|
||||||
|
fn inscribed_by_search(&self, width: u32, height: u32) -> CropRect {
|
||||||
|
let (w, h) = if self.swaps_axes() {
|
||||||
|
(f64::from(height), f64::from(width))
|
||||||
|
} else {
|
||||||
|
(f64::from(width), f64::from(height))
|
||||||
|
};
|
||||||
|
let fa = w / h;
|
||||||
|
let rad = f64::from(self.angle).to_radians();
|
||||||
|
let (sn, cs) = (rad.sin(), rad.cos());
|
||||||
|
let map = self.keystone_map();
|
||||||
|
|
||||||
|
// Whether the output point `(fx, fy)`, in fractions of the frame from
|
||||||
|
// its centre, has a source pixel behind it. The prologue's steps, in
|
||||||
|
// its order, stopping short of the turns: those are a permutation of
|
||||||
|
// the frame and cannot move a point across its edge.
|
||||||
|
let defined = |fx: f64, fy: f64| {
|
||||||
|
let p = (fx * fa, fy);
|
||||||
|
let q = (p.0 * cs - p.1 * sn, p.0 * sn + p.1 * cs);
|
||||||
|
let n = (q.0 / fa, q.1);
|
||||||
|
let src = match &map {
|
||||||
|
Some(m) => m.apply(n),
|
||||||
|
None => Some(n),
|
||||||
|
};
|
||||||
|
const EDGE: f64 = 0.5 + 1e-9;
|
||||||
|
src.is_some_and(|(x, y)| x.abs() <= EDGE && y.abs() <= EDGE)
|
||||||
|
};
|
||||||
|
let fits = |hw: f64, hh: f64| {
|
||||||
|
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
|
||||||
|
.iter()
|
||||||
|
.all(|(sx, sy)| defined(sx * hw, sy * hh))
|
||||||
|
};
|
||||||
|
|
||||||
|
if fits(0.5, 0.5) {
|
||||||
|
return CropRect::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Half the tallest height that fits at half-width `hw`.
|
||||||
|
let tallest = |hw: f64| {
|
||||||
|
if fits(hw, 0.5) {
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
if !fits(hw, 0.0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let (mut lo, mut hi) = (0.0, 0.5);
|
||||||
|
for _ in 0..40 {
|
||||||
|
let mid = 0.5 * (lo + hi);
|
||||||
|
if fits(hw, mid) {
|
||||||
|
lo = mid;
|
||||||
|
} else {
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lo
|
||||||
|
};
|
||||||
|
let area = |hw: f64| hw * tallest(hw);
|
||||||
|
|
||||||
|
let ratio = (5.0_f64.sqrt() - 1.0) * 0.5;
|
||||||
|
let (mut a, mut b) = (0.0, 0.5);
|
||||||
|
let mut c = b - ratio * (b - a);
|
||||||
|
let mut d = a + ratio * (b - a);
|
||||||
|
let (mut fc, mut fd) = (area(c), area(d));
|
||||||
|
for _ in 0..48 {
|
||||||
|
if fc < fd {
|
||||||
|
a = c;
|
||||||
|
c = d;
|
||||||
|
fc = fd;
|
||||||
|
d = a + ratio * (b - a);
|
||||||
|
fd = area(d);
|
||||||
|
} else {
|
||||||
|
b = d;
|
||||||
|
d = c;
|
||||||
|
fd = fc;
|
||||||
|
c = b - ratio * (b - a);
|
||||||
|
fc = area(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let hw = 0.5 * (a + b);
|
||||||
|
let hh = tallest(hw);
|
||||||
|
// Tested at `hw` as returned, so a width that the search's last step
|
||||||
|
// nudged past the boundary cannot come back with a height that no
|
||||||
|
// longer fits it.
|
||||||
|
let (hw, hh) = if hh > 0.0 && fits(hw, hh) {
|
||||||
|
(hw, hh)
|
||||||
|
} else {
|
||||||
|
(c.min(d), tallest(c.min(d)))
|
||||||
|
};
|
||||||
|
|
||||||
|
let fw = (2.0 * hw) as f32;
|
||||||
|
let fh = (2.0 * hh) as f32;
|
||||||
|
let fw = fw.clamp(CropRect::MIN_EXTENT, 1.0);
|
||||||
|
let fh = fh.clamp(CropRect::MIN_EXTENT, 1.0);
|
||||||
|
CropRect {
|
||||||
|
x: (1.0 - fw) * 0.5,
|
||||||
|
y: (1.0 - fh) * 0.5,
|
||||||
|
width: fw,
|
||||||
|
height: fh,
|
||||||
|
}
|
||||||
|
.normalised()
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3
|
/// TRACES: FR-DEV-3
|
||||||
/// Where an output point comes from in the source, both in normalised
|
/// Where an output point comes from in the source, both in normalised
|
||||||
/// `0..1` coordinates.
|
/// `0..1` coordinates.
|
||||||
@@ -782,6 +1127,15 @@ impl Framing {
|
|||||||
p = (p.0 * c - p.1 * s, p.0 * s + p.1 * c);
|
p = (p.0 * c - p.1 * s, p.0 * s + p.1 * c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(m) = self.keystone_map() {
|
||||||
|
p = match m.apply((f64::from(p.0 / fx), f64::from(p.1))) {
|
||||||
|
Some((x, y)) => (x as f32 * fx, y as f32),
|
||||||
|
// Beyond the map's horizon: no source point at all, reported
|
||||||
|
// as one far outside the frame rather than as a NaN.
|
||||||
|
None => (1e6, 1e6),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let (turns, flip_h, flip_v) = self.effective();
|
let (turns, flip_h, flip_v) = self.effective();
|
||||||
p = match turns {
|
p = match turns {
|
||||||
1 => (p.1 * ax, -p.0 / fx),
|
1 => (p.1 * ax, -p.0 / fx),
|
||||||
@@ -824,6 +1178,13 @@ impl Framing {
|
|||||||
_ => p,
|
_ => p,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Some(m) = self.keystone_map() {
|
||||||
|
p = match m.inverse().apply((f64::from(p.0 / fx), f64::from(p.1))) {
|
||||||
|
Some((x, y)) => (x as f32 * fx, y as f32),
|
||||||
|
None => (1e6, 1e6),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if self.angle != 0.0 {
|
if self.angle != 0.0 {
|
||||||
let rad = -self.angle * PI / 180.0;
|
let rad = -self.angle * PI / 180.0;
|
||||||
let (s, c) = (rad.sin(), rad.cos());
|
let (s, c) = (rad.sin(), rad.cos());
|
||||||
@@ -865,6 +1226,19 @@ impl Framing {
|
|||||||
// identical whether or not the user is zoomed in, and costs no extra
|
// identical whether or not the user is zoomed in, and costs no extra
|
||||||
// uniform slot.
|
// uniform slot.
|
||||||
let rect = self.visible_rect();
|
let rect = self.visible_rect();
|
||||||
|
// The perspective map by columns, so the prologue can apply it as
|
||||||
|
// three multiply-adds. The identity when there is none: the slots
|
||||||
|
// exist either way and the prologue does not read them.
|
||||||
|
let m = self
|
||||||
|
.keystone_map()
|
||||||
|
.unwrap_or(Homography([
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0],
|
||||||
|
[0.0, 0.0, 1.0],
|
||||||
|
]))
|
||||||
|
.0;
|
||||||
|
let col = |c: usize| [m[0][c] as f32, m[1][c] as f32, m[2][c] as f32, 0.0];
|
||||||
|
let [c0, c1, c2] = [col(0), col(1), col(2)];
|
||||||
[
|
[
|
||||||
rect.x,
|
rect.x,
|
||||||
rect.y,
|
rect.y,
|
||||||
@@ -874,6 +1248,18 @@ impl Framing {
|
|||||||
rad.cos(),
|
rad.cos(),
|
||||||
0.0,
|
0.0,
|
||||||
0.0,
|
0.0,
|
||||||
|
c0[0],
|
||||||
|
c0[1],
|
||||||
|
c0[2],
|
||||||
|
c0[3],
|
||||||
|
c1[0],
|
||||||
|
c1[1],
|
||||||
|
c1[2],
|
||||||
|
c1[3],
|
||||||
|
c2[0],
|
||||||
|
c2[1],
|
||||||
|
c2[2],
|
||||||
|
c2[3],
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,6 +1358,28 @@ impl Framing {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.has_keystone() {
|
||||||
|
// TRACES: FR-DEV-20
|
||||||
|
// After the straightening and before the turns, so the keystone
|
||||||
|
// acts on the photograph as it is shown. The map is measured in
|
||||||
|
// fractions of the frame (see `Framing::keystone_map`), hence the
|
||||||
|
// aspect divided out and put back. A point past the map's horizon
|
||||||
|
// has no source at all and is sent far outside it, where the
|
||||||
|
// sampler's bounds test renders it void.
|
||||||
|
s.push_str(
|
||||||
|
"
|
||||||
|
// Perspective: the straightened frame onto a trapezoid of the source.
|
||||||
|
let key_n = vec2<f32>(p.x / frame_aspect.x, p.y);
|
||||||
|
let key_h = u.keystone_c0.xyz * key_n.x + u.keystone_c1.xyz * key_n.y + u.keystone_c2.xyz;
|
||||||
|
p = select(
|
||||||
|
vec2<f32>(1.0e6),
|
||||||
|
vec2<f32>(key_h.x / key_h.z * frame_aspect.x, key_h.y / key_h.z),
|
||||||
|
key_h.z > 1.0e-6,
|
||||||
|
);
|
||||||
|
",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// The user's turns and mirrors composed with the file's stored
|
// The user's turns and mirrors composed with the file's stored
|
||||||
// orientation. One permutation covers both, so honouring the EXIF tag
|
// orientation. One permutation covers both, so honouring the EXIF tag
|
||||||
// adds no per-pixel work over an untagged file.
|
// adds no per-pixel work over an untagged file.
|
||||||
@@ -1040,13 +1448,15 @@ impl Framing {
|
|||||||
| u64::from(flip_v) << 3
|
| u64::from(flip_v) << 3
|
||||||
| u64::from(turns) << 4
|
| u64::from(turns) << 4
|
||||||
| u64::from(self.is_active()) << 6
|
| u64::from(self.is_active()) << 6
|
||||||
|
| u64::from(self.has_keystone()) << 7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Floats the framing block occupies in the generated uniform struct.
|
/// Floats the framing block occupies in the generated uniform struct.
|
||||||
///
|
///
|
||||||
/// Two `vec4`s: the crop rect, and the angle's sin/cos with padding.
|
/// Five `vec4`s: the crop rect, the angle's sin/cos with padding, and the
|
||||||
pub const FRAMING_UNIFORM_FIELDS: usize = 8;
|
/// perspective map's three columns, each padded.
|
||||||
|
pub const FRAMING_UNIFORM_FIELDS: usize = 20;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -2025,6 +2435,8 @@ mod tests {
|
|||||||
(CROP_Y, 0.2),
|
(CROP_Y, 0.2),
|
||||||
(CROP_W, 0.5),
|
(CROP_W, 0.5),
|
||||||
(CROP_H, 0.4),
|
(CROP_H, 0.4),
|
||||||
|
(KEYSTONE_V, 35.0),
|
||||||
|
(KEYSTONE_H, -20.0),
|
||||||
] {
|
] {
|
||||||
f.set_param(id, v);
|
f.set_param(id, v);
|
||||||
assert_eq!(f.param(id), v, "{id} did not round-trip");
|
assert_eq!(f.param(id), v, "{id} did not round-trip");
|
||||||
@@ -2241,6 +2653,8 @@ mod tests {
|
|||||||
f.rotate_quarters(turns);
|
f.rotate_quarters(turns);
|
||||||
f.set_param(FLIP_H, 1.0);
|
f.set_param(FLIP_H, 1.0);
|
||||||
f.set_param(FLIP_V, 1.0);
|
f.set_param(FLIP_V, 1.0);
|
||||||
|
f.set_param(KEYSTONE_V, 60.0);
|
||||||
|
f.set_param(KEYSTONE_H, -25.0);
|
||||||
|
|
||||||
for out in [(0.0, 0.0), (0.5, 0.5), (0.2, 0.9), (0.95, 0.05)] {
|
for out in [(0.0, 0.0), (0.5, 0.5), (0.2, 0.9), (0.95, 0.05)] {
|
||||||
let src = f.source_at(out, SRC.0, SRC.1);
|
let src = f.source_at(out, SRC.0, SRC.1);
|
||||||
@@ -2316,6 +2730,27 @@ mod tests {
|
|||||||
"the three-turn permutation moved; `source_at` must move with it"
|
"the three-turn permutation moved; `source_at` must move with it"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The perspective step: the map applied in fractions of the frame,
|
||||||
|
// which is what `source_at` divides the aspect out for.
|
||||||
|
let mut f = Framing::new();
|
||||||
|
f.set_param(KEYSTONE_V, 40.0);
|
||||||
|
let prologue = f.wgsl_prologue();
|
||||||
|
assert!(
|
||||||
|
prologue.contains("let key_n = vec2<f32>(p.x / frame_aspect.x, p.y);")
|
||||||
|
&& prologue.contains("key_h.x / key_h.z * frame_aspect.x"),
|
||||||
|
"the perspective step moved; `source_at` must move with it"
|
||||||
|
);
|
||||||
|
// After the straightening and before the turns, as `source_at` has it.
|
||||||
|
let mut f = Framing::new();
|
||||||
|
f.set_param(KEYSTONE_V, 40.0);
|
||||||
|
f.set_param(ANGLE, 3.0);
|
||||||
|
f.rotate_quarters(1);
|
||||||
|
let prologue = f.wgsl_prologue();
|
||||||
|
let straighten = prologue.find("// Straighten").unwrap();
|
||||||
|
let keystone = prologue.find("// Perspective").unwrap();
|
||||||
|
let turn = prologue.find("90° clockwise").unwrap();
|
||||||
|
assert!(straighten < keystone && keystone < turn, "{prologue}");
|
||||||
|
|
||||||
// And the sampler's last step, which lives in `operation.rs` and is
|
// And the sampler's last step, which lives in `operation.rs` and is
|
||||||
// the half of the map this file does not emit.
|
// the half of the map this file does not emit.
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2323,4 +2758,241 @@ mod tests {
|
|||||||
"the sampler's return to texture coordinates moved"
|
"the sampler's return to texture coordinates moved"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- perspective (FR-DEV-20) -----------------------------------------
|
||||||
|
|
||||||
|
/// A grid over the whole output frame, edges included.
|
||||||
|
fn grid() -> impl Iterator<Item = (f32, f32)> {
|
||||||
|
(0..=10).flat_map(|j| (0..=10).map(move |i| (i as f32 / 10.0, j as f32 / 10.0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inside(p: (f32, f32)) -> bool {
|
||||||
|
(-1e-4..=1.0 + 1e-4).contains(&p.0) && (-1e-4..=1.0 + 1e-4).contains(&p.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_keystone_is_an_edit_and_a_resample() {
|
||||||
|
let mut f = Framing::new();
|
||||||
|
let neutral = f.structure_key();
|
||||||
|
f.set_param(KEYSTONE_V, 30.0);
|
||||||
|
assert!(f.is_active());
|
||||||
|
assert!(f.edits_image(), "a keystone must light the modified dot");
|
||||||
|
assert!(f.needs_interpolation());
|
||||||
|
assert_ne!(f.structure_key(), neutral);
|
||||||
|
assert!(f.wgsl_prologue().contains("u.keystone_c0"));
|
||||||
|
// Neither the output size nor the crop moves: the frame is reshaped
|
||||||
|
// inside itself, so what the user cropped stays cropped.
|
||||||
|
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
|
||||||
|
assert!(f.crop().is_full());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_keystone_magnitude_does_not_reach_the_structure_key() {
|
||||||
|
// Dragging the slider is a uniform upload, never a shader build.
|
||||||
|
let mut f = Framing::new();
|
||||||
|
f.set_param(KEYSTONE_V, 10.0);
|
||||||
|
let key = f.structure_key();
|
||||||
|
for (v, h) in [(80.0, 0.0), (-45.0, 30.0), (1.0, -100.0)] {
|
||||||
|
f.set_param(KEYSTONE_V, v);
|
||||||
|
f.set_param(KEYSTONE_H, h);
|
||||||
|
assert_eq!(f.structure_key(), key, "{v}/{h} forced a recompile");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_keystone_is_clamped_to_its_travel() {
|
||||||
|
let mut f = Framing::new();
|
||||||
|
f.set_param(KEYSTONE_V, 1e9);
|
||||||
|
f.set_param(KEYSTONE_H, f32::NAN);
|
||||||
|
assert_eq!(f.keystone(), (MAX_KEYSTONE, 0.0));
|
||||||
|
assert!(f.uniforms().iter().all(|v| v.is_finite()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_keystone_alone_never_reaches_outside_the_source() {
|
||||||
|
// The design decision the crop relies on: the output frame is mapped
|
||||||
|
// onto a trapezoid *inside* the source, so no corner goes empty and
|
||||||
|
// a crop drawn before the keystone is still a crop of the picture.
|
||||||
|
for (v, h) in [
|
||||||
|
(100.0, 0.0),
|
||||||
|
(-100.0, 0.0),
|
||||||
|
(0.0, 100.0),
|
||||||
|
(0.0, -100.0),
|
||||||
|
(100.0, 100.0),
|
||||||
|
(-100.0, 100.0),
|
||||||
|
(37.0, -64.0),
|
||||||
|
] {
|
||||||
|
for turns in 0..4 {
|
||||||
|
let mut f = Framing::new();
|
||||||
|
f.rotate_quarters(turns);
|
||||||
|
f.set_param(KEYSTONE_V, v);
|
||||||
|
f.set_param(KEYSTONE_H, h);
|
||||||
|
for out in grid() {
|
||||||
|
let src = f.source_at(out, SRC.0, SRC.1);
|
||||||
|
assert!(inside(src), "{v}/{h}, {turns} turn(s): {out:?} -> {src:?}");
|
||||||
|
}
|
||||||
|
assert!(f.max_inscribed_crop(SRC.0, SRC.1).is_full());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_vertical_keystone_makes_upward_converging_lines_parallel() {
|
||||||
|
// What the control is for. Output columns are straight verticals;
|
||||||
|
// with a positive keystone each must come from a straight source line
|
||||||
|
// that leans in toward the centre as it rises — the shape a building
|
||||||
|
// has when photographed looking up.
|
||||||
|
let mut f = Framing::new();
|
||||||
|
f.set_param(KEYSTONE_V, 60.0);
|
||||||
|
|
||||||
|
for x in [0.1f32, 0.3, 0.7, 0.9] {
|
||||||
|
let bottom = f.source_at((x, 1.0), SRC.0, SRC.1);
|
||||||
|
let middle = f.source_at((x, 0.5), SRC.0, SRC.1);
|
||||||
|
let top = f.source_at((x, 0.0), SRC.0, SRC.1);
|
||||||
|
|
||||||
|
// Straight: the middle sits on the line through the two ends.
|
||||||
|
let cross = (top.0 - bottom.0) * (middle.1 - bottom.1)
|
||||||
|
- (top.1 - bottom.1) * (middle.0 - bottom.0);
|
||||||
|
assert!(cross.abs() < 1e-4, "column {x} is not a straight line");
|
||||||
|
// Leaning in: the top is nearer the centre than the bottom.
|
||||||
|
assert!(
|
||||||
|
(top.0 - 0.5).abs() < (bottom.0 - 0.5).abs(),
|
||||||
|
"column {x}: top {top:?} is not inside bottom {bottom:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bottom row is left where it was; the top row is the one spread.
|
||||||
|
close(
|
||||||
|
f.source_at((0.0, 1.0), SRC.0, SRC.1),
|
||||||
|
(0.0, 1.0),
|
||||||
|
"bottom-left",
|
||||||
|
);
|
||||||
|
close(
|
||||||
|
f.source_at((0.0, 0.0), SRC.0, SRC.1),
|
||||||
|
(0.15, 0.0),
|
||||||
|
"top-left",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_horizontal_keystone_spreads_the_right_hand_side() {
|
||||||
|
let mut f = Framing::new();
|
||||||
|
f.set_param(KEYSTONE_H, 100.0);
|
||||||
|
// The right-hand column comes from half the source's height.
|
||||||
|
close(
|
||||||
|
f.source_at((1.0, 0.0), SRC.0, SRC.1),
|
||||||
|
(1.0, 0.25),
|
||||||
|
"top-right",
|
||||||
|
);
|
||||||
|
close(
|
||||||
|
f.source_at((1.0, 1.0), SRC.0, SRC.1),
|
||||||
|
(1.0, 0.75),
|
||||||
|
"bottom-right",
|
||||||
|
);
|
||||||
|
close(
|
||||||
|
f.source_at((0.0, 0.0), SRC.0, SRC.1),
|
||||||
|
(0.0, 0.0),
|
||||||
|
"top-left",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_keystone_acts_on_the_frame_as_shown() {
|
||||||
|
// A portrait frame the camera stored on its side: "vertical" is the
|
||||||
|
// frame's displayed height, so the same keystone must move the same
|
||||||
|
// *displayed* points whatever the file's stored orientation.
|
||||||
|
let mut upright = Framing::new();
|
||||||
|
upright.set_param(KEYSTONE_V, 50.0);
|
||||||
|
let mut sideways = Framing::new();
|
||||||
|
sideways.set_baseline(dr_types::Orientation::from_exif(6));
|
||||||
|
sideways.set_param(KEYSTONE_V, 50.0);
|
||||||
|
|
||||||
|
// Compare in the displayed frame: map the sideways result back
|
||||||
|
// through the orientation alone.
|
||||||
|
let mut turn_only = Framing::new();
|
||||||
|
turn_only.set_baseline(dr_types::Orientation::from_exif(6));
|
||||||
|
for out in grid() {
|
||||||
|
let a = upright.source_at(out, SRC.1, SRC.0);
|
||||||
|
let b = turn_only.output_at(sideways.source_at(out, SRC.0, SRC.1), SRC.0, SRC.1);
|
||||||
|
close(a, b, "the keystone turned with the file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_inscribed_crop_accounts_for_the_keystone() {
|
||||||
|
// Straightening a keystoned frame: the empty area is no longer the
|
||||||
|
// rotated rectangle's, and the crop must avoid the area that is.
|
||||||
|
for (angle, v, h) in [
|
||||||
|
(5.0f32, 50.0f32, 0.0f32),
|
||||||
|
(-8.0, -70.0, 20.0),
|
||||||
|
(12.0, 100.0, 100.0),
|
||||||
|
(2.0, 0.0, -40.0),
|
||||||
|
] {
|
||||||
|
for turns in [0, 1] {
|
||||||
|
let mut f = Framing::new();
|
||||||
|
f.rotate_quarters(turns);
|
||||||
|
f.set_param(ANGLE, angle);
|
||||||
|
f.set_param(KEYSTONE_V, v);
|
||||||
|
f.set_param(KEYSTONE_H, h);
|
||||||
|
let c = f.max_inscribed_crop(SRC.0, SRC.1);
|
||||||
|
assert!(
|
||||||
|
c.width > 0.3 && c.height > 0.3 && !c.is_full(),
|
||||||
|
"{angle}°/{v}/{h}: {c:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
((c.x + c.width * 0.5) - 0.5).abs() < 1e-4
|
||||||
|
&& ((c.y + c.height * 0.5) - 0.5).abs() < 1e-4,
|
||||||
|
"{c:?} is not centred"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every point of it, edges included, has a source pixel.
|
||||||
|
f.set_crop(c);
|
||||||
|
for out in grid() {
|
||||||
|
let src = f.source_at(out, SRC.0, SRC.1);
|
||||||
|
assert!(inside(src), "{angle}°/{v}/{h}: {out:?} -> {src:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_inscribed_crop_with_a_keystone_is_not_needlessly_small() {
|
||||||
|
// The search must find the best rectangle, not merely a safe one. A
|
||||||
|
// tenth larger in either direction has to reach outside the source.
|
||||||
|
let mut f = Framing::new();
|
||||||
|
f.set_param(ANGLE, 6.0);
|
||||||
|
f.set_param(KEYSTONE_V, 60.0);
|
||||||
|
let c = f.max_inscribed_crop(SRC.0, SRC.1);
|
||||||
|
for (gw, gh) in [(1.1, 1.0), (1.0, 1.1)] {
|
||||||
|
let mut g = f;
|
||||||
|
let (w, h) = (c.width * gw, c.height * gh);
|
||||||
|
g.set_crop(CropRect {
|
||||||
|
x: 0.5 - w * 0.5,
|
||||||
|
y: 0.5 - h * 0.5,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
});
|
||||||
|
let spills = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
|
||||||
|
.into_iter()
|
||||||
|
.any(|out| !inside(g.source_at(out, SRC.0, SRC.1)));
|
||||||
|
// Either the grown rect spills, or it could not grow at all
|
||||||
|
// because the crop was already at the frame's edge on that axis.
|
||||||
|
assert!(
|
||||||
|
spills
|
||||||
|
|| (gw > 1.0 && c.width >= 1.0 - 1e-4)
|
||||||
|
|| (gh > 1.0 && c.height >= 1.0 - 1e-4),
|
||||||
|
"{c:?} grown by {gw}x{gh} still fits"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_the_keystone() {
|
||||||
|
let mut f = Framing::new();
|
||||||
|
f.set_param(KEYSTONE_V, 30.0);
|
||||||
|
f.set_param(KEYSTONE_H, -30.0);
|
||||||
|
f.reset();
|
||||||
|
assert_eq!(f.keystone(), (0.0, 0.0));
|
||||||
|
assert!(!f.is_active());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ pub mod mask;
|
|||||||
pub mod neutral;
|
pub mod neutral;
|
||||||
pub mod operation;
|
pub mod operation;
|
||||||
pub mod ops;
|
pub mod ops;
|
||||||
|
pub mod orphan;
|
||||||
pub mod preset;
|
pub mod preset;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
pub mod spot;
|
pub mod spot;
|
||||||
|
|||||||
@@ -926,6 +926,21 @@ pub enum Join {
|
|||||||
/// erase stroke is a hole in the part it was painted into and reads as
|
/// erase stroke is a hole in the part it was painted into and reads as
|
||||||
/// nothing at all.
|
/// nothing at all.
|
||||||
Subtract,
|
Subtract,
|
||||||
|
/// TRACES: FR-DEV-19a
|
||||||
|
/// Only where both agree. "Keep the part of this mask that is also that."
|
||||||
|
///
|
||||||
|
/// The join that makes cheap criteria precise: a sky is a category *and*
|
||||||
|
/// a luminance band, skin is a subject *and* a hue. Neither alone is the
|
||||||
|
/// selection, and no feather on either makes it one.
|
||||||
|
///
|
||||||
|
/// The product of the two coverages rather than their minimum, because
|
||||||
|
/// that is what one fixed-function blend gives on the device
|
||||||
|
/// (`dst · src`, `docs/dev/mask-editing.md` §5.2) and it agrees with the
|
||||||
|
/// minimum wherever either side is fully in or fully out. Between two soft
|
||||||
|
/// edges it is the softer of the two readings, which is the right way to
|
||||||
|
/// be wrong: an overlap of two partial selections is less certainly
|
||||||
|
/// selected than either.
|
||||||
|
Intersect,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Join {
|
impl Join {
|
||||||
@@ -933,6 +948,7 @@ impl Join {
|
|||||||
match self {
|
match self {
|
||||||
Self::Union => "union",
|
Self::Union => "union",
|
||||||
Self::Subtract => "subtract",
|
Self::Subtract => "subtract",
|
||||||
|
Self::Intersect => "intersect",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,12 +956,30 @@ impl Join {
|
|||||||
Some(match name {
|
Some(match name {
|
||||||
"union" => Self::Union,
|
"union" => Self::Union,
|
||||||
"subtract" => Self::Subtract,
|
"subtract" => Self::Subtract,
|
||||||
|
"intersect" => Self::Intersect,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every variant, for a UI building a choice control.
|
/// TRACES: FR-DEV-19a
|
||||||
pub const ALL: [Join; 2] = [Join::Union, Join::Subtract];
|
/// The coverage this join leaves at one point, given what the mask had
|
||||||
|
/// there (`dst`) and what the part covers (`src`), both in `0..=1`.
|
||||||
|
///
|
||||||
|
/// The definition the device's blend states implement, spelled out once
|
||||||
|
/// on the CPU so a test can hold the GPU to it and a reader can see the
|
||||||
|
/// three set operations side by side without reading `wgpu` enums.
|
||||||
|
pub fn apply(self, dst: f32, src: f32) -> f32 {
|
||||||
|
match self {
|
||||||
|
Self::Union => dst.max(src),
|
||||||
|
Self::Subtract => dst * (1.0 - src),
|
||||||
|
Self::Intersect => dst * src,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every variant, for a UI building a choice control. The order is the
|
||||||
|
/// panel's: a chip cycles through it and a stored index names a place in
|
||||||
|
/// it, so a new join goes on the end.
|
||||||
|
pub const ALL: [Join; 3] = [Join::Union, Join::Subtract, Join::Intersect];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One selection inside a layer's mask.
|
/// One selection inside a layer's mask.
|
||||||
@@ -1580,9 +1614,20 @@ impl MaskLayer {
|
|||||||
// A hidden part is not in the build, whichever way it joins — and a
|
// A hidden part is not in the build, whichever way it joins — and a
|
||||||
// hidden base hands its role to the first part that is shown, which
|
// hidden base hands its role to the first part that is shown, which
|
||||||
// is why "adds" is asked of the shown parts rather than of index 0.
|
// is why "adds" is asked of the shown parts rather than of index 0.
|
||||||
|
//
|
||||||
|
// Folded rather than asked with `any`, because an intersection can
|
||||||
|
// take away everything the parts before it added: a subject
|
||||||
|
// intersected with an unpainted brush covers nothing. An inverted
|
||||||
|
// part is taken to cover, whatever its source — an inverted empty
|
||||||
|
// brush is the whole frame, and saying "covers nothing" of a layer
|
||||||
|
// that does would hide an adjustment the photographer made.
|
||||||
self.shown_parts()
|
self.shown_parts()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.any(|(i, p)| (i == 0 || p.join == Join::Union) && p.covers())
|
.fold(false, |acc, (i, p)| match (i, p.join) {
|
||||||
|
(0, _) | (_, Join::Union) => acc || p.covers(),
|
||||||
|
(_, Join::Subtract) => acc,
|
||||||
|
(_, Join::Intersect) => acc && (p.covers() || p.invert),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-19a
|
/// TRACES: FR-DEV-19a
|
||||||
@@ -3037,6 +3082,69 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-19a
|
||||||
|
/// The three joins, pointwise, on every pair of coverages a part and a
|
||||||
|
/// mask can meet at: fully in, fully out, and each soft edge. Intersection
|
||||||
|
/// is the product, which agrees with the minimum wherever either side is
|
||||||
|
/// decided and is the softer reading where both are not.
|
||||||
|
#[test]
|
||||||
|
fn the_joins_are_max_cut_and_product() {
|
||||||
|
let levels = [0.0f32, 0.25, 0.5, 0.75, 1.0];
|
||||||
|
for &dst in &levels {
|
||||||
|
for &src in &levels {
|
||||||
|
assert_eq!(Join::Union.apply(dst, src), dst.max(src));
|
||||||
|
assert_eq!(Join::Subtract.apply(dst, src), dst * (1.0 - src));
|
||||||
|
let meet = Join::Intersect.apply(dst, src);
|
||||||
|
assert_eq!(meet, dst * src);
|
||||||
|
assert!(meet <= dst.min(src), "never more than either side");
|
||||||
|
if dst == 0.0 || dst == 1.0 || src == 0.0 || src == 1.0 {
|
||||||
|
assert_eq!(meet, dst.min(src), "the minimum where either is decided");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stored index names a place in [`Join::ALL`], and a sidecar names a
|
||||||
|
/// join by word: both have to survive a third join arriving, which means
|
||||||
|
/// the first two keep their places and every name reads back as itself.
|
||||||
|
#[test]
|
||||||
|
fn every_join_reads_back_by_name_and_keeps_its_place() {
|
||||||
|
assert_eq!(Join::ALL[0], Join::Union);
|
||||||
|
assert_eq!(Join::ALL[1], Join::Subtract);
|
||||||
|
for join in Join::ALL {
|
||||||
|
assert_eq!(Join::from_name(join.name()), Some(join));
|
||||||
|
}
|
||||||
|
assert_eq!(Join::from_name("intersect"), Some(Join::Intersect));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-19a
|
||||||
|
/// An intersection can empty a mask the parts before it filled: a range
|
||||||
|
/// meeting an unpainted brush selects nothing, and rasterising it would
|
||||||
|
/// spend a slice to draw an empty field. Painting the brush, or inverting
|
||||||
|
/// it, gives the intersection something to keep.
|
||||||
|
#[test]
|
||||||
|
fn an_intersection_with_nothing_covers_nothing() {
|
||||||
|
let mut layer = MaskLayer::new("m1", MaskSource::highlights());
|
||||||
|
layer.set_param("exposure", ParamId("exposure"), 1.0);
|
||||||
|
layer.push_part(MaskPart::painted("p2", Join::Intersect));
|
||||||
|
assert!(
|
||||||
|
!layer.is_active(),
|
||||||
|
"a range intersected with an unpainted brush selects nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
layer.part_mut(1).expect("p2").invert = true;
|
||||||
|
assert!(layer.is_active(), "inverted, the empty brush is everywhere");
|
||||||
|
layer.part_mut(1).expect("p2").invert = false;
|
||||||
|
|
||||||
|
layer.begin_stroke(1, false, 0.1, 0.5, 1.0);
|
||||||
|
layer.extend_stroke(1, 0.5, 0.5);
|
||||||
|
layer.end_stroke(1);
|
||||||
|
assert!(layer.is_active(), "and painted, it keeps what it covers");
|
||||||
|
|
||||||
|
layer.part_mut(1).expect("p2").hidden = true;
|
||||||
|
assert!(layer.is_active(), "hidden, it is out of the build");
|
||||||
|
}
|
||||||
|
|
||||||
/// One stale part is a stale layer: the mask is the fold over all of them,
|
/// One stale part is a stale layer: the mask is the fold over all of them,
|
||||||
/// so a part that would draw a confidently wrong shape makes the result
|
/// so a part that would draw a confidently wrong shape makes the result
|
||||||
/// wrong whichever way it joins.
|
/// wrong whichever way it joins.
|
||||||
|
|||||||
@@ -801,7 +801,11 @@ fn compose_inner(
|
|||||||
\x20 // angle as sin/cos — a trig call per pixel would recompute a\n\
|
\x20 // angle as sin/cos — a trig call per pixel would recompute a\n\
|
||||||
\x20 // value that is constant across the dispatch.\n\
|
\x20 // value that is constant across the dispatch.\n\
|
||||||
\x20 crop_rect: vec4<f32>,\n\
|
\x20 crop_rect: vec4<f32>,\n\
|
||||||
\x20 framing_angle: vec4<f32>,\n",
|
\x20 framing_angle: vec4<f32>,\n\
|
||||||
|
\x20 // The perspective map (FR-DEV-20) by columns, `.w` unused.\n\
|
||||||
|
\x20 keystone_c0: vec4<f32>,\n\
|
||||||
|
\x20 keystone_c1: vec4<f32>,\n\
|
||||||
|
\x20 keystone_c2: vec4<f32>,\n",
|
||||||
);
|
);
|
||||||
uniform_values.extend_from_slice(&framing.uniforms());
|
uniform_values.extend_from_slice(&framing.uniforms());
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,645 @@
|
|||||||
|
//! TRACES: FR-DEV-17
|
||||||
|
//! Whether a crop leaves a mask layer's work outside the frame.
|
||||||
|
//!
|
||||||
|
//! # Why this is a question worth asking
|
||||||
|
//!
|
||||||
|
//! Mask geometry is stored in normalised *source* coordinates (see
|
||||||
|
//! [`MaskSource::Linear`] and [`Stroke::points`]), so a tighter crop never
|
||||||
|
//! destroys a layer. It makes it invisible — which is worse, because nothing
|
||||||
|
//! announces it. The layer is still in the panel, still in the sidecar, still
|
||||||
|
//! costing a rasterisation, and its adjustment lands on pixels nobody will
|
||||||
|
//! ever see. The crop that did that is exactly the kind of edit made early
|
||||||
|
//! and quickly, and the loss is found, if at all, much later.
|
||||||
|
//!
|
||||||
|
//! This module answers one question on the CPU, cheaply enough to ask once
|
||||||
|
//! per committed crop: *which layers did this change of crop take out of the
|
||||||
|
//! picture?* The interface turns the answer into a notice. It never refuses
|
||||||
|
//! the crop — the photographer may well mean it.
|
||||||
|
//!
|
||||||
|
//! # How it is measured
|
||||||
|
//!
|
||||||
|
//! Each layer's mask is sampled on a [`GRID`]×[`GRID`] lattice over the
|
||||||
|
//! source, with the same geometry the mask shader uses (`mask.wgsl`), folded
|
||||||
|
//! part by part with the layer's joins and inversions. The sample points are
|
||||||
|
//! then mapped through the framing — crop, straighten, turns and flips — into
|
||||||
|
//! the frame, and the layer's *share inside* is the coverage that lands in
|
||||||
|
//! the crop over the coverage there is. A layer is hidden by a crop when that
|
||||||
|
//! share falls below [`HIDDEN_SHARE`] and was not already below it.
|
||||||
|
//!
|
||||||
|
//! The lattice is coarse on purpose. The question is "is this layer mostly
|
||||||
|
//! gone", not "which pixels are": the edge treatment — feather, morphology —
|
||||||
|
//! moves a boundary by a few hundredths of the frame and cannot turn a layer
|
||||||
|
//! that is mostly inside into one that is mostly outside, so it is left out.
|
||||||
|
//!
|
||||||
|
//! # What cannot be orphaned
|
||||||
|
//!
|
||||||
|
//! A range ([`MaskSource::Luminance`], [`MaskSource::Colour`]) selects by a
|
||||||
|
//! property of the picture, so wherever the crop falls it selects whatever
|
||||||
|
//! part of the picture remains — there is nothing for a crop to strand. A
|
||||||
|
//! region selection needs the segmentation's label map, which this crate does
|
||||||
|
//! not hold, and a model's selection with no raster to hand has nothing to
|
||||||
|
//! measure. A layer that *adds* any of these is therefore never reported: a
|
||||||
|
//! false alarm on the common path would teach the photographer to dismiss the
|
||||||
|
//! notice unread, which is the failure it exists to prevent. Subtracting one
|
||||||
|
//! is ignored, which can only make the layer look larger — the safe side.
|
||||||
|
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
use crate::framing::{CropRect, Framing};
|
||||||
|
use crate::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack, Stroke};
|
||||||
|
|
||||||
|
/// Samples per axis over the source.
|
||||||
|
///
|
||||||
|
/// 4096 points: enough that a brush dab a twentieth of the frame across is
|
||||||
|
/// several samples wide, and few enough that the whole stack is measured in
|
||||||
|
/// well under a frame when a crop is let go.
|
||||||
|
pub const GRID: usize = 64;
|
||||||
|
|
||||||
|
/// The share of a layer's coverage below which it counts as cropped away.
|
||||||
|
///
|
||||||
|
/// "Mostly outside" rather than "entirely": a gradient reduced to a sliver
|
||||||
|
/// along one edge, or a subject of which one elbow survives, has lost the
|
||||||
|
/// work as surely as one that is wholly gone. Low enough that an ordinary
|
||||||
|
/// recomposition which trims part of a subject is not reported.
|
||||||
|
pub const HIDDEN_SHARE: f32 = 0.1;
|
||||||
|
|
||||||
|
/// A model's selection, as bytes over the source at some proxy size.
|
||||||
|
///
|
||||||
|
/// Supplied by the caller for a part whose own [`MaskPart::coverage`] is not
|
||||||
|
/// set — the live session holds its model output outside the graph and folds
|
||||||
|
/// it into the parts only when saving.
|
||||||
|
pub struct Raster<'a> {
|
||||||
|
pub values: Cow<'a, [u8]>,
|
||||||
|
pub width: usize,
|
||||||
|
pub height: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One layer's coverage, sampled over the source.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Footprint {
|
||||||
|
/// Row-major over the lattice, `0.0..=1.0`, one per cell centre.
|
||||||
|
weights: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Footprint {
|
||||||
|
/// The layer's coverage on the lattice, or `None` when it has none to
|
||||||
|
/// strand — see the module header for which layers those are.
|
||||||
|
///
|
||||||
|
/// `model` is asked for the raster behind a subject or category part that
|
||||||
|
/// carries none of its own.
|
||||||
|
pub fn of<'r>(
|
||||||
|
layer: &MaskLayer,
|
||||||
|
source: (u32, u32),
|
||||||
|
model: &dyn Fn(&MaskPart) -> Option<Raster<'r>>,
|
||||||
|
) -> Option<Self> {
|
||||||
|
let mut acc: Option<Vec<f32>> = None;
|
||||||
|
for (i, part) in layer.shown_parts().enumerate() {
|
||||||
|
let adds = i == 0 || part.join == Join::Union;
|
||||||
|
let Some(values) = part_values(part, source, model) else {
|
||||||
|
if adds {
|
||||||
|
// It follows the picture, or cannot be measured: either
|
||||||
|
// way it is not a shape a crop can leave behind.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
acc = Some(match acc {
|
||||||
|
None => values,
|
||||||
|
Some(mut a) => {
|
||||||
|
for (d, s) in a.iter_mut().zip(values) {
|
||||||
|
*d = part.join.apply(*d, s);
|
||||||
|
}
|
||||||
|
a
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut weights = acc?;
|
||||||
|
if layer.invert {
|
||||||
|
weights.iter_mut().for_each(|w| *w = 1.0 - *w);
|
||||||
|
}
|
||||||
|
Some(Self { weights })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Of this layer's coverage, the share `inside` marks as in frame.
|
||||||
|
/// `None` when there is no coverage at all.
|
||||||
|
fn share(&self, inside: &[bool]) -> Option<f32> {
|
||||||
|
let (mut total, mut kept) = (0.0f32, 0.0f32);
|
||||||
|
for (w, &i) in self.weights.iter().zip(inside) {
|
||||||
|
total += w;
|
||||||
|
if i {
|
||||||
|
kept += w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(total > 1e-3).then(|| kept / total)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Of this layer's coverage, the share `framing`'s crop keeps.
|
||||||
|
pub fn share_inside(&self, framing: &Framing, source: (u32, u32)) -> Option<f32> {
|
||||||
|
self.share(&in_frame(framing, source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-17
|
||||||
|
/// The layers that changing the framing from `before` to `after` took out of
|
||||||
|
/// the picture, in stack order.
|
||||||
|
///
|
||||||
|
/// Only those it *newly* hid: a layer already cropped away by `before` is not
|
||||||
|
/// reported again, or every later adjustment of the crop would repeat a notice
|
||||||
|
/// the photographer has already answered.
|
||||||
|
///
|
||||||
|
/// The view (zoom and pan) of either framing is ignored. It is a way of
|
||||||
|
/// looking, not the frame.
|
||||||
|
pub fn hidden_by_crop<'m, 'r>(
|
||||||
|
masks: &'m MaskStack,
|
||||||
|
before: &Framing,
|
||||||
|
after: &Framing,
|
||||||
|
source: (u32, u32),
|
||||||
|
model: &dyn Fn(&MaskPart) -> Option<Raster<'r>>,
|
||||||
|
) -> Vec<&'m MaskLayer> {
|
||||||
|
if masks.is_empty() || framed_alike(before, after) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let was = in_frame(before, source);
|
||||||
|
let now = in_frame(after, source);
|
||||||
|
masks
|
||||||
|
.layers()
|
||||||
|
.iter()
|
||||||
|
.filter(|layer| {
|
||||||
|
let Some(print) = Footprint::of(layer, source, model) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let (Some(was), Some(now)) = (print.share(&was), print.share(&now)) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
was >= HIDDEN_SHARE && now < HIDDEN_SHARE
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the two frame the same part of the source, zoom aside.
|
||||||
|
fn framed_alike(a: &Framing, b: &Framing) -> bool {
|
||||||
|
let mut a = *a;
|
||||||
|
let mut b = *b;
|
||||||
|
a.set_view(CropRect::default());
|
||||||
|
b.set_view(CropRect::default());
|
||||||
|
a == b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which lattice cells the framing's crop keeps.
|
||||||
|
fn in_frame(framing: &Framing, source: (u32, u32)) -> Vec<bool> {
|
||||||
|
let mut f = *framing;
|
||||||
|
f.set_view(CropRect::default());
|
||||||
|
lattice()
|
||||||
|
.map(|uv| {
|
||||||
|
let (x, y) = f.output_at(uv, source.0, source.1);
|
||||||
|
(0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cell centres, row-major, in normalised source coordinates.
|
||||||
|
fn lattice() -> impl Iterator<Item = (f32, f32)> {
|
||||||
|
let step = 1.0 / GRID as f32;
|
||||||
|
(0..GRID).flat_map(move |y| {
|
||||||
|
(0..GRID).map(move |x| ((x as f32 + 0.5) * step, (y as f32 + 0.5) * step))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One part's coverage on the lattice, its own inversion applied, or `None`
|
||||||
|
/// where it cannot be measured as a shape.
|
||||||
|
fn part_values<'r>(
|
||||||
|
part: &MaskPart,
|
||||||
|
source: (u32, u32),
|
||||||
|
model: &dyn Fn(&MaskPart) -> Option<Raster<'r>>,
|
||||||
|
) -> Option<Vec<f32>> {
|
||||||
|
let aspect = source.0.max(1) as f32 / source.1.max(1) as f32;
|
||||||
|
let mut values: Vec<f32> = match &part.source {
|
||||||
|
MaskSource::Linear {
|
||||||
|
centre,
|
||||||
|
angle,
|
||||||
|
width,
|
||||||
|
} => {
|
||||||
|
let axis = (angle.cos(), angle.sin());
|
||||||
|
lattice()
|
||||||
|
.map(|(u, v)| {
|
||||||
|
let d = (u - centre.0) * aspect * axis.0 + (v - centre.1) * axis.1;
|
||||||
|
if *width <= 0.0 {
|
||||||
|
if d >= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
smoothstep(-width * 0.5, width * 0.5, d)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
MaskSource::Radial {
|
||||||
|
centre,
|
||||||
|
radii,
|
||||||
|
angle,
|
||||||
|
feather,
|
||||||
|
} => {
|
||||||
|
let (sa, ca) = (-angle).sin_cos();
|
||||||
|
let radii = (radii.0.max(1e-6), radii.1.max(1e-6));
|
||||||
|
let edge = feather.clamp(0.0, 1.0);
|
||||||
|
lattice()
|
||||||
|
.map(|(u, v)| {
|
||||||
|
let d = ((u - centre.0) * aspect, v - centre.1);
|
||||||
|
let local = (d.0 * ca - d.1 * sa, d.0 * sa + d.1 * ca);
|
||||||
|
let r = (local.0 / radii.0).hypot(local.1 / radii.1);
|
||||||
|
if edge <= 0.0 {
|
||||||
|
if r <= 1.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
1.0 - smoothstep(1.0 - edge, 1.0, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
MaskSource::Brush { strokes } => brush_values(strokes, source),
|
||||||
|
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
|
||||||
|
if let Some(coverage) = &part.coverage {
|
||||||
|
// The lattice is a regular grid over the source, which is
|
||||||
|
// exactly the resample `decode_at` does.
|
||||||
|
coverage
|
||||||
|
.decode_at(GRID, GRID)
|
||||||
|
.into_iter()
|
||||||
|
.map(|b| f32::from(b) / 255.0)
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
let raster = model(part)?;
|
||||||
|
if raster.width == 0
|
||||||
|
|| raster.height == 0
|
||||||
|
|| raster.values.len() < raster.width * raster.height
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
lattice()
|
||||||
|
.map(|(u, v)| {
|
||||||
|
let x = ((u * raster.width as f32) as usize).min(raster.width - 1);
|
||||||
|
let y = ((v * raster.height as f32) as usize).min(raster.height - 1);
|
||||||
|
f32::from(raster.values[y * raster.width + x]) / 255.0
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MaskSource::Regions { .. } | MaskSource::Luminance { .. } | MaskSource::Colour { .. } => {
|
||||||
|
return None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if part.invert {
|
||||||
|
values.iter_mut().for_each(|w| *w = 1.0 - *w);
|
||||||
|
}
|
||||||
|
Some(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strokes composited in order, the way `fs_brush` and its blend states do.
|
||||||
|
fn brush_values(strokes: &[Stroke], source: (u32, u32)) -> Vec<f32> {
|
||||||
|
// Into units of the shorter edge, as `to_square` does, so a dab is round.
|
||||||
|
let short = source.0.min(source.1).max(1) as f32;
|
||||||
|
let scale = (
|
||||||
|
source.0.max(1) as f32 / short,
|
||||||
|
source.1.max(1) as f32 / short,
|
||||||
|
);
|
||||||
|
let square = |p: (f32, f32)| (p.0 * scale.0, p.1 * scale.1);
|
||||||
|
|
||||||
|
let cells: Vec<(f32, f32)> = lattice().map(square).collect();
|
||||||
|
let mut out = vec![0.0f32; cells.len()];
|
||||||
|
for stroke in strokes.iter().filter(|s| !s.is_empty()) {
|
||||||
|
let points: Vec<(f32, f32)> = stroke.points.iter().copied().map(square).collect();
|
||||||
|
let r = stroke.radius;
|
||||||
|
let inner = r * stroke.hardness.clamp(0.0, 1.0);
|
||||||
|
// Only cells inside the stroke's bounding box can be reached.
|
||||||
|
let (lo, hi) = points.iter().fold(
|
||||||
|
((f32::MAX, f32::MAX), (f32::MIN, f32::MIN)),
|
||||||
|
|(lo, hi), p| {
|
||||||
|
(
|
||||||
|
(lo.0.min(p.0), lo.1.min(p.1)),
|
||||||
|
(hi.0.max(p.0), hi.1.max(p.1)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for (cell, dst) in cells.iter().zip(out.iter_mut()) {
|
||||||
|
if cell.0 < lo.0 - r || cell.0 > hi.0 + r || cell.1 < lo.1 - r || cell.1 > hi.1 + r {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let d = if points.len() == 1 {
|
||||||
|
dist(*cell, points[0])
|
||||||
|
} else {
|
||||||
|
points
|
||||||
|
.windows(2)
|
||||||
|
.map(|s| segment_distance(*cell, s[0], s[1]))
|
||||||
|
.fold(f32::MAX, f32::min)
|
||||||
|
};
|
||||||
|
let c = ((1.0 - smoothstep(inner, r, d)) * stroke.flow).clamp(0.0, 1.0);
|
||||||
|
*dst = if stroke.erase {
|
||||||
|
*dst * (1.0 - c)
|
||||||
|
} else {
|
||||||
|
*dst + c - *dst * c
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dist(a: (f32, f32), b: (f32, f32)) -> f32 {
|
||||||
|
(a.0 - b.0).hypot(a.1 - b.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment_distance(q: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
|
||||||
|
let ab = (b.0 - a.0, b.1 - a.1);
|
||||||
|
let len2 = ab.0 * ab.0 + ab.1 * ab.1;
|
||||||
|
if len2 <= 1e-12 {
|
||||||
|
return dist(q, a);
|
||||||
|
}
|
||||||
|
let t = (((q.0 - a.0) * ab.0 + (q.1 - a.1) * ab.1) / len2).clamp(0.0, 1.0);
|
||||||
|
dist(q, (a.0 + ab.0 * t, a.1 + ab.1 * t))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WGSL's `smoothstep`, including its behaviour when the edges meet.
|
||||||
|
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
|
||||||
|
if e1 <= e0 {
|
||||||
|
return if x < e0 { 0.0 } else { 1.0 };
|
||||||
|
}
|
||||||
|
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
|
||||||
|
t * t * (3.0 - 2.0 * t)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::coverage::Coverage;
|
||||||
|
|
||||||
|
const SOURCE: (u32, u32) = (3000, 2000);
|
||||||
|
|
||||||
|
fn no_model(_: &MaskPart) -> Option<Raster<'static>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cropped(x: f32, y: f32, w: f32, h: f32) -> Framing {
|
||||||
|
let mut f = Framing::new();
|
||||||
|
f.set_crop(CropRect {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
});
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small circle near the source's top-left corner.
|
||||||
|
fn top_left_circle() -> MaskLayer {
|
||||||
|
MaskLayer::new(
|
||||||
|
"m1",
|
||||||
|
MaskSource::Radial {
|
||||||
|
centre: (0.15, 0.15),
|
||||||
|
radii: (0.08, 0.08),
|
||||||
|
angle: 0.0,
|
||||||
|
feather: 0.2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stack(layers: Vec<MaskLayer>) -> MaskStack {
|
||||||
|
let mut s = MaskStack::new();
|
||||||
|
for l in layers {
|
||||||
|
assert!(s.push(l));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hidden(masks: &MaskStack, before: &Framing, after: &Framing) -> Vec<String> {
|
||||||
|
hidden_by_crop(masks, before, after, SOURCE, &no_model)
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| l.id.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_crop_away_from_a_shape_hides_it() {
|
||||||
|
let masks = stack(vec![top_left_circle()]);
|
||||||
|
let after = cropped(0.5, 0.5, 0.5, 0.5);
|
||||||
|
assert_eq!(hidden(&masks, &Framing::new(), &after), vec!["m1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_crop_that_keeps_the_shape_is_silent() {
|
||||||
|
let masks = stack(vec![top_left_circle()]);
|
||||||
|
let after = cropped(0.0, 0.0, 0.6, 0.6);
|
||||||
|
assert!(hidden(&masks, &Framing::new(), &after).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_crop_that_trims_part_of_a_shape_is_silent() {
|
||||||
|
// Half the circle survives, which is a recomposition, not a loss.
|
||||||
|
let masks = stack(vec![top_left_circle()]);
|
||||||
|
let after = cropped(0.15, 0.0, 0.85, 1.0);
|
||||||
|
assert!(hidden(&masks, &Framing::new(), &after).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_layer_already_cropped_away_is_not_reported_again() {
|
||||||
|
let masks = stack(vec![top_left_circle()]);
|
||||||
|
let before = cropped(0.5, 0.5, 0.5, 0.5);
|
||||||
|
let after = cropped(0.6, 0.6, 0.4, 0.4);
|
||||||
|
assert!(hidden(&masks, &before, &after).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uncropping_brings_a_layer_back_and_says_nothing() {
|
||||||
|
let masks = stack(vec![top_left_circle()]);
|
||||||
|
let before = cropped(0.5, 0.5, 0.5, 0.5);
|
||||||
|
assert!(hidden(&masks, &before, &Framing::new()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unchanged_frame_is_silent_whatever_the_zoom() {
|
||||||
|
let masks = stack(vec![top_left_circle()]);
|
||||||
|
let before = cropped(0.5, 0.5, 0.5, 0.5);
|
||||||
|
let mut after = before;
|
||||||
|
after.set_view(CropRect {
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.5,
|
||||||
|
height: 0.5,
|
||||||
|
});
|
||||||
|
assert!(hidden(&masks, &Framing::new(), &after).len() == 1);
|
||||||
|
assert!(hidden(&masks, &before, &after).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_range_follows_the_picture_and_is_never_stranded() {
|
||||||
|
let masks = stack(vec![
|
||||||
|
MaskLayer::new("lum", MaskSource::highlights()),
|
||||||
|
MaskLayer::new("skin", MaskSource::skin_tones()),
|
||||||
|
]);
|
||||||
|
let after = cropped(0.9, 0.9, 0.1, 0.1);
|
||||||
|
assert!(hidden(&masks, &Framing::new(), &after).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_linear_gradient_over_the_bottom_is_hidden_by_keeping_the_top() {
|
||||||
|
let masks = stack(vec![MaskLayer::new(
|
||||||
|
"grad",
|
||||||
|
MaskSource::Linear {
|
||||||
|
centre: (0.5, 0.8),
|
||||||
|
angle: std::f32::consts::FRAC_PI_2,
|
||||||
|
width: 0.05,
|
||||||
|
},
|
||||||
|
)]);
|
||||||
|
assert_eq!(
|
||||||
|
hidden(&masks, &Framing::new(), &cropped(0.0, 0.0, 1.0, 0.5)),
|
||||||
|
vec!["grad"]
|
||||||
|
);
|
||||||
|
assert!(hidden(&masks, &Framing::new(), &cropped(0.0, 0.5, 1.0, 0.5)).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_painted_stroke_is_measured_where_it_was_painted() {
|
||||||
|
let mut layer = MaskLayer::new("paint", MaskSource::brush());
|
||||||
|
layer.begin_stroke(0, false, 0.05, 0.8, 1.0);
|
||||||
|
for i in 0..10 {
|
||||||
|
layer.extend_stroke(0, 0.8 + i as f32 * 0.01, 0.8);
|
||||||
|
}
|
||||||
|
layer.end_stroke(0);
|
||||||
|
let masks = stack(vec![layer]);
|
||||||
|
assert_eq!(
|
||||||
|
hidden(&masks, &Framing::new(), &cropped(0.0, 0.0, 0.5, 0.5)),
|
||||||
|
vec!["paint"]
|
||||||
|
);
|
||||||
|
assert!(hidden(&masks, &Framing::new(), &cropped(0.5, 0.5, 0.5, 0.5)).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unpainted_brush_has_nothing_to_lose() {
|
||||||
|
let masks = stack(vec![MaskLayer::new("empty", MaskSource::brush())]);
|
||||||
|
assert!(hidden(&masks, &Framing::new(), &cropped(0.0, 0.0, 0.3, 0.3)).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_inverted_layer_is_measured_as_what_it_selects() {
|
||||||
|
// Everything *but* a corner circle: a crop into the other corner
|
||||||
|
// keeps most of it.
|
||||||
|
let mut layer = top_left_circle();
|
||||||
|
layer.invert = true;
|
||||||
|
let masks = stack(vec![layer]);
|
||||||
|
assert!(hidden(&masks, &Framing::new(), &cropped(0.5, 0.5, 0.5, 0.5)).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subject_is_measured_from_its_stored_coverage_or_the_model() {
|
||||||
|
// A subject occupying the right-hand quarter of a 40x20 proxy.
|
||||||
|
let (w, h) = (40usize, 20usize);
|
||||||
|
let bytes: Vec<u8> = (0..w * h)
|
||||||
|
.map(|i| if i % w >= 30 { 255 } else { 0 })
|
||||||
|
.collect();
|
||||||
|
let subject = MaskSource::Subject {
|
||||||
|
signature: 1,
|
||||||
|
index: 0,
|
||||||
|
class: "dog".into(),
|
||||||
|
score: 0.9,
|
||||||
|
};
|
||||||
|
let left = cropped(0.0, 0.0, 0.5, 1.0);
|
||||||
|
|
||||||
|
let mut stored = MaskLayer::new("stored", subject.clone());
|
||||||
|
stored.base_mut().coverage = Coverage::encode(&bytes, w, h, 2).map(Arc::new);
|
||||||
|
let live = MaskLayer::new("live", subject);
|
||||||
|
let masks = stack(vec![stored, live]);
|
||||||
|
|
||||||
|
// No model to hand: only the layer carrying its raster is measured.
|
||||||
|
assert_eq!(hidden(&masks, &Framing::new(), &left), vec!["stored"]);
|
||||||
|
|
||||||
|
let model = |_: &MaskPart| {
|
||||||
|
Some(Raster {
|
||||||
|
values: Cow::Borrowed(bytes.as_slice()),
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let ids: Vec<_> = hidden_by_crop(&masks, &Framing::new(), &left, SOURCE, &model)
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| l.id.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(ids, vec!["stored", "live"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_crop_is_read_in_the_turned_frame() {
|
||||||
|
// Turned a quarter clockwise, the source's top-left lands at the
|
||||||
|
// frame's top-right, so keeping the right half of the frame keeps it.
|
||||||
|
let masks = stack(vec![top_left_circle()]);
|
||||||
|
let mut before = Framing::new();
|
||||||
|
before.rotate_quarters(1);
|
||||||
|
let mut right = before;
|
||||||
|
right.set_crop(CropRect {
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.0,
|
||||||
|
width: 0.5,
|
||||||
|
height: 1.0,
|
||||||
|
});
|
||||||
|
let mut left = before;
|
||||||
|
left.set_crop(CropRect {
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
width: 0.5,
|
||||||
|
height: 1.0,
|
||||||
|
});
|
||||||
|
assert!(hidden(&masks, &before, &right).is_empty());
|
||||||
|
assert_eq!(hidden(&masks, &before, &left), vec!["m1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subtracted_range_does_not_hide_the_shape_it_cuts() {
|
||||||
|
let mut layer = top_left_circle();
|
||||||
|
assert!(layer.push_part(MaskPart::new(
|
||||||
|
"p2",
|
||||||
|
Join::Subtract,
|
||||||
|
MaskSource::highlights()
|
||||||
|
)));
|
||||||
|
let masks = stack(vec![layer.clone()]);
|
||||||
|
assert_eq!(
|
||||||
|
hidden(&masks, &Framing::new(), &cropped(0.5, 0.5, 0.5, 0.5)),
|
||||||
|
vec!["m1"]
|
||||||
|
);
|
||||||
|
// Added instead, the range reaches everywhere and nothing is lost.
|
||||||
|
let mut layer = top_left_circle();
|
||||||
|
assert!(layer.push_part(MaskPart::new("p2", Join::Union, MaskSource::highlights())));
|
||||||
|
let masks = stack(vec![layer]);
|
||||||
|
assert!(hidden(&masks, &Framing::new(), &cropped(0.5, 0.5, 0.5, 0.5)).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_intersection_keeps_only_what_both_parts_cover() {
|
||||||
|
let circle = |id: &str, join, centre, r| {
|
||||||
|
MaskPart::new(
|
||||||
|
id,
|
||||||
|
join,
|
||||||
|
MaskSource::Radial {
|
||||||
|
centre,
|
||||||
|
radii: (r, r),
|
||||||
|
angle: 0.0,
|
||||||
|
feather: 0.2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// Two circles in opposite corners: keeping the bottom-right quarter
|
||||||
|
// keeps one of them, and nothing is lost.
|
||||||
|
let mut layer = top_left_circle();
|
||||||
|
assert!(layer.push_part(circle("p2", Join::Union, (0.85, 0.85), 0.08)));
|
||||||
|
let bottom_right = cropped(0.5, 0.5, 0.5, 0.5);
|
||||||
|
let masks = stack(vec![layer.clone()]);
|
||||||
|
assert!(hidden(&masks, &Framing::new(), &bottom_right).is_empty());
|
||||||
|
// Intersected with a disc around the top-left, only that corner's
|
||||||
|
// circle survives, and the same crop takes it out of the frame.
|
||||||
|
assert!(layer.push_part(circle("p3", Join::Intersect, (0.15, 0.15), 0.3)));
|
||||||
|
let masks = stack(vec![layer]);
|
||||||
|
assert_eq!(hidden(&masks, &Framing::new(), &bottom_right), vec!["m1"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -725,6 +725,7 @@ mod tests {
|
|||||||
height: 0.5,
|
height: 0.5,
|
||||||
});
|
});
|
||||||
g.set_param(framing::ID, framing::ANGLE, -2.0);
|
g.set_param(framing::ID, framing::ANGLE, -2.0);
|
||||||
|
g.set_param(framing::ID, framing::KEYSTONE_V, 40.0);
|
||||||
g
|
g
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,6 +821,14 @@ mod tests {
|
|||||||
Some(0.0),
|
Some(0.0),
|
||||||
"the source's straightening must not travel on this scope"
|
"the source's straightening must not travel on this scope"
|
||||||
);
|
);
|
||||||
|
// TRACES: FR-DEV-20
|
||||||
|
// Perspective is composition on the same terms as the crop: it is
|
||||||
|
// withheld by the default scope, not pasted over the target's frame.
|
||||||
|
assert_eq!(
|
||||||
|
target.param(framing::ID, framing::KEYSTONE_V),
|
||||||
|
Some(0.0),
|
||||||
|
"the source's keystone must not travel on this scope"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -829,6 +838,7 @@ mod tests {
|
|||||||
preset.apply(&mut target, Scope::everything());
|
preset.apply(&mut target, Scope::everything());
|
||||||
|
|
||||||
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
|
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
|
||||||
|
assert_eq!(target.param(framing::ID, framing::KEYSTONE_V), Some(40.0));
|
||||||
assert!(
|
assert!(
|
||||||
(target.crop().width - 0.5).abs() < 1e-5,
|
(target.crop().width - 0.5).abs() < 1e-5,
|
||||||
"{:?}",
|
"{:?}",
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ pub const MAX_RATING: u8 = 5;
|
|||||||
/// Highest flag code: 0 unflagged, 1 pick, 2 reject.
|
/// Highest flag code: 0 unflagged, 1 pick, 2 reject.
|
||||||
pub const MAX_FLAG: u8 = 2;
|
pub const MAX_FLAG: u8 = 2;
|
||||||
|
|
||||||
|
/// Highest colour-label code: purple. Mirrors `dr_catalog::rating::label_code`.
|
||||||
|
pub const MAX_LABEL: u8 = 5;
|
||||||
|
|
||||||
/// TRACES: FR-CAT-8 | FR-NC-8
|
/// TRACES: FR-CAT-8 | FR-NC-8
|
||||||
/// One image's sidecar: a keyed set of versions.
|
/// One image's sidecar: a keyed set of versions.
|
||||||
///
|
///
|
||||||
@@ -187,6 +190,17 @@ pub struct Version {
|
|||||||
/// so a value moving between the two stores needs no translation table
|
/// so a value moving between the two stores needs no translation table
|
||||||
/// that could drift.
|
/// that could drift.
|
||||||
pub flag: u8,
|
pub flag: u8,
|
||||||
|
/// TRACES: FR-CAT-5
|
||||||
|
/// The colour label, `0` for none and `1..=5` red, yellow, green, blue,
|
||||||
|
/// purple — the catalog's `versions.label` codes, for the reason
|
||||||
|
/// [`Self::flag`] shares its encoding.
|
||||||
|
///
|
||||||
|
/// Here for the reason the rating is: a label that lived only in the
|
||||||
|
/// catalog would go with the catalog, and would never reach the
|
||||||
|
/// photographer's other devices, which learn judgements from this file.
|
||||||
|
/// A build that predates the key keeps it as an unknown line and writes
|
||||||
|
/// it back, so an older device passes it on rather than erasing it.
|
||||||
|
pub label: u8,
|
||||||
/// The edit itself: `(op, param) -> value`, non-default values only.
|
/// The edit itself: `(op, param) -> value`, non-default values only.
|
||||||
pub params: BTreeMap<(String, String), f32>,
|
pub params: BTreeMap<(String, String), f32>,
|
||||||
/// TRACES: FR-DEV-3 | FR-NC-9
|
/// TRACES: FR-DEV-3 | FR-NC-9
|
||||||
@@ -264,6 +278,7 @@ impl Version {
|
|||||||
// it in the "not yet looked at" state a cull resumes from.
|
// it in the "not yet looked at" state a cull resumes from.
|
||||||
rating: 0,
|
rating: 0,
|
||||||
flag: 0,
|
flag: 0,
|
||||||
|
label: 0,
|
||||||
params,
|
params,
|
||||||
masks,
|
masks,
|
||||||
film,
|
film,
|
||||||
@@ -572,6 +587,10 @@ impl Version {
|
|||||||
// that never had it.
|
// that never had it.
|
||||||
self.rating = merge_judgement(self.rating, remote.rating, remote_wins);
|
self.rating = merge_judgement(self.rating, remote.rating, remote_wins);
|
||||||
self.flag = merge_judgement(self.flag, remote.flag, remote_wins);
|
self.flag = merge_judgement(self.flag, remote.flag, remote_wins);
|
||||||
|
// TRACES: FR-CAT-5
|
||||||
|
// A label under the same rule: 0 is "none given", so a device that
|
||||||
|
// never labelled a frame cannot clear another's label.
|
||||||
|
self.label = merge_judgement(self.label, remote.label, remote_wins);
|
||||||
|
|
||||||
// TRACES: FR-DEV-3f
|
// TRACES: FR-DEV-3f
|
||||||
// The film resolves wholesale to the higher revision, like a mask
|
// The film resolves wholesale to the higher revision, like a mask
|
||||||
@@ -873,6 +892,9 @@ impl Sidecar {
|
|||||||
if v.flag > 0 {
|
if v.flag > 0 {
|
||||||
let _ = writeln!(out, "flag = {}", v.flag);
|
let _ = writeln!(out, "flag = {}", v.flag);
|
||||||
}
|
}
|
||||||
|
if v.label > 0 {
|
||||||
|
let _ = writeln!(out, "label = {}", v.label);
|
||||||
|
}
|
||||||
// TRACES: FR-DEV-3f
|
// TRACES: FR-DEV-3f
|
||||||
// Before the parameters, because it decides what they mean: the
|
// Before the parameters, because it decides what they mean: the
|
||||||
// film's exposure slider is a slider on *that stock's* curve.
|
// film's exposure slider is a slider on *that stock's* curve.
|
||||||
@@ -1068,6 +1090,17 @@ impl Sidecar {
|
|||||||
Some(value.to_string());
|
Some(value.to_string());
|
||||||
}
|
}
|
||||||
"flag" => version.flag = value.parse::<u8>().unwrap_or(0).min(MAX_FLAG),
|
"flag" => version.flag = value.parse::<u8>().unwrap_or(0).min(MAX_FLAG),
|
||||||
|
// TRACES: FR-CAT-5
|
||||||
|
// A code this build does not know reads as none rather than
|
||||||
|
// being clamped onto purple: a wrong colour is a claim, and
|
||||||
|
// no colour is only a gap.
|
||||||
|
"label" => {
|
||||||
|
version.label = value
|
||||||
|
.parse::<u8>()
|
||||||
|
.ok()
|
||||||
|
.filter(|l| *l <= MAX_LABEL)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
// TRACES: FR-DEV-8
|
// TRACES: FR-DEV-8
|
||||||
// Ahead of the `op.param` arm below, which would otherwise try
|
// Ahead of the `op.param` arm below, which would otherwise try
|
||||||
// to read eight numbers as one float and drop the repair with a
|
// to read eight numbers as one float and drop the repair with a
|
||||||
@@ -2141,6 +2174,8 @@ mod tests {
|
|||||||
height: 0.6,
|
height: 0.6,
|
||||||
});
|
});
|
||||||
g.set_param(framing::ID, framing::ANGLE, -1.5);
|
g.set_param(framing::ID, framing::ANGLE, -1.5);
|
||||||
|
g.set_param(framing::ID, framing::KEYSTONE_V, 42.0);
|
||||||
|
g.set_param(framing::ID, framing::KEYSTONE_H, -17.0);
|
||||||
g.rotate_quarters(1);
|
g.rotate_quarters(1);
|
||||||
|
|
||||||
let mut sidecar = Sidecar::new();
|
let mut sidecar = Sidecar::new();
|
||||||
@@ -2157,6 +2192,12 @@ mod tests {
|
|||||||
assert_eq!(restored.crop(), g.crop());
|
assert_eq!(restored.crop(), g.crop());
|
||||||
assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5));
|
assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5));
|
||||||
assert_eq!(restored.param(framing::ID, framing::ROTATION), Some(1.0));
|
assert_eq!(restored.param(framing::ID, framing::ROTATION), Some(1.0));
|
||||||
|
// TRACES: FR-DEV-20
|
||||||
|
assert_eq!(restored.param(framing::ID, framing::KEYSTONE_V), Some(42.0));
|
||||||
|
assert_eq!(
|
||||||
|
restored.param(framing::ID, framing::KEYSTONE_H),
|
||||||
|
Some(-17.0)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2828,6 +2869,37 @@ mod tests {
|
|||||||
assert_eq!(back.flag, 1);
|
assert_eq!(back.flag, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_label_survives_the_round_trip_and_an_unknown_one_reads_as_none() {
|
||||||
|
// TRACES: FR-CAT-5
|
||||||
|
let mut v = version_of(&EditGraph::default_chain());
|
||||||
|
v.label = 3;
|
||||||
|
let mut sidecar = Sidecar::new();
|
||||||
|
sidecar.put(v);
|
||||||
|
let text = sidecar.to_text();
|
||||||
|
assert!(text.contains("label = 3"), "{text}");
|
||||||
|
let parsed = Sidecar::parse(&text).expect("valid");
|
||||||
|
assert_eq!(parsed.default_version().expect("a version").label, 3);
|
||||||
|
|
||||||
|
let odd = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
|
||||||
|
label = 9\n";
|
||||||
|
let parsed = Sidecar::parse(odd).expect("valid");
|
||||||
|
assert_eq!(parsed.default_version().expect("a version").label, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unlabelled_device_cannot_clear_another_devices_label() {
|
||||||
|
// TRACES: FR-CAT-5
|
||||||
|
let mut local = version_of(&EditGraph::default_chain());
|
||||||
|
local.label = 0;
|
||||||
|
local.revision = 9;
|
||||||
|
let mut remote = version_of(&EditGraph::default_chain());
|
||||||
|
remote.label = 1;
|
||||||
|
remote.revision = 1;
|
||||||
|
local.merge(&remote, None);
|
||||||
|
assert_eq!(local.label, 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unrated_image_writes_no_judgement_lines() {
|
fn an_unrated_image_writes_no_judgement_lines() {
|
||||||
// The non-default rule applied to judgement: a library that has never
|
// The non-default rule applied to judgement: a library that has never
|
||||||
@@ -2838,6 +2910,7 @@ mod tests {
|
|||||||
let text = sidecar.to_text();
|
let text = sidecar.to_text();
|
||||||
assert!(!text.contains("rating"), "{text}");
|
assert!(!text.contains("rating"), "{text}");
|
||||||
assert!(!text.contains("flag"), "{text}");
|
assert!(!text.contains("flag"), "{text}");
|
||||||
|
assert!(!text.contains("label"), "{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1344,6 +1344,28 @@ fn a_correction_painted_onto_a_subject_survives_a_round_trip() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-19a
|
||||||
|
/// An intersecting part is written under its own word and reads back as an
|
||||||
|
/// intersection. A build from before intersection read that word as a union
|
||||||
|
/// (see `an_unknown_join_adds_the_part`), which keeps the part visible and
|
||||||
|
/// fixable rather than silently cutting the mask down.
|
||||||
|
#[test]
|
||||||
|
fn an_intersecting_part_survives_a_round_trip() {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
graph.masks_mut().push(corrected("m1", Join::Intersect));
|
||||||
|
|
||||||
|
let mut sidecar = Sidecar::new();
|
||||||
|
sidecar.put(Version::from_graph("default", "Default", &graph));
|
||||||
|
let text = sidecar.to_text();
|
||||||
|
assert!(text.contains("join = intersect"), "{text}");
|
||||||
|
|
||||||
|
let restored = round_trip(&graph);
|
||||||
|
let layer = &restored.masks().layers()[0];
|
||||||
|
assert_eq!(layer.parts().len(), 2);
|
||||||
|
assert_eq!(layer.parts()[1].join, Join::Intersect);
|
||||||
|
assert_eq!(layer.parts()[0].join, Join::Union, "the base is untouched");
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-19a
|
/// TRACES: FR-DEV-19a
|
||||||
/// A part left out of the build comes back left out, and the file says so
|
/// A part left out of the build comes back left out, and the file says so
|
||||||
/// under a word that cannot be confused with the layer's own switch.
|
/// under a word that cannot be confused with the layer's own switch.
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ pub struct LoginFlow {
|
|||||||
pub login_url: String,
|
pub login_url: String,
|
||||||
#[serde(rename = "poll")]
|
#[serde(rename = "poll")]
|
||||||
pub poll: PollInfo,
|
pub poll: PollInfo,
|
||||||
|
/// The server the flow was started against — the address the user typed,
|
||||||
|
/// already normalised. Not part of the response: [`begin`] fills it in so
|
||||||
|
/// [`poll`] can put it in the credentials instead of the server's answer.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub server: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -66,9 +71,57 @@ pub async fn begin(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.json::<LoginFlow>()
|
let mut flow = resp
|
||||||
|
.json::<LoginFlow>()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RemoteError::Protocol(e.to_string()))
|
.map_err(|e| RemoteError::Protocol(e.to_string()))?;
|
||||||
|
|
||||||
|
// Both URLs are the server's to choose, and neither may be trusted as
|
||||||
|
// sent. The login URL is handed to the operating system to open, where a
|
||||||
|
// `file:` or UNC path is a program launch rather than a web page; the poll
|
||||||
|
// endpoint is where the app password comes back from.
|
||||||
|
flow.login_url = upgraded("login URL", &flow.login_url)?;
|
||||||
|
flow.poll.endpoint = upgraded("poll endpoint", &flow.poll.endpoint)?;
|
||||||
|
flow.server = server.trim_end_matches('/').to_string();
|
||||||
|
Ok(flow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-SEC-3
|
||||||
|
/// A URL the server sent, upgraded to HTTPS, or refused.
|
||||||
|
///
|
||||||
|
/// `http` is upgraded rather than refused: a Nextcloud behind a TLS-terminating
|
||||||
|
/// proxy without `overwriteprotocol` builds every absolute URL it returns with
|
||||||
|
/// `http`, and the same path over `https` is the one that works. Any other
|
||||||
|
/// scheme is refused, because the only thing it could be for is reaching
|
||||||
|
/// something that is not this server.
|
||||||
|
///
|
||||||
|
/// The host is not checked. A server reached by its LAN address may answer with
|
||||||
|
/// its public name, and nothing here is safer for refusing that: the account
|
||||||
|
/// is stored under the address the user typed (see [`poll`]), not under
|
||||||
|
/// anything the server said.
|
||||||
|
pub(crate) fn upgraded(what: &str, url: &str) -> Result<String, RemoteError> {
|
||||||
|
let mut parsed = url::Url::parse(url).map_err(|e| {
|
||||||
|
RemoteError::Protocol(format!(
|
||||||
|
"the server sent a {what} that is not a URL ({e}): {url}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
match parsed.scheme() {
|
||||||
|
"https" => {}
|
||||||
|
"http" => parsed.set_scheme("https").map_err(|()| {
|
||||||
|
RemoteError::Protocol(format!("{what} cannot be upgraded to https: {url}"))
|
||||||
|
})?,
|
||||||
|
other => {
|
||||||
|
return Err(RemoteError::Protocol(format!(
|
||||||
|
"the server sent a {what} using {other}:, and only https is accepted: {url}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parsed.host_str().is_none_or(str::is_empty) {
|
||||||
|
return Err(RemoteError::Protocol(format!(
|
||||||
|
"the server sent a {what} with no host: {url}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(parsed.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll until the user finishes authenticating in the browser.
|
/// Poll until the user finishes authenticating in the browser.
|
||||||
@@ -98,10 +151,11 @@ pub async fn poll(
|
|||||||
|
|
||||||
match resp.status().as_u16() {
|
match resp.status().as_u16() {
|
||||||
200 => {
|
200 => {
|
||||||
return resp
|
let creds = resp
|
||||||
.json::<AppCredentials>()
|
.json::<AppCredentials>()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RemoteError::Protocol(e.to_string()))
|
.map_err(|e| RemoteError::Protocol(e.to_string()))?;
|
||||||
|
return Ok(under_typed_server(creds, &flow.server));
|
||||||
}
|
}
|
||||||
// Still waiting for the user.
|
// Still waiting for the user.
|
||||||
404 => tokio::time::sleep(POLL_INTERVAL).await,
|
404 => tokio::time::sleep(POLL_INTERVAL).await,
|
||||||
@@ -117,6 +171,26 @@ pub async fn poll(
|
|||||||
Err(RemoteError::AuthFailed)
|
Err(RemoteError::AuthFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-SEC-3
|
||||||
|
/// Credentials filed under the address the user typed, not the one the server
|
||||||
|
/// reports.
|
||||||
|
///
|
||||||
|
/// That report is the server's idea of its own URL, and behind a proxy
|
||||||
|
/// without `overwriteprotocol` it says `http://` — which, stored, would send
|
||||||
|
/// the app password in the clear on every request from then on. The typed
|
||||||
|
/// address has just carried the whole flow, so it is known to reach the
|
||||||
|
/// server.
|
||||||
|
fn under_typed_server(mut creds: AppCredentials, server: &str) -> AppCredentials {
|
||||||
|
if creds.server.trim_end_matches('/') != server {
|
||||||
|
log::info!(
|
||||||
|
"server reports itself as {}; keeping {server}",
|
||||||
|
creds.server
|
||||||
|
);
|
||||||
|
}
|
||||||
|
creds.server = server.to_string();
|
||||||
|
creds
|
||||||
|
}
|
||||||
|
|
||||||
/// Percent-encode a form value.
|
/// Percent-encode a form value.
|
||||||
fn urlencode(s: &str) -> String {
|
fn urlencode(s: &str) -> String {
|
||||||
s.bytes()
|
s.bytes()
|
||||||
@@ -167,6 +241,49 @@ mod tests {
|
|||||||
assert!(flow.login_url.contains("/login/v2/flow/"));
|
assert!(flow.login_url.contains("/login/v2/flow/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-SEC-3
|
||||||
|
#[test]
|
||||||
|
fn server_urls_are_upgraded_to_https_or_refused() {
|
||||||
|
assert_eq!(
|
||||||
|
upgraded("login URL", "http://cloud.example/login/v2/flow/xyz").unwrap(),
|
||||||
|
"https://cloud.example/login/v2/flow/xyz"
|
||||||
|
);
|
||||||
|
// A non-default port stays, and only the scheme changes.
|
||||||
|
assert_eq!(
|
||||||
|
upgraded("poll endpoint", "http://cloud.example:8443/login/v2/poll").unwrap(),
|
||||||
|
"https://cloud.example:8443/login/v2/poll"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
upgraded("login URL", "https://cloud.example/x").unwrap(),
|
||||||
|
"https://cloud.example/x"
|
||||||
|
);
|
||||||
|
// What `rundll32 url.dll,FileProtocolHandler` would run, and what
|
||||||
|
// `xdg-open` would hand to whatever claims it.
|
||||||
|
for hostile in [
|
||||||
|
"file:///C:/Windows/System32/calc.exe",
|
||||||
|
"\\\\evil\\share\\x.exe",
|
||||||
|
"C:\\x.exe",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"-v",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(upgraded("login URL", hostile).is_err(), "{hostile:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-SEC-3
|
||||||
|
#[test]
|
||||||
|
fn credentials_keep_the_typed_server_not_the_reported_one() {
|
||||||
|
let reported = AppCredentials {
|
||||||
|
server: "http://cloud.example".into(),
|
||||||
|
login_name: "duncan".into(),
|
||||||
|
app_password: "secret-token".into(),
|
||||||
|
};
|
||||||
|
let c = under_typed_server(reported, "https://cloud.example");
|
||||||
|
assert_eq!(c.server, "https://cloud.example");
|
||||||
|
assert_eq!(c.app_password, "secret-token");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn form_values_are_encoded() {
|
fn form_values_are_encoded() {
|
||||||
assert_eq!(urlencode("abc123"), "abc123");
|
assert_eq!(urlencode("abc123"), "abc123");
|
||||||
|
|||||||
@@ -723,6 +723,15 @@ pub fn http_client(user_agent: &str) -> Result<reqwest::Client, RemoteError> {
|
|||||||
// for months. [`EXTRA_ROOTS`] carries those, and is additive — the
|
// for months. [`EXTRA_ROOTS`] carries those, and is additive — the
|
||||||
// webpki-roots set is still installed alongside.
|
// webpki-roots set is still installed alongside.
|
||||||
.tls_certs_only(extra_roots())
|
.tls_certs_only(extra_roots())
|
||||||
|
// TRACES: NFR-SEC-3
|
||||||
|
// Refuse `http` here, below every URL this crate builds, rather than
|
||||||
|
// trusting each place a URL comes from. `normalise_endpoint` upgrades
|
||||||
|
// what the user types, but the login flow's poll endpoint, the account
|
||||||
|
// an older build saved and a redirect all arrive from somewhere else,
|
||||||
|
// and any of them naming `http://` would send the app password in
|
||||||
|
// Basic auth in the clear. reqwest checks this before connecting and
|
||||||
|
// again on every redirect, so a refused request never opens a socket.
|
||||||
|
.https_only(true)
|
||||||
// A request that hangs forever is indistinguishable from a worker that
|
// A request that hangs forever is indistinguishable from a worker that
|
||||||
// died, and cost a long time to tell apart once. These turn that into
|
// died, and cost a long time to tell apart once. These turn that into
|
||||||
// an error the UI can show.
|
// an error the UI can show.
|
||||||
@@ -780,8 +789,16 @@ fn map_send_error(e: reqwest::Error) -> RemoteError {
|
|||||||
let mut detail = e.to_string();
|
let mut detail = e.to_string();
|
||||||
let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&e);
|
let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&e);
|
||||||
let mut tls = false;
|
let mut tls = false;
|
||||||
|
// A URL the client refused to send — `http` under `https_only`, on the
|
||||||
|
// first request or a redirect. Nothing left the process, so this is the
|
||||||
|
// account's configuration, not the network: reported as `Network` it
|
||||||
|
// would put the app into offline mode over a connection that is fine.
|
||||||
|
let mut refused = e.is_builder();
|
||||||
while let Some(s) = src {
|
while let Some(s) = src {
|
||||||
let text = s.to_string();
|
let text = s.to_string();
|
||||||
|
if text.contains("URL scheme is not allowed") {
|
||||||
|
refused = true;
|
||||||
|
}
|
||||||
// rustls surfaces every verification failure through this wording:
|
// rustls surfaces every verification failure through this wording:
|
||||||
// UnknownIssuer, Expired, NotValidForName, BadSignature.
|
// UnknownIssuer, Expired, NotValidForName, BadSignature.
|
||||||
if text.contains("invalid peer certificate")
|
if text.contains("invalid peer certificate")
|
||||||
@@ -795,7 +812,9 @@ fn map_send_error(e: reqwest::Error) -> RemoteError {
|
|||||||
src = std::error::Error::source(s);
|
src = std::error::Error::source(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
if tls {
|
if refused {
|
||||||
|
RemoteError::Configuration(detail)
|
||||||
|
} else if tls {
|
||||||
RemoteError::Tls(detail)
|
RemoteError::Tls(detail)
|
||||||
} else {
|
} else {
|
||||||
RemoteError::Network(detail)
|
RemoteError::Network(detail)
|
||||||
@@ -1013,6 +1032,35 @@ mod tests {
|
|||||||
assert!(c.is_ok(), "client must build without a backend");
|
assert!(c.is_ok(), "client must build without a backend");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-SEC-3
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plain_http_is_refused_before_a_connection_opens() {
|
||||||
|
// A listener that would take the connection if one were made. The
|
||||||
|
// app password travels in a header, so a request that reached the
|
||||||
|
// socket has already leaked it; failing on the response is too late.
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
listener.set_nonblocking(true).unwrap();
|
||||||
|
let url = format!("http://{}/remote.php/dav/", listener.local_addr().unwrap());
|
||||||
|
|
||||||
|
let err = http_client("test")
|
||||||
|
.unwrap()
|
||||||
|
.get(&url)
|
||||||
|
.basic_auth("duncan", Some("app-password"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("http must be refused");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(map_send_error(err), RemoteError::Configuration(_)),
|
||||||
|
"a refused scheme is configuration, not the network"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
listener.accept().err().map(|e| e.kind()),
|
||||||
|
Some(std::io::ErrorKind::WouldBlock),
|
||||||
|
"nothing may have connected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delta_is_unsupported_and_says_why() {
|
async fn delta_is_unsupported_and_says_why() {
|
||||||
// Verified absent in the server; the engine must fall back rather
|
// Verified absent in the server; the engine must fall back rather
|
||||||
|
|||||||
@@ -96,6 +96,18 @@ impl BackendProvider for NextcloudProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-SEC-3
|
||||||
|
/// An `http://` account written before sign-in kept the address the user
|
||||||
|
/// typed: it was stored as the server reported itself, and behind a proxy
|
||||||
|
/// without `overwriteprotocol` that is `http`. The client refuses to send
|
||||||
|
/// to it now, so it is upgraded here rather than left to fail. The
|
||||||
|
/// namespace ignores the scheme, so the catalog stays where it is.
|
||||||
|
fn upgrade_endpoint(&self, stored: &str) -> Option<String> {
|
||||||
|
stored
|
||||||
|
.strip_prefix("http://")
|
||||||
|
.map(|rest| format!("https://{rest}"))
|
||||||
|
}
|
||||||
|
|
||||||
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
|
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
|
||||||
let creds = Self::credentials(conn)?;
|
let creds = Self::credentials(conn)?;
|
||||||
Ok(Box::new(NextcloudBackend::new(
|
Ok(Box::new(NextcloudBackend::new(
|
||||||
@@ -132,6 +144,18 @@ mod tests {
|
|||||||
assert!(p.normalise_endpoint(" ").is_err());
|
assert!(p.normalise_endpoint(" ").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-SEC-3
|
||||||
|
#[test]
|
||||||
|
fn a_stored_http_endpoint_is_upgraded_and_nothing_else_is_touched() {
|
||||||
|
let p = NextcloudProvider;
|
||||||
|
assert_eq!(
|
||||||
|
p.upgrade_endpoint("http://cloud.example/nextcloud")
|
||||||
|
.as_deref(),
|
||||||
|
Some("https://cloud.example/nextcloud")
|
||||||
|
);
|
||||||
|
assert_eq!(p.upgrade_endpoint("https://cloud.example"), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_account_keeps_the_dav_user_id_apart_from_the_login() {
|
fn the_account_keeps_the_dav_user_id_apart_from_the_login() {
|
||||||
// A login can be an email address while the user id is something
|
// A login can be an email address while the user id is something
|
||||||
|
|||||||
+223
-1
@@ -29,7 +29,7 @@ use dr_plat::{SecretError, SecretRef, SecretStore};
|
|||||||
use dr_types::{Format, FormatFilter};
|
use dr_types::{Format, FormatFilter};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::RemoteError;
|
use crate::{BackendRegistry, RemoteError};
|
||||||
|
|
||||||
/// The connector every account had before there was a choice.
|
/// The connector every account had before there was a choice.
|
||||||
///
|
///
|
||||||
@@ -510,6 +510,95 @@ impl AccountStore {
|
|||||||
written
|
written
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-SEC-3
|
||||||
|
/// Rewrite the endpoints an older build stored in a form this one would
|
||||||
|
/// not, asking each account's connector
|
||||||
|
/// ([`upgrade_endpoint`](crate::BackendProvider::upgrade_endpoint)).
|
||||||
|
///
|
||||||
|
/// Per account and best effort: one that cannot be moved — its keyring
|
||||||
|
/// locked, say — is logged and left as it was, and is tried again on the
|
||||||
|
/// next launch, rather than stopping the others or the launch. Returns
|
||||||
|
/// the accounts it rewrote.
|
||||||
|
pub fn upgrade_endpoints(&self, registry: &BackendRegistry) -> Vec<Account> {
|
||||||
|
let mut upgraded = Vec::new();
|
||||||
|
for account in self.list() {
|
||||||
|
let Ok(provider) = registry.for_account(&account) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(endpoint) = provider.upgrade_endpoint(&account.endpoint) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if endpoint == account.endpoint {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match self.move_endpoint(&account, &endpoint) {
|
||||||
|
Ok(moved) => {
|
||||||
|
log::info!("account {} moved to {endpoint}", account.describe());
|
||||||
|
upgraded.push(moved);
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!(
|
||||||
|
"account {} could not be moved to {endpoint}: {e}",
|
||||||
|
account.describe()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upgraded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move an account to a new endpoint, taking its credential with it.
|
||||||
|
///
|
||||||
|
/// The endpoint is half of two keys, and both have to be dealt with. The
|
||||||
|
/// credential is filed under it ([`Account::secret_ref`]), so rewriting
|
||||||
|
/// the record alone would strand the app password under the old key and
|
||||||
|
/// sign the user out. And it feeds [`Account::namespace`], so a rewrite
|
||||||
|
/// that changed the namespace would abandon the catalog and everything
|
||||||
|
/// beside it; that is refused outright rather than left to the caller.
|
||||||
|
///
|
||||||
|
/// Ordered so an interruption at any step leaves something that works:
|
||||||
|
/// the credential is copied before the record names the new key, and the
|
||||||
|
/// old copy is deleted only once nothing names the old one.
|
||||||
|
fn move_endpoint(&self, account: &Account, endpoint: &str) -> Result<Account, AccountError> {
|
||||||
|
let mut moved = account.clone();
|
||||||
|
moved.endpoint = endpoint.to_string();
|
||||||
|
if moved.namespace() != account.namespace() {
|
||||||
|
return Err(AccountError::WouldMoveData {
|
||||||
|
from: account.namespace(),
|
||||||
|
to: moved.namespace(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut config = self.read_config();
|
||||||
|
// Already there — the user signed in again at the new address. That
|
||||||
|
// record and its credential are the newer, so the old one just goes.
|
||||||
|
let duplicate = config.sessions.iter().any(|a| a.is_same_as(&moved));
|
||||||
|
|
||||||
|
let old_ref = account.secret_ref();
|
||||||
|
let secret = match self.secrets.retrieve(&old_ref) {
|
||||||
|
Ok(s) => Some(s),
|
||||||
|
Err(SecretError::NotFound) => None,
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
if let (Some(s), false) = (&secret, duplicate) {
|
||||||
|
self.secrets.store(&moved.secret_ref(), s)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if duplicate {
|
||||||
|
config.sessions.retain(|a| !a.is_same_as(account));
|
||||||
|
} else {
|
||||||
|
// In place, not removed and pushed: the last record is the one
|
||||||
|
// the next launch resumes.
|
||||||
|
for a in config.sessions.iter_mut().filter(|a| a.is_same_as(account)) {
|
||||||
|
*a = moved.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.write_config(&config)?;
|
||||||
|
|
||||||
|
if secret.is_some() {
|
||||||
|
self.secrets.delete(&old_ref)?;
|
||||||
|
}
|
||||||
|
Ok(moved)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_config(&self) -> ConfigFile {
|
fn read_config(&self) -> ConfigFile {
|
||||||
std::fs::read_to_string(&self.config_path)
|
std::fs::read_to_string(&self.config_path)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -545,6 +634,11 @@ pub enum AccountError {
|
|||||||
|
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Remote(#[from] RemoteError),
|
Remote(#[from] RemoteError),
|
||||||
|
|
||||||
|
/// A change that would give an account a different local data directory,
|
||||||
|
/// leaving its catalog and caches behind under the old one.
|
||||||
|
#[error("moving the account would leave its local data behind ({from} → {to})")]
|
||||||
|
WouldMoveData { from: String, to: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -574,6 +668,134 @@ mod tests {
|
|||||||
d
|
d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A connector that upgrades `http://` endpoints the way Nextcloud's does,
|
||||||
|
/// and every other one not at all.
|
||||||
|
struct Upgrading(&'static str);
|
||||||
|
impl crate::BackendProvider for Upgrading {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
fn display_name(&self) -> &'static str {
|
||||||
|
"U"
|
||||||
|
}
|
||||||
|
fn endpoint_label(&self) -> &'static str {
|
||||||
|
"Server"
|
||||||
|
}
|
||||||
|
fn endpoint_placeholder(&self) -> &'static str {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
fn sign_in(&self) -> crate::SignIn {
|
||||||
|
crate::SignIn::Browser
|
||||||
|
}
|
||||||
|
fn normalise_endpoint(&self, i: &str) -> Result<String, String> {
|
||||||
|
Ok(i.into())
|
||||||
|
}
|
||||||
|
fn upgrade_endpoint(&self, stored: &str) -> Option<String> {
|
||||||
|
stored
|
||||||
|
.strip_prefix("http://")
|
||||||
|
.map(|rest| format!("https://{rest}"))
|
||||||
|
}
|
||||||
|
fn connect(&self, _: &Connection) -> Result<Box<dyn crate::RemoteBackend>, RemoteError> {
|
||||||
|
Err(RemoteError::Unsupported("stub"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upgrading(id: &'static str) -> BackendRegistry {
|
||||||
|
let mut r = BackendRegistry::new();
|
||||||
|
r.register(std::sync::Arc::new(Upgrading(id)));
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-SEC-3
|
||||||
|
#[test]
|
||||||
|
fn an_http_account_is_upgraded_and_keeps_its_credential_and_data() {
|
||||||
|
let dir = tmpdir("upgrade");
|
||||||
|
let store = store_in(&dir);
|
||||||
|
let old =
|
||||||
|
Account::new(LEGACY_BACKEND, "http://cloud.example").with_login("duncan", "duncan");
|
||||||
|
store.save(&folder(), None).unwrap();
|
||||||
|
store
|
||||||
|
.save(&old, Some(&Secret::new("secret-token")))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let moved = store.upgrade_endpoints(&upgrading(LEGACY_BACKEND));
|
||||||
|
assert_eq!(moved.len(), 1);
|
||||||
|
|
||||||
|
let now = store.current().expect("still the account resumed");
|
||||||
|
assert_eq!(now.endpoint, "https://cloud.example");
|
||||||
|
assert_eq!(
|
||||||
|
now.namespace(),
|
||||||
|
old.namespace(),
|
||||||
|
"the catalog directory must not move"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.connection(&now, true)
|
||||||
|
.unwrap()
|
||||||
|
.require_secret()
|
||||||
|
.unwrap()
|
||||||
|
.expose(),
|
||||||
|
"secret-token",
|
||||||
|
"the credential moves with the account"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store.connection(&old, true).is_err(),
|
||||||
|
"nothing is left under the old key"
|
||||||
|
);
|
||||||
|
assert_eq!(store.list().len(), 2, "the folder library is untouched");
|
||||||
|
|
||||||
|
// And a second launch has nothing to do.
|
||||||
|
assert!(store
|
||||||
|
.upgrade_endpoints(&upgrading(LEGACY_BACKEND))
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-SEC-3
|
||||||
|
#[test]
|
||||||
|
fn a_move_that_would_change_the_data_directory_is_refused() {
|
||||||
|
// A scheme change keeps the namespace, which is what makes the upgrade
|
||||||
|
// safe; a host change does not, and would give the library a new,
|
||||||
|
// empty data directory. The account is left exactly as it was.
|
||||||
|
let dir = tmpdir("upgrade-refused");
|
||||||
|
let store = store_in(&dir);
|
||||||
|
let old = nextcloud();
|
||||||
|
store
|
||||||
|
.save(&old, Some(&Secret::new("secret-token")))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
store.move_endpoint(&old, "https://elsewhere.example"),
|
||||||
|
Err(AccountError::WouldMoveData { .. })
|
||||||
|
));
|
||||||
|
assert_eq!(store.current().unwrap().endpoint, old.endpoint);
|
||||||
|
assert!(store.connection(&old, true).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-SEC-3
|
||||||
|
#[test]
|
||||||
|
fn an_upgrade_onto_an_existing_sign_in_keeps_the_newer_one() {
|
||||||
|
let dir = tmpdir("upgrade-duplicate");
|
||||||
|
let store = store_in(&dir);
|
||||||
|
let old =
|
||||||
|
Account::new(LEGACY_BACKEND, "http://cloud.example").with_login("duncan", "duncan");
|
||||||
|
let new =
|
||||||
|
Account::new(LEGACY_BACKEND, "https://cloud.example").with_login("duncan", "duncan");
|
||||||
|
store.save(&old, Some(&Secret::new("stale"))).unwrap();
|
||||||
|
store.save(&new, Some(&Secret::new("fresh"))).unwrap();
|
||||||
|
|
||||||
|
store.upgrade_endpoints(&upgrading(LEGACY_BACKEND));
|
||||||
|
assert_eq!(store.list().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.connection(&new, true)
|
||||||
|
.unwrap()
|
||||||
|
.require_secret()
|
||||||
|
.unwrap()
|
||||||
|
.expose(),
|
||||||
|
"fresh"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_saved_account_survives_reopening() {
|
fn a_saved_account_survives_reopening() {
|
||||||
let dir = tmpdir("survives");
|
let dir = tmpdir("survives");
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ pub trait BackendProvider: Send + Sync {
|
|||||||
/// wrong rather than naming a type.
|
/// wrong rather than naming a type.
|
||||||
fn normalise_endpoint(&self, input: &str) -> Result<String, String>;
|
fn normalise_endpoint(&self, input: &str) -> Result<String, String>;
|
||||||
|
|
||||||
|
/// The form a *stored* endpoint should take now, where an older build
|
||||||
|
/// wrote one this build would not.
|
||||||
|
///
|
||||||
|
/// Not [`normalise_endpoint`](Self::normalise_endpoint) run again: that
|
||||||
|
/// judges what a person typed, and may touch the world to do it — a folder
|
||||||
|
/// is canonicalised and must exist — so rerunning it on every launch would
|
||||||
|
/// fail a library whose disk is unplugged, or rename one whose path now
|
||||||
|
/// resolves differently. This is a pure rewrite of the string, and `None`
|
||||||
|
/// means leave it alone, which is the answer for almost every connector.
|
||||||
|
fn upgrade_endpoint(&self, stored: &str) -> Option<String> {
|
||||||
|
let _ = stored;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Build an account from a normalised endpoint alone.
|
/// Build an account from a normalised endpoint alone.
|
||||||
///
|
///
|
||||||
/// Only meaningful for [`SignIn::EndpointOnly`]; a browser flow produces
|
/// Only meaningful for [`SignIn::EndpointOnly`]; a browser flow produces
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ pub enum PlaceScope {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct StoredFilter {
|
pub struct StoredFilter {
|
||||||
pub min_rating: u8,
|
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 unjudged: bool,
|
||||||
pub flag: Option<FlagState>,
|
pub flag: Option<FlagState>,
|
||||||
pub local_only: bool,
|
pub local_only: bool,
|
||||||
@@ -131,6 +135,10 @@ pub struct StoredFilter {
|
|||||||
/// Travels: it is a narrowing like `local_only`, and a record without it
|
/// Travels: it is a narrowing like `local_only`, and a record without it
|
||||||
/// — from a build before it existed — reads as off.
|
/// — from a build before it existed — reads as off.
|
||||||
pub eyes_open: bool,
|
pub eyes_open: bool,
|
||||||
|
/// TRACES: FR-CAT-6
|
||||||
|
/// The colour label the grid was narrowed to. A record without it reads
|
||||||
|
/// as none, as every term added after the first does.
|
||||||
|
pub label: Option<crate::ColourLabel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the photographer was, at the moment they were there.
|
/// Where the photographer was, at the moment they were there.
|
||||||
|
|||||||
@@ -332,6 +332,29 @@ else
|
|||||||
echo " assets: no models found (face indexing and the scene tab will be off on the device)"
|
echo " assets: no models found (face indexing and the scene tab will be off on the device)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The manual: the rendered page and its pictures, read in place by
|
||||||
|
# ManualActivity's WebView as file:///android_asset/manual/index.html. Not
|
||||||
|
# unpacked like the models: a WebView reads an asset straight out of the APK,
|
||||||
|
# and relative links to media/ resolve inside the same asset tree, so the page
|
||||||
|
# costs no first-launch copy and no second copy on /data.
|
||||||
|
#
|
||||||
|
# About 27 MB, stored below like the models — a GIF or PNG is already
|
||||||
|
# compressed, and deflating it again buys nothing. The pictures are LFS
|
||||||
|
# objects, and unlike a missing model a pointer would not fail loudly: it
|
||||||
|
# ships as a manual full of broken images. So it stops the build here.
|
||||||
|
rm -rf "${OUT}/staging/assets/manual"
|
||||||
|
mkdir -p "${OUT}/staging/assets/manual/media"
|
||||||
|
cp "${REPO}/docs/manual/index.html" "${OUT}/staging/assets/manual/"
|
||||||
|
for f in "${REPO}/docs/manual/media"/*; do
|
||||||
|
if head -c 40 "${f}" | grep -q '^version https://git-lfs'; then
|
||||||
|
echo "error: $(basename "${f}") is an LFS pointer, not a picture." >&2
|
||||||
|
echo " run: git lfs pull --include='docs/manual/media/**'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "${f}" "${OUT}/staging/assets/manual/media/"
|
||||||
|
done
|
||||||
|
echo " manual: index.html and $(ls "${OUT}/staging/assets/manual/media" | wc -l) picture(s), $(du -sh "${OUT}/staging/assets/manual" | cut -f1)"
|
||||||
|
|
||||||
# -0 "" stores the .so without compression so Android can mmap it directly
|
# -0 "" stores the .so without compression so Android can mmap it directly
|
||||||
# (extractNativeLibs=false territory); for a 37 MB library that also keeps
|
# (extractNativeLibs=false territory); for a 37 MB library that also keeps
|
||||||
# install times sane.
|
# install times sane.
|
||||||
|
|||||||
@@ -69,6 +69,23 @@ for dir in face scene inpaint; do
|
|||||||
done
|
done
|
||||||
echo "==> staged $(ls "${STAGE}/models" | wc -l) model file(s)"
|
echo "==> staged $(ls "${STAGE}/models" | wc -l) model file(s)"
|
||||||
|
|
||||||
|
# The manual: the rendered page and its pictures, beside the executable where
|
||||||
|
# dr_ui::manual looks on Windows (dr_plat::system_data_dirs is the exe's own
|
||||||
|
# directory there). The pictures are LFS objects; a pointer is ~130 bytes of
|
||||||
|
# text that every browser draws as a broken image, so refuse it here rather
|
||||||
|
# than ship a manual with no pictures in it.
|
||||||
|
mkdir -p "${STAGE}/manual/media"
|
||||||
|
cp "${REPO}/docs/manual/index.html" "${STAGE}/manual/"
|
||||||
|
for f in "${REPO}/docs/manual/media"/*; do
|
||||||
|
if head -c 40 "${f}" | grep -q '^version https://git-lfs'; then
|
||||||
|
echo "error: $(basename "${f}") is an LFS pointer, not a picture." >&2
|
||||||
|
echo " run: git lfs pull --include='docs/manual/media/**'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "${f}" "${STAGE}/manual/media/"
|
||||||
|
done
|
||||||
|
echo "==> staged the manual and $(ls "${STAGE}/manual/media" | wc -l) picture(s)"
|
||||||
|
|
||||||
# One installer in the output directory, the one just built. The directory
|
# One installer in the output directory, the one just built. The directory
|
||||||
# is cached between CI runs, so after a version bump a glob over it would find
|
# is cached between CI runs, so after a version bump a glob over it would find
|
||||||
# two and the smoke test would hand Wine both names as one path.
|
# two and the smoke test would hand Wine both names as one path.
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ and records the decisions and constraints behind the design.
|
|||||||
## 1. Overview
|
## 1. Overview
|
||||||
|
|
||||||
DarkRoom is a Rust application with a Slint interface. On Linux it renders through wgpu to Vulkan.
|
DarkRoom is a Rust application with a Slint interface. On Linux it renders through wgpu to Vulkan.
|
||||||
On Android it renders through Skia to OpenGL — not by preference but because wgpu's Vulkan
|
On Android it renders through Skia, also on wgpu's Vulkan swapchain, which a patched wgpu-hal and
|
||||||
swapchain cannot pre-rotate, which tears a portrait window on a landscape-mounted panel
|
Skia renderer pre-rotate for a landscape-mounted panel ([technical-debt.md TD-1](technical-debt.md),
|
||||||
([technical-debt.md TD-1](technical-debt.md)). The compute passes are wgpu on both. The design is organised around four ideas, each of which the rest of this
|
[third_party/](../../third_party/README.md)). The compute passes are wgpu on both, on the same
|
||||||
|
device the compositor draws with. The design is organised around four ideas, each of which the rest of this
|
||||||
document elaborates:
|
document elaborates:
|
||||||
|
|
||||||
1. **Pixels stay on the GPU.** From decode to display, image data never round-trips through the
|
1. **Pixels stay on the GPU.** From decode to display, image data never round-trips through the
|
||||||
@@ -1125,12 +1126,11 @@ At 4K the shader finishes in 0.28 ms and then 7.15 ms is spent moving pixels thr
|
|||||||
26× overhead that scales with area, which is why an uncapped window resize falls off a cliff. The
|
26× overhead that scales with area, which is why an uncapped window resize falls off a cliff. The
|
||||||
constraint is not a stylistic preference; it is the dominant cost in the frame.
|
constraint is not a stylistic preference; it is the dominant cost in the frame.
|
||||||
|
|
||||||
**One exception, on Android only, and it is debt rather than a revision.** The develop view there
|
**No exceptions since 0.15.0.** Android read the develop frame back until then, because wgpu's
|
||||||
reads the frame back rather than handing over a texture, because zero-copy requires Slint to draw
|
Vulkan swapchain could not pre-rotate and a portrait window tore on the tablet. Two local patches
|
||||||
with wgpu and wgpu's Android swapchain tears a portrait window. The reasoning, the measurements
|
removed the need ([technical-debt.md TD-1](technical-debt.md)), so the frame reaches the compositor
|
||||||
that forced it and what would remove it are in [technical-debt.md TD-1](technical-debt.md). The
|
as a texture on both platforms. The constraint governs every display path. The export pipeline
|
||||||
constraint above still governs every other path, including the desktop develop view and the export
|
reads pixels back by design, because a file is its output.
|
||||||
pipeline, and the Android exception is expected to be temporary.
|
|
||||||
|
|
||||||
### 6.2 Tiling from day one
|
### 6.2 Tiling from day one
|
||||||
|
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ The set operations are already expressible in fixed-function blending over
|
|||||||
|------|-------------------------------|--------|-------|
|
|------|-------------------------------|--------|-------|
|
||||||
| Union | `One`, `One`, `Max` | `max(dst, src)` | M1 |
|
| Union | `One`, `One`, `Max` | `max(dst, src)` | M1 |
|
||||||
| Subtract | `Zero`, `OneMinusSrc`, `Add` | `dst · (1 − src)` | M1 |
|
| Subtract | `Zero`, `OneMinusSrc`, `Add` | `dst · (1 − src)` | M1 |
|
||||||
| Intersect | `Zero`, `Src`, `Add` | `dst · src` | M2 |
|
| Intersect | `Zero`, `Src`, `Add` | `dst · src` | M2 (built 2026-09-24) |
|
||||||
|
|
||||||
The middle row is `brush_erase`, already constructed in
|
The middle row is `brush_erase`, already constructed in
|
||||||
[`MaskPass::new`](../../core/dr-gpu/src/mask.rs). The other two are the same
|
[`MaskPass::new`](../../core/dr-gpu/src/mask.rs). The other two are the same
|
||||||
@@ -292,6 +292,16 @@ rendering as it did. `an_erase_stroke_holes_its_own_part_and_not_the_mask` in
|
|||||||
[`local_adjustments.rs`](../../core/dr-gpu/tests/local_adjustments.rs) is the test
|
[`local_adjustments.rs`](../../core/dr-gpu/tests/local_adjustments.rs) is the test
|
||||||
that holds this in place.
|
that holds this in place.
|
||||||
|
|
||||||
|
Intersection, as built, is exactly the table's row: a third pipeline beside
|
||||||
|
the other two in `MaskPass::new`, `Join::apply` stating the three on the CPU,
|
||||||
|
and `a_part_unioned_subtracted_and_intersected_gives_the_three_fields` and
|
||||||
|
`the_joins_match_their_definition` in `local_adjustments.rs` holding the GPU
|
||||||
|
to it. It is a product, not a minimum: the two agree wherever either side is
|
||||||
|
fully in or out, and between two soft edges the product is the softer reading,
|
||||||
|
which is the right way for an overlap of two partial selections to be wrong.
|
||||||
|
Its blend is `Add`, not `Max`, so it adds nothing to the Android question
|
||||||
|
below.
|
||||||
|
|
||||||
`Max` blending on `r8unorm` is core WGPU and universally supported on the
|
`Max` blending on `r8unorm` is core WGPU and universally supported on the
|
||||||
desktop backends; **verify it on the Android adapter before M2 lands**, since
|
desktop backends; **verify it on the Android adapter before M2 lands**, since
|
||||||
that is the platform where a blend mode is most likely to be quietly emulated
|
that is the platform where a blend mode is most likely to be quietly emulated
|
||||||
@@ -689,6 +699,14 @@ nothing, and every report of it came back as "the masks do not work".
|
|||||||
per-part distance fields (§5.5), the part list with its join chips, folding
|
per-part distance fields (§5.5), the part list with its join chips, folding
|
||||||
two layers.
|
two layers.
|
||||||
|
|
||||||
|
Done (2026-09-24): `Intersect` — the join, its blend state, the sidecar word
|
||||||
|
`intersect`, the part row's chip cycling + / − / ∩, and an "∩ Intersect"
|
||||||
|
button beside Add and Subtract (§7.1). The part list with its chips and a
|
||||||
|
per-part eye was already there from M1. Outstanding: joining a model,
|
||||||
|
gradient or range part from the panel (the buttons still join a painted
|
||||||
|
part, so an intersection is painted where the mask should survive), per-part
|
||||||
|
distance fields, and folding two layers.
|
||||||
|
|
||||||
**M3 — Push, and cling.** The warp pass (§5.3) and edge-aware deposit (§5.6).
|
**M3 — Push, and cling.** The warp pass (§5.3) and edge-aware deposit (§5.6).
|
||||||
Both are refinements of a tool that already works, which is the right place
|
Both are refinements of a tool that already works, which is the right place
|
||||||
for the two riskiest pieces.
|
for the two riskiest pieces.
|
||||||
|
|||||||
+13
-15
@@ -283,22 +283,20 @@ on one control — the parameter slider in `adjust.slint` — and nothing is set
|
|||||||
all. Everything else in eighteen Slint files is unnamed to AT-SPI and TalkBack. The requirement's own
|
all. Everything else in eighteen Slint files is unnamed to AT-SPI and TalkBack. The requirement's own
|
||||||
caveat, that Slint's Android accessibility needs verifying, is spike S13, which has not run.
|
caveat, that Slint's Android accessibility needs verifying, is spike S13, which has not run.
|
||||||
|
|
||||||
**NFR-A11Y-3 — Colour-independent status.** Built where a control exists, and now tagged: the
|
**NFR-A11Y-3 — Colour-independent status.** Built, and now asserted. The clipping readout pairs a
|
||||||
clipping readout pairs a marker that appears or disappears with a figure in words, the rating strip
|
marker that appears or disappears with a figure in words; the rating strip is a solid star against
|
||||||
is a solid star against an outline in an achromatic palette, the pick/reject mark is a tick against
|
an outline in an achromatic palette; the pick/reject mark is a tick against a cross; the
|
||||||
a cross, and the focus-peaking colour chips say "Red" and "Cyan" rather than showing swatches. Each
|
focus-peaking colour chips say "Red" and "Cyan" rather than showing swatches; and colour labels —
|
||||||
of those already carried the reasoning in a comment naming this requirement and simply had no
|
the requirement's first named example — now have an interface built with a shape from the start:
|
||||||
`TRACES` line.
|
every mark carries its label's initial (R, Y, G, B, P) on its colour, and every chip, picker and the
|
||||||
|
develop bar name the label in words.
|
||||||
|
|
||||||
Two caveats, because the tag now says more than the evidence does. **Only the clipping clause has a
|
The clipping clause is pinned by `a_clipping_figure_distinguishes_none_from_nearly_none`. The other
|
||||||
test** — `a_clipping_figure_distinguishes_none_from_nearly_none`, which pins `<0.1%` apart from `0%`
|
four are pinned by `ui/dr-ui/tests/status_is_not_colour_alone.rs`, which reads the markup and fails
|
||||||
so the figure cannot contradict the lit marker beside it. The three Slint components are
|
if a star, a flag or a label comes to differ in tint alone — if the glyph a state selects stops
|
||||||
inspected-and-argued, not asserted, and nothing would fail if a future edit made a star differ only
|
depending on the state, if two states select the same drawing, if two labels share a letter, or if
|
||||||
in tint. **And the requirement's first named example has no interface at all**: catalog colour
|
the peaking chips stop being words. It is a structural check, not a perceptual one: it cannot say
|
||||||
labels are a nullable `label INTEGER` column on the versions table and are set and shown nowhere, so
|
whether the letters are legible at 14px on a given screen, which is still a matter of looking.
|
||||||
the clause about them is untestable rather than satisfied. That clause closes when the label UI is
|
|
||||||
built, not before, and it should be built with a shape from the outset — which is the same argument
|
|
||||||
as below, for doing this alongside NFR-A11Y-2 rather than after it.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -243,9 +243,15 @@ read metadata, and `import.rs` fetches exactly that range through `Storage::read
|
|||||||
calling `dr_decode::metadata`. The decoder states its requirement and the storage layer satisfies
|
calling `dr_decode::metadata`. The decoder states its requirement and the storage layer satisfies
|
||||||
it; a decoder holding its own `SourceRef` would have had to implement the range policy itself.
|
it; a decoder holding its own `SourceRef` would have had to implement the range policy itself.
|
||||||
|
|
||||||
What is genuinely not built is the trait. There is one decoder, reached through free functions, so
|
*Status (2026-09-24).* The trait is built: `dr_decode::Decoder`, over bytes — `header_bytes`,
|
||||||
"without changing callers" is a claim nothing yet tests. The clause stands as written and is
|
`metadata`, `orientation`, `locate_preview`, `preview` and `decode` — with `dr_decode::Rawler` as
|
||||||
outstanding work, not a satisfied one.
|
its one implementation, delegating to the free functions that were there before. The catalog scan,
|
||||||
|
the thumbnail ladder, import, the viewer, export, merge and repairs take a `&dyn Decoder`; only the
|
||||||
|
places that start a job name `dr_decode::default()`. "Without changing callers" is tested by
|
||||||
|
`dr-ui`'s `decoder_seam` tests, which hand a stub decoder for a container no real decoder reads to
|
||||||
|
the scan, the ladder and export, and fail if any of them reaches past the trait. Nothing in the
|
||||||
|
trait takes a path or a `SourceRef`. The second decoder itself (LibRaw, D2) is not built; S7 (#48)
|
||||||
|
is what would say when it is needed.
|
||||||
|
|
||||||
**FR-RAW-3 — Sensor data handling.** Correctly apply per-camera black/white levels, CFA pattern
|
**FR-RAW-3 — Sensor data handling.** Correctly apply per-camera black/white levels, CFA pattern
|
||||||
identification, and camera-native colour matrices. Demosaic quality shall be selectable, with at
|
identification, and camera-native colour matrices. Demosaic quality shall be selectable, with at
|
||||||
@@ -544,9 +550,17 @@ decade; a mask that cannot be corrected is the reason an edit leaves this applic
|
|||||||
editor.
|
editor.
|
||||||
|
|
||||||
**FR-DEV-19a — Mask composition.** A layer's mask is an ordered list of parts, each naming a source
|
**FR-DEV-19a — Mask composition.** A layer's mask is an ordered list of parts, each naming a source
|
||||||
and how it joins the mask before it — added to it, or taken out of it. A layer of one part is
|
and how it joins the mask before it — added to it, taken out of it, or intersected with it, keeping
|
||||||
exactly the layer that existed before this, and reads and writes the same sidecar. The parts of a
|
only where both agree. A layer of one part is exactly the layer that existed before this, and reads
|
||||||
layer merge under FR-NC-9 as the layer does, and each carries its own edge treatment.
|
and writes the same sidecar. The parts of a layer merge under FR-NC-9 as the layer does, and each
|
||||||
|
carries its own edge treatment.
|
||||||
|
|
||||||
|
*Intersection added 2026-09-24* (issue #9, `mask-editing.md` M2). Union and subtraction were built
|
||||||
|
first; the selections that most need composing — the sky that is also bright, the subject that is
|
||||||
|
also skin — are intersections, and spelling one as a subtraction needs a part that selects the
|
||||||
|
complement. Intersection is the product of the two coverages, which is the minimum wherever either
|
||||||
|
side is fully in or out. A sidecar written before it never names it; a build from before it reads
|
||||||
|
the word as a union, which keeps the part visible rather than dropping it.
|
||||||
|
|
||||||
**FR-DEV-19b — Hand correction.** A part may be painted, with add and erase strokes, at a radius,
|
**FR-DEV-19b — Hand correction.** A part may be painted, with add and erase strokes, at a radius,
|
||||||
hardness and flow the photographer sets. Strokes are stored as normalised source coordinates and
|
hardness and flow the photographer sets. Strokes are stored as normalised source coordinates and
|
||||||
@@ -569,6 +583,17 @@ which knows nothing of a layer's shaping and nothing at all about a gradient, a
|
|||||||
so choosing a subject or a category produced a layer whose extent was invisible, and every control
|
so choosing a subject or a category produced a layer whose extent was invisible, and every control
|
||||||
in FR-DEV-19a and FR-DEV-19b acted on something the photographer could not see.
|
in FR-DEV-19a and FR-DEV-19b acted on something the photographer could not see.
|
||||||
|
|
||||||
|
**FR-DEV-17 — A crop that orphans a mask says so.** When a crop is let go — the end of the drag,
|
||||||
|
not any frame of it — and it has left a mask layer's coverage entirely or mostly outside the frame,
|
||||||
|
the photographer is told how many layers, and which, and offered to keep the crop or take it back.
|
||||||
|
The crop is applied either way and is never refused; taking it back is the ordinary undo, so the
|
||||||
|
crop and its warning are one step in the history. A crop that strands nothing, and a layer already
|
||||||
|
outside the frame before the crop moved, produce no notice at all.
|
||||||
|
Mask geometry is stored in source coordinates, so a tighter crop does not destroy a layer: it makes
|
||||||
|
it invisible, and nothing announces it. That silent loss is the whole case for cropping last; a
|
||||||
|
notice at the moment it happens turns it into a decision, and lets the crop stay where the histogram
|
||||||
|
argues it belongs — early.
|
||||||
|
|
||||||
**FR-DEV-16 — A keyboard vocabulary for develop.** Every develop gesture that can be reached from
|
**FR-DEV-16 — A keyboard vocabulary for develop.** Every develop gesture that can be reached from
|
||||||
the keyboard is bound, tagged beside its implementation, and generated into the gesture book
|
the keyboard is bound, tagged beside its implementation, and generated into the gesture book
|
||||||
(FR-UI-4): stepping through the folder, fit and 1:1, holding the original, undo and redo, copying
|
(FR-UI-4): stepping through the folder, fit and 1:1, holding the original, undo and redo, copying
|
||||||
@@ -580,6 +605,28 @@ Editing rhythm depends on the hands staying put: reaching for a menu breaks the
|
|||||||
edit the same way it breaks the pace of a cull. A binding nobody can discover is the same as no
|
edit the same way it breaks the pace of a cull. A binding nobody can discover is the same as no
|
||||||
binding, which is why the generated book is part of the requirement rather than documentation of it.
|
binding, which is why the generated book is part of the requirement rather than documentation of it.
|
||||||
|
|
||||||
|
*Status (2026-09-24).* Met. Every binding named above is bound in develop's key handler and tagged
|
||||||
|
beside it, and develop also answers zoom in, out, fit and 1:1, panning a magnified view, rating,
|
||||||
|
flagging and labelling the open photograph, nudging the control last moved (the framing sliders
|
||||||
|
included), turning a mask part's join, keeping a crop that hid a mask, and going back to the grid.
|
||||||
|
"Cannot describe a binding the application does not have" is now checked rather than argued:
|
||||||
|
`traces gestures-check` reads the chord every handler compares against (`Keys.chord`, in
|
||||||
|
`ui/dr-ui/ui/keys.slint`) and fails when a bound key has no tag in its place or a tagged key has no
|
||||||
|
handler (`tools/traceability/src/keymap.rs`).
|
||||||
|
|
||||||
|
**FR-DEV-20 — Perspective correction.** Framing shall include perspective correction — a vertical
|
||||||
|
and a horizontal keystone — applied before the crop. It runs in the coordinate chain after the
|
||||||
|
crop and the straightening and before the stored orientation and the lens warp, so that "vertical"
|
||||||
|
means vertical in the photograph as it is shown and the lens is still corrected on the full frame
|
||||||
|
it projected. It is part of framing and carries its `Compose` attribute, so a settings paste
|
||||||
|
withholds it by default for the same reason it withholds the crop. The correction never exposes
|
||||||
|
undefined area on its own; where it is combined with a straightening angle, the crop that avoids
|
||||||
|
the empty corners (`max_inscribed_crop`) accounts for it, and the crop is refitted when the gesture
|
||||||
|
ends.
|
||||||
|
Converging verticals are the commonest geometric fault in architectural and interior work, and
|
||||||
|
correcting them reframes the image — which is why the order relative to the crop is not a
|
||||||
|
preference, and why the correction belongs to composition and not to the colour operations.
|
||||||
|
|
||||||
### 3.4 Display and interaction
|
### 3.4 Display and interaction
|
||||||
|
|
||||||
**FR-DSP-1 — Proxy-resolution rendering.** The develop view renders at the resolution actually
|
**FR-DSP-1 — Proxy-resolution rendering.** The develop view renders at the resolution actually
|
||||||
@@ -695,6 +742,15 @@ rating, and common adjustments. Neither is required for any operation to be reac
|
|||||||
> wherever they can be set, and on the roll's cells, so stepping along a set shows what has been
|
> wherever they can be set, and on the roll's cells, so stepping along a set shows what has been
|
||||||
> judged.
|
> judged.
|
||||||
|
|
||||||
|
*Status (2026-09-24).* The keyboard half and the amendment are met; the pointer half is not yet.
|
||||||
|
Keys cover navigation, rating and common adjustments in the grid, develop, the collections sidebar,
|
||||||
|
People and the export and copy sheets, each one in the gesture book and held to its handler by the
|
||||||
|
gestures gate. Rating and flagging work in develop on the open photograph without advancing, with
|
||||||
|
stars and Pick/Reject in the top bar, and the roll's cells carry each frame's flag and stars; pick
|
||||||
|
and reject also gained a pointer and touch route in the grid (Flag in the selection bar), where they
|
||||||
|
had been keyboard-only. Outstanding: scroll-wheel adjustment on numeric controls — the develop
|
||||||
|
sliders do not take the wheel.
|
||||||
|
|
||||||
**FR-UI-6 — Shared component library.** Touch and desktop presentations are variants of shared
|
**FR-UI-6 — Shared component library.** Touch and desktop presentations are variants of shared
|
||||||
components, not parallel implementations. A new operation (FR-DEV-3c) becomes usable on both
|
components, not parallel implementations. A new operation (FR-DEV-3c) becomes usable on both
|
||||||
without frontend work.
|
without frontend work.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ has quietly stopped being necessary.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## TD-1 — The Android develop view reads pixels back through the CPU
|
## TD-1 — The Android develop view reads pixels back through the CPU ✅ PAID OFF
|
||||||
|
|
||||||
**Breaks:** [architecture.md §12 / 6.1](architecture.md) — GPU results never round-trip through the
|
**Breaks:** [architecture.md §12 / 6.1](architecture.md) — GPU results never round-trip through the
|
||||||
CPU — and AC-8, on Android only. Desktop is unaffected and keeps the zero-copy path.
|
CPU — and AC-8, on Android only. Desktop is unaffected and keeps the zero-copy path.
|
||||||
@@ -88,6 +88,41 @@ Any one of these removes it:
|
|||||||
`unstable-wgpu-29` to non-Android, the `#[cfg(target_os = "android")]` arm of
|
`unstable-wgpu-29` to non-Android, the `#[cfg(target_os = "android")]` arm of
|
||||||
`DevelopSession::render` is gone, and the tablet is clean in portrait.
|
`DevelopSession::render` is gone, and the tablet is clean in portrait.
|
||||||
|
|
||||||
|
### Paid off
|
||||||
|
|
||||||
|
By none of the three routes above. It came from a fourth that the list missed: patching both
|
||||||
|
halves locally. "wgpu cannot do [the rotation] on Skia's behalf" was true and beside the point,
|
||||||
|
because Skia can rotate its own canvas. Slint's Skia renderer already does it for rotated panels on
|
||||||
|
linuxkms, just not on its wgpu surface. So two small patches, carried in `third_party/`
|
||||||
|
([README](../../third_party/README.md)), do it for us:
|
||||||
|
|
||||||
|
- **wgpu-hal 29.0.4.** `vulkan::Surface::set_pre_transform` lets a caller choose the swapchain's
|
||||||
|
`preTransform`, and `current_transform` reads the surface's. It is opt-in, and the default stays
|
||||||
|
`IDENTITY`, so desktop is unchanged.
|
||||||
|
- **i-slint-renderer-skia 1.17.1.** On Android the wgpu surface sizes its swapchain in the panel's
|
||||||
|
orientation, promises the display's transform, and concatenates the matching rotation onto the
|
||||||
|
canvas before Slint draws. The imported develop texture goes through the same matrix as
|
||||||
|
everything else. The surface re-reads the transform every frame, because a half turn does not
|
||||||
|
resize the window. The item renderer's pixel snapping now accepts right-angle rotations,
|
||||||
|
or portrait would have lost it everywhere.
|
||||||
|
|
||||||
|
With those in place, `unstable-wgpu-29` is common to both platforms, `shared_gpu` hands the one
|
||||||
|
device to Slint on Android too, and both readbacks are gone (the frame through `export_pixels` and
|
||||||
|
the focus overlay through `read_overlay`). The develop frame and its overlay reach the compositor
|
||||||
|
as textures, as they do on desktop.
|
||||||
|
|
||||||
|
**Verified by eye, not by instrument.** On 2026-09-25 the user checked the release-signed build on
|
||||||
|
the tablet and found it clean. That is the portrait tear this entry was opened over. The
|
||||||
|
`dumpsys SurfaceFlinger` readings that would complete the table above (composition and
|
||||||
|
`bufferTransform` per orientation, expected `DEVICE` in all three) were not taken, because adb
|
||||||
|
would not hold the device that morning. Nor was the develop frame time measured before or after.
|
||||||
|
The readback's cost was never measured either, so the saving is reasoned, not measured.
|
||||||
|
|
||||||
|
**The cost moved rather than vanished:** two upstream crates are pinned by path. A Slint or wgpu
|
||||||
|
bump has to carry the patches forward (the README says how), and cargo only *warns* when a patch no
|
||||||
|
longer matches, then silently builds the unpatched crate. Both patches should go upstream: the
|
||||||
|
Skia half is the Android counterpart of a feature Slint already has, and the wgpu half is #3345.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## TD-2 — Thumbnails are fetched one at a time
|
## TD-2 — Thumbnails are fetched one at a time
|
||||||
|
|||||||
+108
-106
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.
|
with them because it is not in their column.
|
||||||
2. **Sliders** — `GroupStrip` above `AdjustPanel`, exactly as in the 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.
|
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
|
`SpotPanel` in repair, `MaskPanel` in local. Empty otherwise, which is
|
||||||
a signal of its own about which mode the view is in (§1.1).
|
a signal of its own about which mode the view is in (§1.1).
|
||||||
|
|
||||||
|
|||||||
@@ -285,6 +285,8 @@ $LOCALAPPDATA\Programs\DarkRoom\
|
|||||||
scrfd_500m_640.int8.onnx scrfd_2.5g_640.int8.onnx scrfd_10g_640.int8.onnx
|
scrfd_500m_640.int8.onnx scrfd_2.5g_640.int8.onnx scrfd_10g_640.int8.onnx
|
||||||
yolo26s-sem-ade20k.onnx yolo26s-sem-ade20k.classes.json categories.txt
|
yolo26s-sem-ade20k.onnx yolo26s-sem-ade20k.classes.json categories.txt
|
||||||
migan-512.onnx
|
migan-512.onnx
|
||||||
|
manual\
|
||||||
|
index.html media\ (the rendered manual and its pictures)
|
||||||
LICENSE
|
LICENSE
|
||||||
uninstall.exe
|
uninstall.exe
|
||||||
```
|
```
|
||||||
|
|||||||
+363
-60
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
|
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
|
||||||
|
|
||||||
41 gestures, in 4 places.
|
67 gestures, in 5 places.
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ Every entry here is extracted from the comment beside the code that implements i
|
|||||||
|
|
||||||
- **Touch** — Press "pick" in the group's heading, then tap something neutral in the picture
|
- **Touch** — Press "pick" in the group's heading, then tap something neutral in the picture
|
||||||
- **Pointer** — Press "pick", then click something neutral
|
- **Pointer** — Press "pick", then click something neutral
|
||||||
|
- **See it** — [in the manual](manual/README.md#white-balance-from-the-photograph)
|
||||||
|
|
||||||
Sampling a neutral is the first move of the tonal pass — every colour judgement afterwards is measured against where the grey was put — and guessing at two sliders until a wall stops looking green is the wrong way round. One click is one sample and one step to undo; the sliders stay, because a sampled neutral is where the decision starts rather than where it ends.
|
Sampling a neutral is the first move of the tonal pass — every colour judgement afterwards is measured against where the grey was put — and guessing at two sliders until a wall stops looking green is the wrong way round. One click is one sample and one step to undo; the sliders stay, because a sampled neutral is where the decision starts rather than where it ends.
|
||||||
|
|
||||||
@@ -22,112 +23,223 @@ Sampling a neutral is the first move of the tonal pass — every colour judgemen
|
|||||||
|
|
||||||
- **Touch** — Pinch it with two fingers
|
- **Touch** — Pinch it with two fingers
|
||||||
- **Pointer** — The scroll wheel over it
|
- **Pointer** — The scroll wheel over it
|
||||||
|
- **Keyboard** — `Ctrl+=` or `Ctrl+Plus` in, `Ctrl+-` out, about the middle of the view
|
||||||
|
- **See it** — [in the manual](manual/README.md#looking-closer)
|
||||||
|
|
||||||
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.
|
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1762`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:1885`</sub>
|
||||||
|
|
||||||
### Move a magnified photograph about
|
### Move a magnified photograph about
|
||||||
|
|
||||||
- **Touch** — Drag it
|
- **Touch** — Drag it
|
||||||
- **Pointer** — Drag it
|
- **Pointer** — Drag it
|
||||||
|
- **Keyboard** — `Shift+←`, `Shift+→`, `Shift+↑` and `Shift+↓`, a fifth of the view at a time
|
||||||
|
- **See it** — [in the manual](manual/README.md#looking-closer)
|
||||||
|
|
||||||
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1853`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:1981`</sub>
|
||||||
|
|
||||||
### Paint a mask by hand
|
### Paint a mask by hand
|
||||||
|
|
||||||
- **Touch** — Choose Paint or Erase, then drag on the photograph
|
- **Touch** — Choose Paint or Erase, then drag on the photograph
|
||||||
- **Pointer** — Choose Paint or Erase, then drag
|
- **Pointer** — Choose Paint or Erase, then drag
|
||||||
|
- **See it** — [in the manual](manual/README.md#local-adjustments)
|
||||||
|
|
||||||
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
|
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1940`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2072`</sub>
|
||||||
|
|
||||||
### Take back the last change
|
### Take back the last change
|
||||||
|
|
||||||
- **Touch** — Tap the step above the current one in the History list
|
- **Touch** — Tap the step above the current one in the History list
|
||||||
- **Pointer** — Click it, or press Undo in the History header
|
- **Pointer** — Click it, or press Undo in the History header
|
||||||
- **Keyboard** — Ctrl+Z
|
- **Keyboard** — `Ctrl+Z`
|
||||||
|
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
|
||||||
|
|
||||||
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2160`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2306`</sub>
|
||||||
|
|
||||||
### Do it again after taking it back
|
### Do it again after taking it back
|
||||||
|
|
||||||
- **Touch** — Tap the step below the current one in the History list
|
- **Touch** — Tap the step below the current one in the History list
|
||||||
- **Pointer** — Click it, or press Redo in the History header
|
- **Pointer** — Click it, or press Redo in the History header
|
||||||
- **Keyboard** — Ctrl+Shift+Z
|
- **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y`
|
||||||
|
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2173`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2320`</sub>
|
||||||
|
|
||||||
|
### Remove a repair
|
||||||
|
|
||||||
|
- **Touch** — Tap it, then Delete Repair
|
||||||
|
- **Pointer** — Click it, then Delete Repair
|
||||||
|
- **Keyboard** — `Delete` or `Backspace`, while repairing
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/app.slint:2340`</sub>
|
||||||
|
|
||||||
### Copy the settings from this photograph
|
### Copy the settings from this photograph
|
||||||
|
|
||||||
- **Touch** — Press Copy in the Settings panel
|
- **Touch** — Press Copy in the top bar
|
||||||
- **Pointer** — Press Copy in the Settings panel
|
- **Pointer** — Press Copy in the top bar
|
||||||
- **Keyboard** — Ctrl+C
|
- **Keyboard** — `Ctrl+C`
|
||||||
|
- **See it** — [in the manual](manual/README.md#copying-settings)
|
||||||
|
|
||||||
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:2359`</sub>
|
||||||
|
|
||||||
### Paste the settings onto this photograph
|
### Paste the settings onto this photograph
|
||||||
|
|
||||||
- **Touch** — Press Paste in the Settings panel
|
- **Touch** — Press Paste in the top bar
|
||||||
- **Pointer** — Press Paste in the Settings panel
|
- **Pointer** — Press Paste in the top bar
|
||||||
- **Keyboard** — Ctrl+V
|
- **Keyboard** — `Ctrl+V`
|
||||||
|
- **See it** — [in the manual](manual/README.md#copying-settings)
|
||||||
|
|
||||||
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2218`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2372`</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
|
||||||
|
- **See it** — [in the manual](manual/README.md#copying-settings)
|
||||||
|
|
||||||
|
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:2390`</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`
|
||||||
|
- **See it** — [in the manual](manual/README.md#export)
|
||||||
|
|
||||||
|
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:2415`</sub>
|
||||||
|
|
||||||
|
### Choose how to export, then export
|
||||||
|
|
||||||
|
- **Touch** — Open Settings, then Export defaults
|
||||||
|
- **Pointer** — Open Settings, then Export defaults
|
||||||
|
- **Keyboard** — `Ctrl+E`
|
||||||
|
- **See it** — [in the manual](manual/README.md#export)
|
||||||
|
|
||||||
|
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:2428`</sub>
|
||||||
|
|
||||||
|
### Keep a crop that leaves a mask outside
|
||||||
|
|
||||||
|
- **Touch** — Press "Keep crop" on the notice, or "Undo crop" to take it back
|
||||||
|
- **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back
|
||||||
|
- **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/app.slint:2493`</sub>
|
||||||
|
|
||||||
|
### Go back to the grid
|
||||||
|
|
||||||
|
- **Touch** — Press "‹ Library" in the top bar
|
||||||
|
- **Pointer** — Press "‹ Library" in the top bar
|
||||||
|
- **Keyboard** — `G`
|
||||||
|
|
||||||
|
Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/app.slint:2510`</sub>
|
||||||
|
|
||||||
|
### Nudge the control last moved
|
||||||
|
|
||||||
|
- **Touch** — Drag its track
|
||||||
|
- **Pointer** — Drag its track
|
||||||
|
- **Keyboard** — `=` or `Plus` up and `-` down, a hundredth of its travel at a time; hold for more
|
||||||
|
|
||||||
|
Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/app.slint:2539`</sub>
|
||||||
|
|
||||||
### Change which group of adjustments is on screen
|
### Change which group of adjustments is on screen
|
||||||
|
|
||||||
- **Touch** — Tap a group in the rail down the left
|
- **Touch** — Tap a group in the rail down the left
|
||||||
- **Pointer** — Click a group in the strip above the develop column
|
- **Pointer** — Click a group in the strip above the develop column
|
||||||
- **Keyboard** — [ and ] step through them, wrapping round through "everything"
|
- **Keyboard** — `[` and `]` step through them, wrapping round through "everything"
|
||||||
|
- **See it** — [in the manual](manual/README.md#developing-a-photograph)
|
||||||
|
|
||||||
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2246`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2567`</sub>
|
||||||
|
|
||||||
### Look at the photograph at 1:1
|
### Look at the photograph at 1:1
|
||||||
|
|
||||||
- **Touch** — Double-tap the photograph
|
- **Touch** — Double-tap the photograph
|
||||||
- **Pointer** — Double-click it, or press the zoom readout floating over the canvas
|
- **Pointer** — Double-click it, or press the zoom readout floating over the canvas
|
||||||
- **Keyboard** — Z
|
- **Keyboard** — `Z` goes in and back out; `Ctrl+1` goes to 1:1 and `Ctrl+0` back to the whole frame
|
||||||
|
- **See it** — [in the manual](manual/README.md#looking-closer)
|
||||||
|
|
||||||
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.
|
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2281`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2603`</sub>
|
||||||
|
|
||||||
|
### Rate this photograph
|
||||||
|
|
||||||
|
- **Touch** — Tap a star in the top bar
|
||||||
|
- **Pointer** — Click a star in the top bar
|
||||||
|
- **Keyboard** — `0`–`5`
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/app.slint:2660`</sub>
|
||||||
|
|
||||||
|
### Pick or reject this photograph
|
||||||
|
|
||||||
|
- **Touch** — Press Pick or Reject in the top bar; again to take the flag off
|
||||||
|
- **Pointer** — Press Pick or Reject in the top bar; again to take the flag off
|
||||||
|
- **Keyboard** — `P` picks, `X` rejects and `U` takes the flag off
|
||||||
|
|
||||||
|
The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/app.slint:2666`</sub>
|
||||||
|
|
||||||
|
### Give this photograph a colour label
|
||||||
|
|
||||||
|
- **Touch** — Tap Label in the top bar, then a colour
|
||||||
|
- **Pointer** — Click Label in the top bar, then a colour
|
||||||
|
- **Keyboard** — `6` red, `7` yellow, `8` green, `9` blue; the same key again takes it off
|
||||||
|
- **See it** — [in the manual](manual/README.md#rating-and-flagging)
|
||||||
|
|
||||||
|
The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/app.slint:2696`</sub>
|
||||||
|
|
||||||
### Move to the next or previous photograph
|
### Move to the next or previous photograph
|
||||||
|
|
||||||
- **Touch** — Tap a frame in the roll along the foot of the canvas
|
- **Touch** — Tap a frame in the roll along the foot of the canvas
|
||||||
- **Pointer** — Click a frame in the roll
|
- **Pointer** — Click a frame in the roll
|
||||||
- **Keyboard** — Right arrow or space for the next, left arrow for the one before
|
- **Keyboard** — `→`, `D` or `Space` for the next; `←` or `A` for the one before
|
||||||
|
- **See it** — [in the manual](manual/README.md#moving-between-photographs)
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2333`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2721`</sub>
|
||||||
|
|
||||||
### See the photograph before you edited it
|
### See the photograph before you edited it
|
||||||
|
|
||||||
- **Touch** — Press and hold "Before"
|
- **Touch** — Press and hold "Before"
|
||||||
- **Pointer** — Press and hold "Before"
|
- **Pointer** — Press and hold "Before"
|
||||||
- **Keyboard** — Hold \
|
- **Keyboard** — Hold `\`
|
||||||
|
- **See it** — [in the manual](manual/README.md#light)
|
||||||
|
|
||||||
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2457`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2851`</sub>
|
||||||
|
|
||||||
### Put one control back to its default
|
### Put one control back to its default
|
||||||
|
|
||||||
- **Touch** — Double-tap its track
|
- **Touch** — Double-tap its track
|
||||||
- **Pointer** — Double-click its track, or right-click it
|
- **Pointer** — Double-click its track, or right-click it
|
||||||
- **Keyboard** — R, for the control last moved
|
- **Keyboard** — `R`, for the control last moved
|
||||||
|
|
||||||
The column is 280px wide and the colour mixer alone puts thirty-six of these in it, so a reset button per row would be most of the width. Two ways in with a pointer because right-click is the one a hand already reaches for and double-click is the one that needs no second button. A group's own reset is in its heading; this is the single control.
|
The column is 280px wide and the colour mixer alone puts thirty-six of these in it, so a reset button per row would be most of the width. Two ways in with a pointer because right-click is the one a hand already reaches for and double-click is the one that needs no second button. A group's own reset is in its heading; this is the single control.
|
||||||
|
|
||||||
@@ -137,6 +249,7 @@ The column is 280px wide and the colour mixer alone puts thirty-six of these in
|
|||||||
|
|
||||||
- **Touch** — Press and hold the eye beside the snapshot
|
- **Touch** — Press and hold the eye beside the snapshot
|
||||||
- **Pointer** — Press and hold the eye beside the snapshot
|
- **Pointer** — Press and hold the eye beside the snapshot
|
||||||
|
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
|
||||||
|
|
||||||
The same hold as "Before", against a point the photographer chose rather than the file: "the version I liked twenty minutes ago" is how a choice between two treatments is actually made. It takes no history step and changes nothing; letting go puts the edit back.
|
The same hold as "Before", against a point the photographer chose rather than the file: "the version I liked twenty minutes ago" is how a choice between two treatments is actually made. It takes no history step and changes nothing; letting go puts the edit back.
|
||||||
|
|
||||||
@@ -146,38 +259,73 @@ The same hold as "Before", against a point the photographer chose rather than th
|
|||||||
|
|
||||||
- **Touch** — Type a name in the History panel and press Snapshot
|
- **Touch** — Type a name in the History panel and press Snapshot
|
||||||
- **Pointer** — Type a name in the History panel and press Snapshot
|
- **Pointer** — Type a name in the History panel and press Snapshot
|
||||||
|
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
|
||||||
|
|
||||||
The history is forgotten with the sitting, on purpose; a snapshot is the photographer saying this one should not be. It is written into the sidecar as a version of the edit, so it survives a restart and reaches the other device. Pressing a snapshot puts the photograph back to it, as one step that undo takes back whole.
|
The history is forgotten with the sitting, on purpose; a snapshot is the photographer saying this one should not be. It is written into the sidecar as a version of the edit, so it survives a restart and reaches the other device. Pressing a snapshot puts the photograph back to it, as one step that undo takes back whole.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/history.slint:307`</sub>
|
<sub>`ui/dr-ui/ui/history.slint:308`</sub>
|
||||||
|
|
||||||
### Show or hide one mask layer
|
### Show or hide one mask layer
|
||||||
|
|
||||||
- **Touch** — Tap the ring at the head of its row
|
- **Touch** — Tap the ring at the head of its row
|
||||||
- **Pointer** — Click the ring at the head of its row
|
- **Pointer** — Click the ring at the head of its row
|
||||||
- **Keyboard** — H, for the selected layer history step, unlike holding "Before" — the layer really is off until it is switched back on.
|
- **Keyboard** — `H`, for the selected layer
|
||||||
|
- **See it** — [in the manual](manual/README.md#local-adjustments)
|
||||||
|
|
||||||
Disabling a layer is the before-and-after a local edit constantly wants, so it is one press away rather than inside the row. It is an edit and does take a
|
Disabling a layer is the before-and-after a local edit constantly wants, so it is one press away rather than inside the row. It is an edit and does take a history step, unlike holding "Before" — the layer really is off until it is switched back on.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/masks.slint:212`</sub>
|
<sub>`ui/dr-ui/ui/masks.slint:213`</sub>
|
||||||
|
|
||||||
### Show or hide one mask on the photograph
|
### Show or hide one mask on the photograph
|
||||||
|
|
||||||
- **Touch** — Tap the eye on its row
|
- **Touch** — Tap the eye on its row
|
||||||
- **Pointer** — Click the eye on its row
|
- **Pointer** — Click the eye on its row
|
||||||
|
- **See it** — [in the manual](manual/README.md#local-adjustments)
|
||||||
|
|
||||||
A mask is judged by seeing where it falls, and two are judged by seeing where they meet — so each row has its own eye rather than the panel having one, and the eye is drawn in the colour the mask shows in, so the row says which shape on the picture is its. Nothing about the edit changes: this is how the photograph is looked at, and takes no history step.
|
A mask is judged by seeing where it falls, and two are judged by seeing where they meet — so each row has its own eye rather than the panel having one, and the eye is drawn in the colour the mask shows in, so the row says which shape on the picture is its. Nothing about the edit changes: this is how the photograph is looked at, and takes no history step.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/masks.slint:279`</sub>
|
<sub>`ui/dr-ui/ui/masks.slint:280`</sub>
|
||||||
|
|
||||||
|
### Change how a part joins its mask
|
||||||
|
|
||||||
|
- **Touch** — Tap the + / − / ∩ chip on the part's row
|
||||||
|
- **Pointer** — Click the + / − / ∩ chip on the part's row
|
||||||
|
- **Keyboard** — `J` turns the selected part's chip, while masking
|
||||||
|
- **See it** — [in the manual](manual/README.md#local-adjustments)
|
||||||
|
|
||||||
|
A chip that cycles rather than a menu, because a photographer flips a join while looking at the picture, not at the panel: add, take away, keep only where both agree, and round.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/masks.slint:367`</sub>
|
||||||
|
|
||||||
### Leave one part out of a mask, and put it back
|
### Leave one part out of a mask, and put it back
|
||||||
|
|
||||||
- **Touch** — Tap the ring on the part's row
|
- **Touch** — Tap the ring on the part's row
|
||||||
- **Pointer** — Click the ring on the part's row
|
- **Pointer** — Click the ring on the part's row
|
||||||
|
- **See it** — [in the manual](manual/README.md#local-adjustments)
|
||||||
|
|
||||||
The question a correction raises is whether it did what it was for — whether the stroke filled the shoulder, whether the subtracted gradient took only the sky. Removing it answers that and loses it. The same ring the layer wears, one row down, because it is the same question about a smaller thing.
|
The question a correction raises is whether it did what it was for — whether the stroke filled the shoulder, whether the subtracted gradient took only the sky. Removing it answers that and loses it. The same ring the layer wears, one row down, because it is the same question about a smaller thing.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/masks.slint:394`</sub>
|
<sub>`ui/dr-ui/ui/masks.slint:409`</sub>
|
||||||
|
|
||||||
|
## Everywhere
|
||||||
|
|
||||||
|
### Close what is open, or go back a step
|
||||||
|
|
||||||
|
- **Touch** — The system Back gesture, or the Back button
|
||||||
|
- **Pointer** — The Close or Back button on whatever is open
|
||||||
|
- **Keyboard** — `Escape`, or `Back` where the device has one
|
||||||
|
|
||||||
|
One key for "up one", innermost first: a question before the sheet under it, a sheet before the view, a view before the library. Nothing is left behind a dialogue that the key walked straight past.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/app.slint:975`</sub>
|
||||||
|
|
||||||
|
### Do what a sheet offers
|
||||||
|
|
||||||
|
- **Touch** — Press its button — Export, or Copy
|
||||||
|
- **Pointer** — Press its button — Export, or Copy
|
||||||
|
- **Keyboard** — `Enter`, on the export and copy sheets
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/app.slint:985`</sub>
|
||||||
|
|
||||||
## Collections sidebar
|
## Collections sidebar
|
||||||
|
|
||||||
@@ -185,37 +333,42 @@ The question a correction raises is whether it did what it was for — whether t
|
|||||||
|
|
||||||
- **Touch** — Press and hold it until it lifts, then drag it
|
- **Touch** — Press and hold it until it lifts, then drag it
|
||||||
- **Pointer** — Drag it, or hold it until it lifts and then drag
|
- **Pointer** — Drag it, or hold it until it lifts and then drag
|
||||||
|
- **See it** — [in the manual](manual/README.md#collections)
|
||||||
|
|
||||||
The tree is inside a Flickable, which claims any drag beginning inside it — so with a finger a drag on a row is a scroll until something says otherwise. The hold is that something, and it is what every mobile list already uses to pick a row up. The row lifts the moment it fires, so the gesture says it has been understood before anything moves.
|
The tree is inside a Flickable, which claims any drag beginning inside it — so with a finger a drag on a row is a scroll until something says otherwise. The hold is that something, and it is what every mobile list already uses to pick a row up. The row lifts the moment it fires, so the gesture says it has been understood before anything moves.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/collections.slint:335`</sub>
|
<sub>`ui/dr-ui/ui/collections.slint:351`</sub>
|
||||||
|
|
||||||
### Act on a collection — rename, nest, un-nest, delete
|
### Act on a collection — rename, nest, un-nest, delete
|
||||||
|
|
||||||
- **Touch** — Press and hold the collection, then let go without moving
|
- **Touch** — Press and hold the collection, then let go without moving
|
||||||
- **Pointer** — Right-click it
|
- **Pointer** — Right-click it
|
||||||
|
- **See it** — [in the manual](manual/README.md#collections)
|
||||||
|
|
||||||
The hold arms a drag and opens this menu, and which one you get is decided by whether you moved — the same fork the grid uses. One menu for everything done to a row, because there is one hold per row: while the hold opened the offline question by itself, nothing else the tree can do had a touch route.
|
The hold arms a drag and opens this menu, and which one you get is decided by whether you moved — the same fork the grid uses. One menu for everything done to a row, because there is one hold per row: while the hold opened the offline question by itself, nothing else the tree can do had a touch route.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/collections.slint:346`</sub>
|
<sub>`ui/dr-ui/ui/collections.slint:363`</sub>
|
||||||
|
|
||||||
### Take a collection back out of the one it is nested in
|
### Take a collection back out of the one it is nested in
|
||||||
|
|
||||||
- **Touch** — Hold it, then drag it onto "All photographs" — or let go and choose "Move to top level"
|
- **Touch** — Hold it, then drag it onto "All photographs" — or let go and choose "Move to top level"
|
||||||
- **Pointer** — Drag it onto "All photographs", or right-click it and choose "Move to top level"
|
- **Pointer** — Drag it onto "All photographs", or right-click it and choose "Move to top level"
|
||||||
|
- **See it** — [in the manual](manual/README.md#collections)
|
||||||
|
|
||||||
Nesting is a drag of one row onto another, and its inverse had no gesture at all: "All photographs" refused every drop, which is right for a photograph — it is already in the library — and wrong for a collection, which has a top level to be returned to. Without it a collection dragged into another was in there permanently.
|
Nesting is a drag of one row onto another, and its inverse had no gesture at all: "All photographs" refused every drop, which is right for a photograph — it is already in the library — and wrong for a collection, which has a top level to be returned to. Without it a collection dragged into another was in there permanently.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/collections.slint:356`</sub>
|
<sub>`ui/dr-ui/ui/collections.slint:374`</sub>
|
||||||
|
|
||||||
### Rename a collection
|
### Rename a collection
|
||||||
|
|
||||||
- **Touch** — Hold the collection, then "Rename"
|
- **Touch** — Hold the collection, then "Rename"
|
||||||
- **Pointer** — Double-click its name, or right-click it and choose "Rename"
|
- **Pointer** — Double-click its name, or right-click it and choose "Rename"
|
||||||
|
- **Keyboard** — Type the name and press `Enter`; `Escape` abandons it
|
||||||
|
- **See it** — [in the manual](manual/README.md#collections)
|
||||||
|
|
||||||
Double-click is what a file manager and a Lightroom panel use for the same thing, so it needs no discovering — but nothing on screen says so, which is what the menu item is for.
|
Double-click is what a file manager and a Lightroom panel use for the same thing, so it needs no discovering — but nothing on screen says so, which is what the menu item is for.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/collections.slint:369`</sub>
|
<sub>`ui/dr-ui/ui/collections.slint:388`</sub>
|
||||||
|
|
||||||
## People
|
## People
|
||||||
|
|
||||||
@@ -223,37 +376,59 @@ Double-click is what a file manager and a Lightroom panel use for the same thing
|
|||||||
|
|
||||||
- **Touch** — Tap the faces that do not belong, then "Split off"
|
- **Touch** — Tap the faces that do not belong, then "Split off"
|
||||||
- **Pointer** — Click the faces that do not belong, then "Split off"
|
- **Pointer** — Click the faces that do not belong, then "Split off"
|
||||||
|
- **See it** — [in the manual](manual/README.md#people)
|
||||||
|
|
||||||
Grouping over-merges on siblings, on parents and children, and on the same person a decade apart, so splitting is as prominent as merging. A tool that can only merge makes its own errors permanent.
|
Grouping over-merges on siblings, on parents and children, and on the same person a decade apart, so splitting is as prominent as merging. A tool that can only merge makes its own errors permanent.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/identity.slint:188`</sub>
|
<sub>`ui/dr-ui/ui/identity.slint:189`</sub>
|
||||||
|
|
||||||
### Rule on a suggested face
|
### Rule on a suggested face
|
||||||
|
|
||||||
- **Touch** — Tick to confirm it, cross to reject it
|
- **Touch** — Tick to confirm it, cross to reject it
|
||||||
- **Pointer** — Tick to confirm it, cross to reject it
|
- **Pointer** — Tick to confirm it, cross to reject it
|
||||||
|
- **See it** — [in the manual](manual/README.md#people)
|
||||||
|
|
||||||
A face is either the system's guess or the user's judgement, and the two are never conflated. A rejection is remembered, so the face is not suggested for that person again. The gesture note above is the whole label: a tick and a cross are only "confirm" and "reject" to someone who can see the suggestion they sit beside, and `IconButton`'s fallback would announce them as "check" and "cross" — two icon names that say nothing about which person is being ruled on.
|
A face is either the system's guess or the user's judgement, and the two are never conflated. A rejection is remembered, so the face is not suggested for that person again. The gesture note above is the whole label: a tick and a cross are only "confirm" and "reject" to someone who can see the suggestion they sit beside, and `IconButton`'s fallback would announce them as "check" and "cross" — two icon names that say nothing about which person is being ruled on.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/identity.slint:208`</sub>
|
<sub>`ui/dr-ui/ui/identity.slint:210`</sub>
|
||||||
|
|
||||||
|
### Move between people
|
||||||
|
|
||||||
|
- **Touch** — Tap a person on the rail
|
||||||
|
- **Pointer** — Click a person on the rail
|
||||||
|
- **Keyboard** — `↑` and `↓`, along the rail
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/identity.slint:385`</sub>
|
||||||
|
|
||||||
|
### Name a person
|
||||||
|
|
||||||
|
- **Touch** — Tap the name field over their faces, type, and press Enter
|
||||||
|
- **Pointer** — Click the name field over their faces, type, and press Enter
|
||||||
|
- **Keyboard** — `F2` puts the name field under the keys
|
||||||
|
|
||||||
|
The rename key everywhere else. Enter finishes the name and hands the keys back to the rail, so naming a run of people is `Down`, `F2`, a name, Enter, over and over.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/identity.slint:391`</sub>
|
||||||
|
|
||||||
### See a person's photographs
|
### See a person's photographs
|
||||||
|
|
||||||
- **Touch** — Choose them in the rail, then "Show photos"
|
- **Touch** — Choose them in the rail, then "Show photos"
|
||||||
- **Pointer** — Choose them in the rail, then "Show photos"
|
- **Pointer** — Choose them in the rail, then "Show photos"
|
||||||
|
- **See it** — [in the manual](manual/README.md#people)
|
||||||
|
|
||||||
This is the point of having identified anybody. Without it the screen is a filing cabinet with no drawer handles.
|
This is the point of having identified anybody. Without it the screen is a filing cabinet with no drawer handles.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/identity.slint:635`</sub>
|
<sub>`ui/dr-ui/ui/identity.slint:689`</sub>
|
||||||
|
|
||||||
### Change how faces are grouped
|
### Change how faces are grouped
|
||||||
|
|
||||||
- **Touch** — "Grouping…", move the dials, then Regroup
|
- **Touch** — "Grouping…", move the dials, then Regroup
|
||||||
- **Pointer** — "Grouping…", move the dials, then Regroup
|
- **Pointer** — "Grouping…", move the dials, then Regroup
|
||||||
|
- **See it** — [in the manual](manual/README.md#people)
|
||||||
|
|
||||||
The right match confidence is a property of your library, not of the model. "What would this do?" answers for this library without writing anything; names, confirmations and the groups you have set aside are kept whatever the dials say.
|
The right match confidence is a property of your library, not of the model. "What would this do?" answers for this library without writing anything; names, confirmations and the groups you have set aside are kept whatever the dials say.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/identity.slint:672`</sub>
|
<sub>`ui/dr-ui/ui/identity.slint:727`</sub>
|
||||||
|
|
||||||
## Library grid
|
## Library grid
|
||||||
|
|
||||||
@@ -261,133 +436,261 @@ The right match confidence is a property of your library, not of the model. "Wha
|
|||||||
|
|
||||||
- **Touch** — Press and hold a photograph, or press Select in the header
|
- **Touch** — Press and hold a photograph, or press Select in the header
|
||||||
- **Pointer** — Ctrl-click, or press Select in the header
|
- **Pointer** — Ctrl-click, or press Select in the header
|
||||||
|
- **See it** — [in the manual](manual/README.md#selecting-several)
|
||||||
|
|
||||||
Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found.
|
Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:1572`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:1698`</sub>
|
||||||
|
|
||||||
### Add or remove one photograph
|
### Add or remove one photograph
|
||||||
|
|
||||||
- **Touch** — While selecting, tap it
|
- **Touch** — While selecting, tap it
|
||||||
- **Pointer** — Ctrl-click it
|
- **Pointer** — Ctrl-click it
|
||||||
|
- **See it** — [in the manual](manual/README.md#selecting-several)
|
||||||
|
|
||||||
While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back.
|
While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:1581`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:1708`</sub>
|
||||||
|
|
||||||
### Leave selecting
|
### Leave selecting
|
||||||
|
|
||||||
- **Touch** — Press Done in the header
|
- **Touch** — Press Done in the header
|
||||||
- **Pointer** — Press Done in the header
|
- **Pointer** — Press Done in the header
|
||||||
- **Keyboard** — Escape
|
- **Keyboard** — `Escape`, or `Back`; an open sheet closes first
|
||||||
|
- **See it** — [in the manual](manual/README.md#selecting-several)
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:1589`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:1717`</sub>
|
||||||
|
|
||||||
### Pick a photograph up to drag it
|
### Pick a photograph up to drag it
|
||||||
|
|
||||||
- **Touch** — Press and hold it until a ring opens around it, then drag
|
- **Touch** — Press and hold it until a ring opens around it, then drag
|
||||||
- **Pointer** — Drag it
|
- **Pointer** — Drag it
|
||||||
|
- **See it** — [in the manual](manual/README.md#collections)
|
||||||
|
|
||||||
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.
|
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:1748`</sub>
|
||||||
|
|
||||||
### Select a range
|
### Select a range
|
||||||
|
|
||||||
- **Touch** — While selecting, press "Select to…", then tap the last photograph of the run
|
- **Touch** — While selecting, press "Select to…", then tap the last photograph of the run
|
||||||
- **Pointer** — Shift-click the last photograph of the run
|
- **Pointer** — Shift-click the last photograph of the run
|
||||||
|
- **Keyboard** — Shift with any key that walks the grid — `Shift+←`, `Shift+→`, `Shift+↑`, `Shift+↓`, `Shift+Page Up`, `Shift+Page Down`, `Shift+Home`, `Shift+End`
|
||||||
|
- **See it** — [in the manual](manual/README.md#selecting-several)
|
||||||
|
|
||||||
This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out.
|
This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:1684`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:1814`</sub>
|
||||||
|
|
||||||
### Take the blinks out of a burst
|
### Take the blinks out of a burst
|
||||||
|
|
||||||
- **Touch** — Narrow to a person, then tap "Eyes open" beside their name on the filter bar
|
- **Touch** — Narrow to a person, then tap "Eyes open" beside their name on the filter bar
|
||||||
- **Pointer** — Narrow to a person, then click "Eyes open" beside their name on the filter bar
|
- **Pointer** — Narrow to a person, then click "Eyes open" beside their name on the filter bar
|
||||||
|
- **See it** — [in the manual](manual/README.md#bursts)
|
||||||
|
|
||||||
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.
|
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:2531`</sub>
|
||||||
|
|
||||||
### Find photographs with two people in them
|
### Find photographs with two people in them
|
||||||
|
|
||||||
- **Touch** — Open the People chip on the filter bar, tap each name, then switch the chip beside them to "all of them"
|
- **Touch** — Open the People chip on the filter bar, tap each name, then switch the chip beside them to "all of them"
|
||||||
- **Pointer** — Open the People chip on the filter bar, click each name, then switch the chip beside them to "all of them"
|
- **Pointer** — Open the People chip on the filter bar, click each name, then switch the chip beside them to "all of them"
|
||||||
|
- **See it** — [in the manual](manual/README.md#people)
|
||||||
|
|
||||||
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
|
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:2412`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:2561`</sub>
|
||||||
|
|
||||||
|
### Show only photographs with one colour label
|
||||||
|
|
||||||
|
- **Touch** — Tap its chip in the filter bar
|
||||||
|
- **Pointer** — Click its chip in the filter bar
|
||||||
|
- **See it** — [in the manual](manual/README.md#rating-and-flagging)
|
||||||
|
|
||||||
|
Each chip is the label's mark and its name, so the one you want is found by reading it; tap the lit chip again to show every label.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/library.slint:2685`</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
|
||||||
|
- **See it** — [in the manual](manual/README.md#export)
|
||||||
|
|
||||||
|
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:3168`</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`
|
||||||
|
- **See it** — [in the manual](manual/README.md#copying-settings)
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/library.slint:3192`</sub>
|
||||||
|
|
||||||
|
### Keyword the selection
|
||||||
|
|
||||||
|
- **Touch** — Select them, then Keywords in the selection bar
|
||||||
|
- **Pointer** — Select them, then Keywords in the selection bar
|
||||||
|
- **Keyboard** — `Ctrl+K`
|
||||||
|
|
||||||
|
Lightroom's keywording chord. The sheet opens with its field ready for typing, so the keys that judge in the grid are out of the way until it closes.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/library.slint:3221`</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, `0`–`5`, for exactly that many stars, or two digits for everything between them; tap `F` alone to show every rating again
|
||||||
|
- **See it** — [in the manual](manual/README.md#rating-and-flagging)
|
||||||
|
|
||||||
|
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:3255`</sub>
|
||||||
|
|
||||||
|
### Give photographs a colour label
|
||||||
|
|
||||||
|
- **Touch** — Select them, then Label in the selection bar and tap a colour
|
||||||
|
- **Pointer** — Select them, then Label in the selection bar and click a colour
|
||||||
|
- **Keyboard** — `6` red, `7` yellow, `8` green, `9` blue, with the pointer over it or on the selection; the same key again takes the label off
|
||||||
|
- **See it** — [in the manual](manual/README.md#rating-and-flagging)
|
||||||
|
|
||||||
|
Lightroom's keys, so hands that learned them there need not learn them again. Purple has no key there either, and is on the bar. Every mark carries its label's initial, so the label is read without telling the colours apart.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/library.slint:3305`</sub>
|
||||||
|
|
||||||
|
### Pick or reject a photograph
|
||||||
|
|
||||||
|
- **Touch** — Select them, then Flag in the selection bar and Pick, Reject or No flag
|
||||||
|
- **Pointer** — Select them, then Flag in the selection bar and Pick, Reject or No flag
|
||||||
|
- **Keyboard** — `P` picks, `X` rejects and `U` takes the flag off, with the pointer over it or on the selection
|
||||||
|
|
||||||
|
The keys every culling tool uses, so muscle memory built elsewhere works here.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/library.slint:3329`</sub>
|
||||||
|
|
||||||
|
### Move photographs to the trash
|
||||||
|
|
||||||
|
- **Touch** — Tap the bin at the start of a cell's stars
|
||||||
|
- **Pointer** — Hover the cell and click the bin before its stars
|
||||||
|
- **Keyboard** — `Delete` or `Backspace`, on the selection
|
||||||
|
|
||||||
|
The bin acts on one photograph, so a stray click cannot trash a selection; the key acts on the selection because that is what every file manager's Delete does. Both are undone from the trash view.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/library.slint:3356`</sub>
|
||||||
|
|
||||||
|
### Open this list
|
||||||
|
|
||||||
|
- **Touch** — Press Help in the header, and Done to put it away
|
||||||
|
- **Pointer** — Press Help in the header, and Done to put it away
|
||||||
|
- **Keyboard** — `F1`, and `Escape` to put it away
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/library.slint:3381`</sub>
|
||||||
|
|
||||||
|
### Rename the collection the grid is showing
|
||||||
|
|
||||||
|
- **Touch** — Hold it in the sidebar, then "Rename"
|
||||||
|
- **Pointer** — Double-click it in the sidebar
|
||||||
|
- **Keyboard** — `F2`
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/library.slint:3389`</sub>
|
||||||
|
|
||||||
|
### Move through the grid
|
||||||
|
|
||||||
|
- **Touch** — Scroll, and tap a photograph
|
||||||
|
- **Pointer** — Scroll, and click a photograph
|
||||||
|
- **Keyboard** — `←`, `→`, `↑` and `↓` move one photograph; `Page Up` and `Page Down` a screenful; `Home` and `End` to the first and the last
|
||||||
|
|
||||||
|
The cursor selects what it lands on, so walking and judging are one hand's work.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/library.slint:3409`</sub>
|
||||||
|
|
||||||
### Resize the thumbnails
|
### Resize the thumbnails
|
||||||
|
|
||||||
- **Touch** — Pinch the grid with two fingers
|
- **Touch** — Pinch the grid with two fingers
|
||||||
- **Pointer** — Ctrl and the scroll wheel
|
- **Pointer** — Ctrl and the scroll wheel
|
||||||
|
- **Keyboard** — `=` or `Plus` for larger, `-` for smaller
|
||||||
|
- **See it** — [in the manual](manual/README.md#getting-about)
|
||||||
|
|
||||||
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
|
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:3072`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:3536`</sub>
|
||||||
|
|
||||||
### File photographs in a collection
|
### File photographs in a collection
|
||||||
|
|
||||||
- **Touch** — Drag a photograph — or a whole selection — onto a collection in the sidebar. Starting a drag stops the press becoming a hold, so it cannot leave you in selection mode.
|
- **Touch** — Drag a photograph — or a whole selection — onto a collection in the sidebar. Starting a drag stops the press becoming a hold, so it cannot leave you in selection mode.
|
||||||
- **Pointer** — Drag a photograph — or a whole selection — onto a collection in the sidebar
|
- **Pointer** — Drag a photograph — or a whole selection — onto a collection in the sidebar
|
||||||
|
- **See it** — [in the manual](manual/README.md#collections)
|
||||||
|
|
||||||
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
|
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:3269`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:3735`</sub>
|
||||||
|
|
||||||
### Open a photograph
|
### Open a photograph
|
||||||
|
|
||||||
- **Touch** — Tap it — a single tap, any length
|
- **Touch** — Tap it — a single tap, any length
|
||||||
- **Pointer** — Click it
|
- **Pointer** — Click it
|
||||||
|
- **Keyboard** — `Enter`, on the photograph the arrow keys have walked to
|
||||||
|
- **See it** — [in the manual](manual/README.md#developing-a-photograph)
|
||||||
|
|
||||||
A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
|
A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:3537`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:4040`</sub>
|
||||||
|
|
||||||
### Rate a photograph without opening it
|
### Rate a photograph without opening it
|
||||||
|
|
||||||
- **Touch** — Tap a star on the cell
|
- **Touch** — Tap a star on the cell
|
||||||
- **Pointer** — Hover the cell, then click a star
|
- **Pointer** — Hover the cell, then click a star
|
||||||
- **Keyboard** — 0 to 5 on the selection
|
- **Keyboard** — `0`–`5` with the pointer over it, or on the selection
|
||||||
|
- **See it** — [in the manual](manual/README.md#rating-and-flagging)
|
||||||
|
|
||||||
A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
|
A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:3657`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:4163`</sub>
|
||||||
|
|
||||||
### Choose the frame a folded burst shows
|
### Choose the frame a folded burst shows
|
||||||
|
|
||||||
- **Touch** — Open the burst, then tap the ring on the frame you want
|
- **Touch** — Open the burst, then tap the ring on the frame you want
|
||||||
- **Pointer** — Open the burst, then click the ring on the frame you want
|
- **Pointer** — Open the burst, then click the ring on the frame you want
|
||||||
|
- **See it** — [in the manual](manual/README.md#bursts)
|
||||||
|
|
||||||
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.
|
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:4296`</sub>
|
||||||
|
|
||||||
### Drop the selection but keep selecting
|
### Drop the selection but keep selecting
|
||||||
|
|
||||||
- **Touch** — Press Clear in the selection strip
|
- **Touch** — Press Clear in the selection strip
|
||||||
- **Pointer** — Press Clear in the selection strip
|
- **Pointer** — Press Clear in the selection strip
|
||||||
|
- **Keyboard** — `Ctrl+D` or `Ctrl+Shift+A`
|
||||||
|
- **See it** — [in the manual](manual/README.md#selecting-several)
|
||||||
|
|
||||||
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
|
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:4454`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:4973`</sub>
|
||||||
|
|
||||||
### Select everything the grid is showing
|
### Select everything the grid is showing
|
||||||
|
|
||||||
- **Touch** — While selecting, press "Select all"
|
- **Touch** — While selecting, press "Select all"
|
||||||
- **Pointer** — While selecting, press "Select all"
|
- **Pointer** — While selecting, press "Select all"
|
||||||
|
- **Keyboard** — `Ctrl+A`
|
||||||
|
- **See it** — [in the manual](manual/README.md#selecting-several)
|
||||||
|
|
||||||
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
|
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:4471`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:4992`</sub>
|
||||||
|
|
||||||
### Take photographs out of a collection
|
### Take photographs out of a collection
|
||||||
|
|
||||||
- **Touch** — Select them, then "Collections…" in the selection bar
|
- **Touch** — Select them, then "Collections…" in the selection bar
|
||||||
- **Pointer** — Select them, then "Collections…" in the selection bar
|
- **Pointer** — Select them, then "Collections…" in the selection bar
|
||||||
|
- **See it** — [in the manual](manual/README.md#collections)
|
||||||
|
|
||||||
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.
|
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:5173`</sub>
|
||||||
|
|||||||
+63
-5
@@ -33,8 +33,8 @@ wrote first.
|
|||||||
|
|
||||||
Photographs are ordered by capture time, with a month heading where each
|
Photographs are ordered by capture time, with a month heading where each
|
||||||
begins. The strip on the left is the timeline — drag it to jump to a year.
|
begins. The strip on the left is the timeline — drag it to jump to a year.
|
||||||
The bar above the grid filters by rating, flag and where the file is (on this
|
The bar above the grid filters by rating, flag, colour label and where the
|
||||||
device, or only on the server).
|
file is (on this device, or only on the server).
|
||||||
|
|
||||||
### Rating and flagging
|
### Rating and flagging
|
||||||
|
|
||||||
@@ -43,6 +43,16 @@ count what each rating holds, and clicking `3+` shows only those.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
Colour labels work as Lightroom's do: `6` red, `7` yellow, `8` green, `9`
|
||||||
|
blue, on the photograph under the pointer or on the selection, and the same
|
||||||
|
key again takes the label off. `Label` on the selection bar offers all five,
|
||||||
|
purple included, and `None`. Each label is drawn with its initial on it, so
|
||||||
|
it reads without telling the colours apart, and the filter bar has a chip for
|
||||||
|
each. In develop, the top bar names the open photograph's label and sets it,
|
||||||
|
and the same keys work there.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### Getting about
|
### Getting about
|
||||||
|
|
||||||
Drag the timeline to scrub through years; Ctrl and the wheel resize the
|
Drag the timeline to scrub through years; Ctrl and the wheel resize the
|
||||||
@@ -86,6 +96,16 @@ move back to the top level, keep it offline, delete.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### Bursts
|
||||||
|
|
||||||
|
Frames taken a moment apart that look alike fold into one cell, with a badge
|
||||||
|
counting them. Click the badge — tap it, on a tablet — to open the burst in
|
||||||
|
the grid, and again to fold it back. A folded burst shows its earliest frame;
|
||||||
|
to have it show another, open it and click the ring on the frame you want.
|
||||||
|
|
||||||
|
With faces indexed, narrowing the grid to a person puts `Eyes open` beside
|
||||||
|
their name on the filter bar, which leaves out the frames where they blinked.
|
||||||
|
|
||||||
## Developing a photograph
|
## Developing a photograph
|
||||||
|
|
||||||
Click a thumbnail to open it. The column on the right is every adjustment;
|
Click a thumbnail to open it. The column on the right is every adjustment;
|
||||||
@@ -105,9 +125,18 @@ Hold `Before` to see the photograph as it was.
|
|||||||
### Looking closer
|
### Looking closer
|
||||||
|
|
||||||
Double-click for 1:1; drag to move about; double-click again to fit. The
|
Double-click for 1:1; drag to move about; double-click again to fit. The
|
||||||
wheel zooms to any amount in between.
|
wheel zooms to any amount in between. Past 1:1 the file's own pixels are
|
||||||
|
drawn as hard-edged blocks rather than smoothed, so what you see is what
|
||||||
|
the sensor recorded.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### Moving between photographs
|
||||||
|
|
||||||
|
The roll along the foot of the canvas holds the photographs the grid was
|
||||||
|
showing; click one to open it. The right arrow, `D` or space opens the next,
|
||||||
|
and the left arrow or `A` the one before. The edit on screen is saved on the
|
||||||
|
way, so stepping along a shoot loses nothing.
|
||||||
|
|
||||||
### White balance from the photograph
|
### White balance from the photograph
|
||||||
|
|
||||||
@@ -126,6 +155,17 @@ ratio from the chips. `Done composing` returns to the photograph.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
`Vertical` and `Horizontal`, under `Straighten`, correct the lines that
|
||||||
|
converge when the camera is tilted up at a building; the crop refits to
|
||||||
|
what is left.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
A crop that leaves a mask wholly outside the frame says so, and offers to
|
||||||
|
take the crop back or keep it.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### Local adjustments
|
### Local adjustments
|
||||||
|
|
||||||
`Local` in the rail turns the column into a mask stack. `Find subjects` runs a
|
`Local` in the rail turns the column into a mask stack. `Find subjects` runs a
|
||||||
@@ -144,7 +184,11 @@ tinted, as alpha, or as an outline; the eye on its row switches it off.
|
|||||||

|

|
||||||
|
|
||||||
Linear and radial gradients, a tone range and a colour range are the other
|
Linear and radial gradients, a tone range and a colour range are the other
|
||||||
ways to make one; each can be combined with any other.
|
ways to make one; each can be combined with any other. `∩ Intersect` keeps
|
||||||
|
only where the new part and the mask agree: below, a gradient over the
|
||||||
|
lower half, then two strokes that survive only where the gradient is.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### Repair
|
### Repair
|
||||||
|
|
||||||
@@ -170,6 +214,15 @@ apply elsewhere, and imports `.xmp` from other applications.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### Copying settings
|
||||||
|
|
||||||
|
`Copy` in the top bar, or Ctrl+C, takes this photograph's settings; `Paste`,
|
||||||
|
or Ctrl+V, puts them on another, and says what it would paste — how many
|
||||||
|
adjustments, and whether the crop comes too. In the grid, `Paste to N` on the
|
||||||
|
selection bar pastes onto every photograph selected. Which kinds of edit a
|
||||||
|
copy carries is chosen in `Presets…`, or with Ctrl+Shift+C: a look carried
|
||||||
|
across a shoot usually leaves each frame's own crop alone.
|
||||||
|
|
||||||
## Merging a panorama
|
## Merging a panorama
|
||||||
|
|
||||||
Select the frames, then `Merge to panorama` from the selection bar. The
|
Select the frames, then `Merge to panorama` from the selection bar. The
|
||||||
@@ -230,6 +283,11 @@ a private X server and records each scene; `record.sh <library>` re-makes
|
|||||||
every picture here. Run it after a change to the interface and commit what
|
every picture here. Run it after a change to the interface and commit what
|
||||||
changed. The pictures are in LFS.
|
changed. The pictures are in LFS.
|
||||||
|
|
||||||
|
The application carries this page. `cargo run -p traceability -- manual`
|
||||||
|
renders it to `index.html` beside it, which the packages install with the
|
||||||
|
pictures and the app opens from Help, from Settings, and from the "See it"
|
||||||
|
link beside a gesture on the help sheet. CI fails when the two differ.
|
||||||
|
|
||||||
Making it the first time turned up nine faults, each fixed in its own
|
Making it the first time turned up nine faults, each fixed in its own
|
||||||
commit before the pictures were taken: the folder picker could not choose
|
commit before the pictures were taken: the folder picker could not choose
|
||||||
the top level, month headings overprinted each other, a category mask
|
the top level, month headings overprinted each other, a category mask
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- GENERATED FILE — do not edit by hand. -->
|
||||||
|
<!-- Source: docs/manual/README.md. Regenerate: cargo run -p traceability -- manual -->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<title>DarkRoom, shown</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #fbfaf8;
|
||||||
|
--ink: #1d1c1a;
|
||||||
|
--ink-dim: #5c5955;
|
||||||
|
--rule: #dedad4;
|
||||||
|
--accent: #8a4b12;
|
||||||
|
--panel: #f1eee9;
|
||||||
|
--mark: #f6e3c7;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #161514;
|
||||||
|
--ink: #e9e6e1;
|
||||||
|
--ink-dim: #a39e97;
|
||||||
|
--rule: #34312d;
|
||||||
|
--accent: #e8a25c;
|
||||||
|
--panel: #201e1c;
|
||||||
|
--mark: #43321f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font: 17px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 15rem minmax(0, 46rem);
|
||||||
|
gap: 3rem;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1.5rem 4rem;
|
||||||
|
}
|
||||||
|
.toc {
|
||||||
|
position: sticky;
|
||||||
|
top: 1.5rem;
|
||||||
|
align-self: start;
|
||||||
|
max-height: calc(100vh - 3rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.toc-title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.toc ul { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.toc ul ul { padding-left: 0.9rem; }
|
||||||
|
.toc li { margin: 0.2rem 0; }
|
||||||
|
.toc a { color: var(--ink-dim); text-decoration: none; }
|
||||||
|
.toc a:hover { color: var(--accent); }
|
||||||
|
main { min-width: 0; }
|
||||||
|
h1, h2, h3 { line-height: 1.25; scroll-margin-top: 1rem; }
|
||||||
|
h1 { font-size: 2.1rem; margin: 0 0 1rem; }
|
||||||
|
h2 { font-size: 1.5rem; margin: 2.6rem 0 0.8rem; padding-top: 1rem; border-top: 1px solid var(--rule); }
|
||||||
|
h3 { font-size: 1.15rem; margin: 1.8rem 0 0.6rem; }
|
||||||
|
h2:target, h3:target { background: var(--mark); border-radius: 4px; padding-left: 0.3rem; margin-left: -0.3rem; }
|
||||||
|
a { color: var(--accent); }
|
||||||
|
code {
|
||||||
|
font: 0.88em ui-monospace, "Cascadia Mono", "DejaVu Sans Mono", monospace;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.05em 0.3em;
|
||||||
|
}
|
||||||
|
figure { margin: 1.4rem 0; }
|
||||||
|
figure img, main img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: auto 16 / 11;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
figcaption { margin-top: 0.4rem; color: var(--ink-dim); font-size: 0.9rem; }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: 0.95rem; }
|
||||||
|
th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--rule); vertical-align: top; }
|
||||||
|
th { color: var(--ink-dim); font-weight: 600; }
|
||||||
|
@media (max-width: 52rem) {
|
||||||
|
.page { grid-template-columns: minmax(0, 1fr); gap: 1rem; padding: 1rem 16px 3rem; }
|
||||||
|
.toc { position: static; max-height: none; border: 1px solid var(--rule); border-radius: 6px; padding: 0.8rem 1rem; background: var(--panel); }
|
||||||
|
body { font-size: 16px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<nav class="toc" aria-label="Contents">
|
||||||
|
<p class="toc-title">Contents</p>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#opening-a-library">Opening a library</a></li>
|
||||||
|
<li><a href="#the-library">The library</a>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#rating-and-flagging">Rating and flagging</a></li>
|
||||||
|
<li><a href="#getting-about">Getting about</a></li>
|
||||||
|
<li><a href="#selecting-several">Selecting several</a></li>
|
||||||
|
<li><a href="#collections">Collections</a></li>
|
||||||
|
<li><a href="#bursts">Bursts</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><a href="#developing-a-photograph">Developing a photograph</a>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#light">Light</a></li>
|
||||||
|
<li><a href="#looking-closer">Looking closer</a></li>
|
||||||
|
<li><a href="#moving-between-photographs">Moving between photographs</a></li>
|
||||||
|
<li><a href="#white-balance-from-the-photograph">White balance from the photograph</a></li>
|
||||||
|
<li><a href="#composing">Composing</a></li>
|
||||||
|
<li><a href="#local-adjustments">Local adjustments</a></li>
|
||||||
|
<li><a href="#repair">Repair</a></li>
|
||||||
|
<li><a href="#film">Film</a></li>
|
||||||
|
<li><a href="#history-snapshots-presets">History, snapshots, presets</a></li>
|
||||||
|
<li><a href="#copying-settings">Copying settings</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><a href="#merging-a-panorama">Merging a panorama</a></li>
|
||||||
|
<li><a href="#export">Export</a></li>
|
||||||
|
<li><a href="#settings">Settings</a></li>
|
||||||
|
<li><a href="#people">People</a></li>
|
||||||
|
<li><a href="#where-things-are-written-down">Where things are written down</a></li>
|
||||||
|
<li><a href="#how-this-page-is-made">How this page is made</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<h1 id="darkroom-shown">DarkRoom, shown</h1>
|
||||||
|
<p>A tour of what the application does, one picture per thing. Every image on
|
||||||
|
this page was captured from the desktop build driving itself — nothing is a
|
||||||
|
mock-up, and nothing has been retouched outside DarkRoom. Where a feature is
|
||||||
|
better seen moving, it moves.</p>
|
||||||
|
<p>The requirements behind each feature are in <a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/requirements.md">requirements.md</a>;
|
||||||
|
the reasoning is in the design documents linked from each section. This page
|
||||||
|
is only about what you see.</p>
|
||||||
|
<p>The photographs are the author's. None show a person.</p>
|
||||||
|
<h2 id="opening-a-library">Opening a library</h2>
|
||||||
|
<p>DarkRoom opens on a library: a folder on this machine, a folder a sync
|
||||||
|
client keeps, or a Nextcloud account. A folder needs no password and uploads
|
||||||
|
nothing.</p>
|
||||||
|
<figure><img loading="lazy" src="media/launch.png" alt="The launch screen: a server field, a folder field, and which formats to scan for"><figcaption>The launch screen: a server field, a folder field, and which formats to scan for</figcaption></figure>
|
||||||
|
<p>Once a folder is named, it is the library — you are not asked for it again,
|
||||||
|
and <code>Open library</code> opens it whole. <code>Subfolder…</code> narrows the scan to part of
|
||||||
|
it. The formats ticked are what the scan looks for; RAW is on and JPEG off
|
||||||
|
by default, because a RAW editor's sensible default is the file the camera
|
||||||
|
wrote first.</p>
|
||||||
|
<figure><img loading="lazy" src="media/launch-folder.png" alt="A folder chosen: the library, whether to scan a subfolder, and the formats"><figcaption>A folder chosen: the library, whether to scan a subfolder, and the formats</figcaption></figure>
|
||||||
|
<h2 id="the-library">The library</h2>
|
||||||
|
<figure><img loading="lazy" src="media/library.png" alt="The grid: collections on the left, the timeline beside it, the roll of thumbnails, and the filter bar above"><figcaption>The grid: collections on the left, the timeline beside it, the roll of thumbnails, and the filter bar above</figcaption></figure>
|
||||||
|
<p>Photographs are ordered by capture time, with a month heading where each
|
||||||
|
begins. The strip on the left is the timeline — drag it to jump to a year.
|
||||||
|
The bar above the grid filters by rating, flag, colour label and where the
|
||||||
|
file is (on this device, or only on the server).</p>
|
||||||
|
<h3 id="rating-and-flagging">Rating and flagging</h3>
|
||||||
|
<p>Hover a cell and the stars appear; click one. The filter chips above the grid
|
||||||
|
count what each rating holds, and clicking <code>3+</code> shows only those.</p>
|
||||||
|
<figure><img loading="lazy" src="media/library-rating.gif" alt="Rating two photographs, then filtering the grid to three stars and more"><figcaption>Rating two photographs, then filtering the grid to three stars and more</figcaption></figure>
|
||||||
|
<p>Colour labels work as Lightroom's do: <code>6</code> red, <code>7</code> yellow, <code>8</code> green, <code>9</code>
|
||||||
|
blue, on the photograph under the pointer or on the selection, and the same
|
||||||
|
key again takes the label off. <code>Label</code> on the selection bar offers all five,
|
||||||
|
purple included, and <code>None</code>. Each label is drawn with its initial on it, so
|
||||||
|
it reads without telling the colours apart, and the filter bar has a chip for
|
||||||
|
each. In develop, the top bar names the open photograph's label and sets it,
|
||||||
|
and the same keys work there.</p>
|
||||||
|
<figure><img loading="lazy" src="media/library-labels.gif" alt="Labelling four frames with 6, 7, 8 and 9, taking one off with the same key, and filtering the grid to green"><figcaption>Labelling four frames with 6, 7, 8 and 9, taking one off with the same key, and filtering the grid to green</figcaption></figure>
|
||||||
|
<h3 id="getting-about">Getting about</h3>
|
||||||
|
<p>Drag the timeline to scrub through years; Ctrl and the wheel resize the
|
||||||
|
thumbnails.</p>
|
||||||
|
<figure><img loading="lazy" src="media/library-timeline.gif" alt="Scrubbing the timeline"><figcaption>Scrubbing the timeline</figcaption></figure>
|
||||||
|
<figure><img loading="lazy" src="media/library-thumbsize.gif" alt="Resizing the thumbnails with Ctrl and the wheel"><figcaption>Resizing the thumbnails with Ctrl and the wheel</figcaption></figure>
|
||||||
|
<h3 id="selecting-several">Selecting several</h3>
|
||||||
|
<p><code>Select</code> in the header — or Ctrl-click — starts a selection. Shift-click picks
|
||||||
|
a range. The bar at the foot of the grid is everything a selection can be done
|
||||||
|
to: collections, keywords, presets, export, and merging to a panorama.</p>
|
||||||
|
<figure><img loading="lazy" src="media/library-selection.png" alt="Twelve photographs selected, with the selection bar along the foot of the grid"><figcaption>Twelve photographs selected, with the selection bar along the foot of the grid</figcaption></figure>
|
||||||
|
<p><code>Keywords</code> on that bar opens a sheet; type a word and press return, and it
|
||||||
|
is on every photograph selected. The list below the field is every keyword
|
||||||
|
the library has, ticked where the selection carries it.</p>
|
||||||
|
<figure><img loading="lazy" src="media/library-keywords.gif" alt="Keywording twelve frames"><figcaption>Keywording twelve frames</figcaption></figure>
|
||||||
|
<h3 id="collections">Collections</h3>
|
||||||
|
<p><code>+</code> at the head of the sidebar makes one. Drag a photograph — or the whole
|
||||||
|
selection — onto its row to file it there; click the row to see it. A
|
||||||
|
photograph can be in several, and the badge on its cell counts them.</p>
|
||||||
|
<figure><img loading="lazy" src="media/library-collections.gif" alt="Filing photographs in a collection by dragging them onto it"><figcaption>Filing photographs in a collection by dragging them onto it</figcaption></figure>
|
||||||
|
<p>Collections nest. Drag one onto another to put it inside; <code>+</code> with a
|
||||||
|
collection selected — or <code>New collection inside</code> from its menu — makes a
|
||||||
|
child. A parent shows everything its children hold, and its count says so.
|
||||||
|
Right-click a row (hold it, on a tablet) for the menu: rename, nest,
|
||||||
|
move back to the top level, keep it offline, delete.</p>
|
||||||
|
<figure><img loading="lazy" src="media/library-nesting.gif" alt="Making Trips, nesting Alps and New York inside it, and opening the parent"><figcaption>Making Trips, nesting Alps and New York inside it, and opening the parent</figcaption></figure>
|
||||||
|
<figure><img loading="lazy" src="media/library-nesting.png" alt="Trips showing both of its children's photographs"><figcaption>Trips showing both of its children's photographs</figcaption></figure>
|
||||||
|
<figure><img loading="lazy" src="media/library-collection-menu.png" alt="The menu on a collection"><figcaption>The menu on a collection</figcaption></figure>
|
||||||
|
<h3 id="bursts">Bursts</h3>
|
||||||
|
<p>Frames taken a moment apart that look alike fold into one cell, with a badge
|
||||||
|
counting them. Click the badge — tap it, on a tablet — to open the burst in
|
||||||
|
the grid, and again to fold it back. A folded burst shows its earliest frame;
|
||||||
|
to have it show another, open it and click the ring on the frame you want.</p>
|
||||||
|
<p>With faces indexed, narrowing the grid to a person puts <code>Eyes open</code> beside
|
||||||
|
their name on the filter bar, which leaves out the frames where they blinked.</p>
|
||||||
|
<h2 id="developing-a-photograph">Developing a photograph</h2>
|
||||||
|
<p>Click a thumbnail to open it. The column on the right is every adjustment;
|
||||||
|
the strip at its head narrows it to one group.</p>
|
||||||
|
<figure><img loading="lazy" src="media/develop.png" alt="The develop view: the photograph, the histogram, and the adjustment column"><figcaption>The develop view: the photograph, the histogram, and the adjustment column</figcaption></figure>
|
||||||
|
<figure><img loading="lazy" src="media/develop-groups.gif" alt="Switching between the Optics, Light, Colour, Effects and Detail groups"><figcaption>Switching between the Optics, Light, Colour, Effects and Detail groups</figcaption></figure>
|
||||||
|
<h3 id="light">Light</h3>
|
||||||
|
<p>Exposure, contrast, highlights, shadows, blacks, whites and a tone curve.
|
||||||
|
Hold <code>Before</code> to see the photograph as it was.</p>
|
||||||
|
<figure><img loading="lazy" src="media/develop-light.gif" alt="Raising exposure, pulling the highlights, lifting the shadows, then holding Before"><figcaption>Raising exposure, pulling the highlights, lifting the shadows, then holding Before</figcaption></figure>
|
||||||
|
<h3 id="looking-closer">Looking closer</h3>
|
||||||
|
<p>Double-click for 1:1; drag to move about; double-click again to fit. The
|
||||||
|
wheel zooms to any amount in between. Past 1:1 the file's own pixels are
|
||||||
|
drawn as hard-edged blocks rather than smoothed, so what you see is what
|
||||||
|
the sensor recorded.</p>
|
||||||
|
<figure><img loading="lazy" src="media/develop-zoom.gif" alt="Zooming to 1:1 with a double-click, panning, then further in with the wheel"><figcaption>Zooming to 1:1 with a double-click, panning, then further in with the wheel</figcaption></figure>
|
||||||
|
<h3 id="moving-between-photographs">Moving between photographs</h3>
|
||||||
|
<p>The roll along the foot of the canvas holds the photographs the grid was
|
||||||
|
showing; click one to open it. The right arrow, <code>D</code> or space opens the next,
|
||||||
|
and the left arrow or <code>A</code> the one before. The edit on screen is saved on the
|
||||||
|
way, so stepping along a shoot loses nothing.</p>
|
||||||
|
<h3 id="white-balance-from-the-photograph">White balance from the photograph</h3>
|
||||||
|
<p>Press <code>pick</code> in the White Balance group, then click something neutral —
|
||||||
|
a white wall, a grey card, the air conditioner here. The picker sets the
|
||||||
|
sliders from the photograph, not from where they were: below, the frame
|
||||||
|
is dragged cold first and one click puts it right. A blown highlight is
|
||||||
|
refused, since a clipped pixel has no colour left to balance.</p>
|
||||||
|
<figure><img loading="lazy" src="media/develop-wb.gif" alt="Cooling the frame with the slider, then picking a white air conditioner to set the white balance"><figcaption>Cooling the frame with the slider, then picking a white air conditioner to set the white balance</figcaption></figure>
|
||||||
|
<h3 id="composing">Composing</h3>
|
||||||
|
<p>Crop by dragging the frame's corners, straighten with the slider, lock a
|
||||||
|
ratio from the chips. <code>Done composing</code> returns to the photograph.</p>
|
||||||
|
<figure><img loading="lazy" src="media/compose.gif" alt="Cropping, straightening and choosing a ratio"><figcaption>Cropping, straightening and choosing a ratio</figcaption></figure>
|
||||||
|
<p><code>Vertical</code> and <code>Horizontal</code>, under <code>Straighten</code>, correct the lines that
|
||||||
|
converge when the camera is tilted up at a building; the crop refits to
|
||||||
|
what is left.</p>
|
||||||
|
<figure><img loading="lazy" src="media/compose-perspective.gif" alt="Standing towers shot from below upright with the Vertical slider"><figcaption>Standing towers shot from below upright with the Vertical slider</figcaption></figure>
|
||||||
|
<p>A crop that leaves a mask wholly outside the frame says so, and offers to
|
||||||
|
take the crop back or keep it.</p>
|
||||||
|
<figure><img loading="lazy" src="media/crop-orphan.gif" alt="A stroke in a corner, a crop that leaves it outside, and the notice with Undo crop and Keep crop"><figcaption>A stroke in a corner, a crop that leaves it outside, and the notice with Undo crop and Keep crop</figcaption></figure>
|
||||||
|
<h3 id="local-adjustments">Local adjustments</h3>
|
||||||
|
<p><code>Local</code> in the rail turns the column into a mask stack. <code>Find subjects</code> runs a
|
||||||
|
segmentation model over the photograph; what it recognises appears as a list
|
||||||
|
of categories with how much of the frame each covers. Click one and it is a
|
||||||
|
mask — then every slider below edits only that region.</p>
|
||||||
|
<figure><img loading="lazy" src="media/local-categories.png" alt="What the model found in an urban scene: ground, architecture, sky, vegetation"><figcaption>What the model found in an urban scene: ground, architecture, sky, vegetation</figcaption></figure>
|
||||||
|
<figure><img loading="lazy" src="media/local-segment.png" alt="The sky chosen: tinted on the photograph, and the column now scoped to it"><figcaption>The sky chosen: tinted on the photograph, and the column now scoped to it</figcaption></figure>
|
||||||
|
<p>A mask is a stack of parts. Paint into it, subtract a gradient from it, grow
|
||||||
|
or shrink its edge, choose how it falls off. <code>Show masks as</code> draws the mask
|
||||||
|
tinted, as alpha, or as an outline; the eye on its row switches it off.</p>
|
||||||
|
<figure><img loading="lazy" src="media/local-paint.gif" alt="Looking at the mask three ways, painting into it, then growing its edge"><figcaption>Looking at the mask three ways, painting into it, then growing its edge</figcaption></figure>
|
||||||
|
<p>Linear and radial gradients, a tone range and a colour range are the other
|
||||||
|
ways to make one; each can be combined with any other. <code>∩ Intersect</code> keeps
|
||||||
|
only where the new part and the mask agree: below, a gradient over the
|
||||||
|
lower half, then two strokes that survive only where the gradient is.</p>
|
||||||
|
<figure><img loading="lazy" src="media/local-intersect.gif" alt="A linear gradient, then Intersect and two painted strokes"><figcaption>A linear gradient, then Intersect and two painted strokes</figcaption></figure>
|
||||||
|
<h3 id="repair">Repair</h3>
|
||||||
|
<p><code>Repair</code> in the rail: click a mark and it is covered from a source DarkRoom
|
||||||
|
chooses beside it. Drag either circle to move it; the size, feather and
|
||||||
|
opacity are in the panel. Heal blends; clone copies.</p>
|
||||||
|
<figure><img loading="lazy" src="media/repair.gif" alt="Covering marks on a road"><figcaption>Covering marks on a road</figcaption></figure>
|
||||||
|
<h3 id="film">Film</h3>
|
||||||
|
<p>The <code>Film</code> chooser at the head of Adjust applies a spectral simulation of a
|
||||||
|
named stock; below it, the print exposure and push controls a film has and a
|
||||||
|
sensor does not.</p>
|
||||||
|
<figure><img loading="lazy" src="media/film.gif" alt="Choosing Velvia, then holding Before"><figcaption>Choosing Velvia, then holding Before</figcaption></figure>
|
||||||
|
<h3 id="history-snapshots-presets">History, snapshots, presets</h3>
|
||||||
|
<p>Every change is a step; <code>Undo</code> and the History panel walk them. <code>Snapshot</code>
|
||||||
|
keeps the current state under a name. <code>Presets…</code> saves the settings to
|
||||||
|
apply elsewhere, and imports <code>.xmp</code> from other applications.</p>
|
||||||
|
<figure><img loading="lazy" src="media/presets.png" alt="The presets sheet"><figcaption>The presets sheet</figcaption></figure>
|
||||||
|
<h3 id="copying-settings">Copying settings</h3>
|
||||||
|
<p><code>Copy</code> in the top bar, or Ctrl+C, takes this photograph's settings; <code>Paste</code>,
|
||||||
|
or Ctrl+V, puts them on another, and says what it would paste — how many
|
||||||
|
adjustments, and whether the crop comes too. In the grid, <code>Paste to N</code> on the
|
||||||
|
selection bar pastes onto every photograph selected. Which kinds of edit a
|
||||||
|
copy carries is chosen in <code>Presets…</code>, or with Ctrl+Shift+C: a look carried
|
||||||
|
across a shoot usually leaves each frame's own crop alone.</p>
|
||||||
|
<h2 id="merging-a-panorama">Merging a panorama</h2>
|
||||||
|
<p>Select the frames, then <code>Merge to panorama</code> from the selection bar. The
|
||||||
|
frames are read, aligned, and drawn on the suggested projection with each one
|
||||||
|
outlined where it landed — twelve hand-held portrait frames across an alpine
|
||||||
|
valley, here. Change the projection (a 150° sweep on a flat perspective is
|
||||||
|
what the middle of the film shows, and why cylindrical is suggested), ask for
|
||||||
|
the border to be filled rather than cropped, then <code>Merge</code>. The composite is
|
||||||
|
written beside its sources as a DNG and appears in the grid with the merge
|
||||||
|
as the first step in its history.</p>
|
||||||
|
<figure><img loading="lazy" src="media/panorama.gif" alt="Twelve frames aligned, the projections tried, and the border filled"><figcaption>Twelve frames aligned, the projections tried, and the border filled</figcaption></figure>
|
||||||
|
<figure><img loading="lazy" src="media/panorama-aligned.png" alt="The alignment on a cylinder, each frame outlined where it landed"><figcaption>The alignment on a cylinder, each frame outlined where it landed</figcaption></figure>
|
||||||
|
<figure><img loading="lazy" src="media/panorama-filled.png" alt="The same, with the ragged border filled by the model rather than cropped away"><figcaption>The same, with the ragged border filled by the model rather than cropped away</figcaption></figure>
|
||||||
|
<h2 id="export">Export</h2>
|
||||||
|
<p><code>Export</code> in the develop header, or <code>Export N</code> from a selection. Format,
|
||||||
|
size, colour space, sharpening, naming and where the file goes are in
|
||||||
|
Settings, and apply to every export until changed. An export with no folder
|
||||||
|
set is refused, and the header says so.</p>
|
||||||
|
<figure><img loading="lazy" src="media/settings-export.png" alt="Export defaults in Settings"><figcaption>Export defaults in Settings</figcaption></figure>
|
||||||
|
<h2 id="settings">Settings</h2>
|
||||||
|
<figure><img loading="lazy" src="media/settings.png" alt="The settings page: background activity, indexing, storage, display"><figcaption>The settings page: background activity, indexing, storage, display</figcaption></figure>
|
||||||
|
<p>Background activity with progress, thumbnail and face indexing, storage on
|
||||||
|
this device, display and colour, export defaults, what is written to XMP
|
||||||
|
sidecars, and a diagnostics bundle for a bug report.</p>
|
||||||
|
<h2 id="people">People</h2>
|
||||||
|
<p>Face detection and identity run over the library and group faces by person;
|
||||||
|
the <code>Identity</code> page is where suggestions are confirmed, rejected and split,
|
||||||
|
and <code>People</code> on the filter bar narrows the grid to someone. Not pictured
|
||||||
|
here, for the obvious reason — <a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/faces.md">faces.md</a> has the design.</p>
|
||||||
|
<h2 id="where-things-are-written-down">Where things are written down</h2>
|
||||||
|
<table><thead><tr><th>Feature</th><th>Design</th></tr></thead><tbody>
|
||||||
|
<tr><td>Local masks and segmentation</td><td><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/segmentation.md">segmentation.md</a>, <a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/mask-editing.md">mask-editing.md</a></td></tr>
|
||||||
|
<tr><td>Repair</td><td><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/spot-removal.md">spot-removal.md</a></td></tr>
|
||||||
|
<tr><td>Panorama</td><td><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/panorama.md">panorama.md</a></td></tr>
|
||||||
|
<tr><td>Faces and identity</td><td><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/faces.md">faces.md</a></td></tr>
|
||||||
|
<tr><td>Gestures, generated from the code</td><td><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/gestures.md">gestures.md</a></td></tr>
|
||||||
|
<tr><td>Navigation and layout</td><td><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/ui-navigation.md">ui-navigation.md</a></td></tr>
|
||||||
|
<tr><td>Sync and storage</td><td><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/docs/dev/storage.md">storage.md</a></td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
<h2 id="how-this-page-is-made">How this page is made</h2>
|
||||||
|
<p><a href="https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/tools/manual/README.md"><code>tools/manual/</code></a> drives the desktop build on
|
||||||
|
a private X server and records each scene; <code>record.sh <library></code> re-makes
|
||||||
|
every picture here. Run it after a change to the interface and commit what
|
||||||
|
changed. The pictures are in LFS.</p>
|
||||||
|
<p>The application carries this page. <code>cargo run -p traceability -- manual</code>
|
||||||
|
renders it to <code>index.html</code> beside it, which the packages install with the
|
||||||
|
pictures and the app opens from Help, from Settings, and from the "See it"
|
||||||
|
link beside a gesture on the help sheet. CI fails when the two differ.</p>
|
||||||
|
<p>Making it the first time turned up nine faults, each fixed in its own
|
||||||
|
commit before the pictures were taken: the folder picker could not choose
|
||||||
|
the top level, month headings overprinted each other, a category mask
|
||||||
|
widened the column off the window, the mask tint outlived its mode, the
|
||||||
|
first sync uploaded an empty thumbnail shard, a merge that ran out of GPU
|
||||||
|
memory left the page on Stop for ever, the export settings promised to ask
|
||||||
|
for a folder and did not, the keyword sheet sent its keys to the grid, and
|
||||||
|
an empty trash told you to check your library folder.</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16
-1
@@ -4,7 +4,7 @@
|
|||||||
# makes `makepkg -si` in this directory install what you are actually working
|
# makes `makepkg -si` in this directory install what you are actually working
|
||||||
# on. Swap `source` for a tagged tarball when there is something to release.
|
# on. Swap `source` for a tagged tarball when there is something to release.
|
||||||
pkgname=darkroom
|
pkgname=darkroom
|
||||||
pkgver=0.14.0
|
pkgver=0.15.0
|
||||||
# Back to 1 with the version: a new pkgver is a new archive name, so there is
|
# Back to 1 with the version: a new pkgver is a new archive name, so there is
|
||||||
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
|
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
@@ -59,6 +59,21 @@ package() {
|
|||||||
|
|
||||||
install -Dm644 "README.md" "${pkgdir}/usr/share/doc/${pkgname}/README.md"
|
install -Dm644 "README.md" "${pkgdir}/usr/share/doc/${pkgname}/README.md"
|
||||||
|
|
||||||
|
# The manual: the rendered page and its pictures, where the app's Help
|
||||||
|
# opens it (dr_ui::manual, through dr_plat::system_data_dirs). Offline by
|
||||||
|
# design — the help sheet's "See it" links land here, on a machine that
|
||||||
|
# may have no network. The pictures are in LFS like the models, and a
|
||||||
|
# pointer shipped in their place is a manual of broken images, so the
|
||||||
|
# same refusal applies.
|
||||||
|
install -Dm644 "docs/manual/index.html" "${pkgdir}/usr/share/darkroom/manual/index.html"
|
||||||
|
for _f in docs/manual/media/*; do
|
||||||
|
if head -c 40 "${_f}" | grep -q '^version https://git-lfs'; then
|
||||||
|
echo "error: $(basename "${_f}") is an LFS pointer, not a picture — run: git lfs pull" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
install -Dm644 "${_f}" "${pkgdir}/usr/share/darkroom/manual/media/$(basename "${_f}")"
|
||||||
|
done
|
||||||
|
|
||||||
# The face models, into the last directory the app searches. A pair the
|
# The face models, into the last directory the app searches. A pair the
|
||||||
# user placed in their own data directory outranks these, so installing
|
# user placed in their own data directory outranks these, so installing
|
||||||
# them cannot override a deliberate choice of weights.
|
# them cannot override a deliberate choice of weights.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
;
|
;
|
||||||
; makensis -DVERSION=0.12.0 -DSTAGE=/path/to/staging -DOUT=/path/to/setup.exe darkroom.nsi
|
; makensis -DVERSION=0.12.0 -DSTAGE=/path/to/staging -DOUT=/path/to/setup.exe darkroom.nsi
|
||||||
;
|
;
|
||||||
; STAGE holds exactly what §5.2 installs: darkroom.exe, models\ and LICENSE.
|
; STAGE holds exactly what §5.2 installs: darkroom.exe, models\, manual\ and
|
||||||
|
; LICENSE.
|
||||||
; package.sh assembles it and applies the LFS-pointer guard before this runs.
|
; package.sh assembles it and applies the LFS-pointer guard before this runs.
|
||||||
|
|
||||||
; A 64-bit installer, not NSIS's default 32-bit stub. The application is
|
; A 64-bit installer, not NSIS's default 32-bit stub. The application is
|
||||||
@@ -76,6 +77,13 @@ Section "DarkRoom" SecMain
|
|||||||
SetOutPath "$INSTDIR\models"
|
SetOutPath "$INSTDIR\models"
|
||||||
File /r "${STAGE}\models\*"
|
File /r "${STAGE}\models\*"
|
||||||
|
|
||||||
|
; The manual and its pictures, opened by Help and by the help sheet's
|
||||||
|
; "See it" links (dr_ui::manual). Beside the executable for the same reason
|
||||||
|
; the models are: it is the one directory the application can find without
|
||||||
|
; asking the registry.
|
||||||
|
SetOutPath "$INSTDIR\manual"
|
||||||
|
File /r "${STAGE}\manual\*"
|
||||||
|
|
||||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
; Add/Remove Programs. HKCU, to match the per-user install.
|
; Add/Remove Programs. HKCU, to match the per-user install.
|
||||||
@@ -114,6 +122,7 @@ Section "Uninstall"
|
|||||||
Delete "$INSTDIR\LICENSE"
|
Delete "$INSTDIR\LICENSE"
|
||||||
Delete "$INSTDIR\uninstall.exe"
|
Delete "$INSTDIR\uninstall.exe"
|
||||||
RMDir /r "$INSTDIR\models"
|
RMDir /r "$INSTDIR\models"
|
||||||
|
RMDir /r "$INSTDIR\manual"
|
||||||
RMDir "$INSTDIR"
|
RMDir "$INSTDIR"
|
||||||
|
|
||||||
Delete "$SMPROGRAMS\${NAME}\${NAME}.lnk"
|
Delete "$SMPROGRAMS\${NAME}\${NAME}.lnk"
|
||||||
|
|||||||
Vendored
+70
@@ -0,0 +1,70 @@
|
|||||||
|
# Patched upstream crates
|
||||||
|
|
||||||
|
Each directory here is a crate exactly as crates.io publishes it, at the
|
||||||
|
version `Cargo.lock` resolves, with a local patch on top. The root
|
||||||
|
`Cargo.toml` routes the dependency here through `[patch.crates-io]`; the
|
||||||
|
directory is excluded from the workspace, so `cargo test --workspace`,
|
||||||
|
clippy and fmt leave it alone.
|
||||||
|
|
||||||
|
The first commit that adds a directory is the pristine copy (from
|
||||||
|
`~/.cargo/registry/src/*/<crate>-<version>`, minus `.cargo-ok` and the
|
||||||
|
crate's own `Cargo.lock`). Every later commit touching it is ours, so
|
||||||
|
`git log -p -- third_party/<dir>` is the patch and nothing else.
|
||||||
|
|
||||||
|
## Carrying a patch forward
|
||||||
|
|
||||||
|
When Slint or wgpu is bumped, the version here stops matching and cargo
|
||||||
|
warns that the patch is unused — the build then silently goes back to the
|
||||||
|
unpatched crate. So a bump is:
|
||||||
|
|
||||||
|
1. Copy the new version in beside the old one, as its own commit.
|
||||||
|
2. Re-apply the diff from `git log -p` on the old directory.
|
||||||
|
3. Point `[patch.crates-io]` at the new directory and delete the old one.
|
||||||
|
4. Re-check on the device (below) — both patches are behaviour that only a
|
||||||
|
rotated Android display exercises.
|
||||||
|
|
||||||
|
Drop a patch entirely once upstream has the fix; each section says what
|
||||||
|
upstream change would make it unnecessary.
|
||||||
|
|
||||||
|
## wgpu-hal 29.0.4 — Vulkan pre-rotation on Android
|
||||||
|
|
||||||
|
Upstream creates every Vulkan swapchain with `preTransform = IDENTITY`
|
||||||
|
(`src/vulkan/swapchain/native.rs`, gfx-rs/wgpu#3345). On Android, a window
|
||||||
|
whose orientation differs from the panel's is then rotated by the
|
||||||
|
compositor on the GPU (`composition=CLIENT`), and on this tablet in
|
||||||
|
portrait those frames tear.
|
||||||
|
|
||||||
|
The patch adds two methods to `wgpu_hal::vulkan::Surface`:
|
||||||
|
|
||||||
|
- `current_transform(&Adapter)` — the surface's `currentTransform`.
|
||||||
|
- `set_pre_transform(transform)` — the `preTransform` for the next
|
||||||
|
swapchain. Opt-in: nothing calls it but the Skia patch below, and the
|
||||||
|
default is still `IDENTITY`, so desktop and every other caller behave
|
||||||
|
exactly as upstream.
|
||||||
|
|
||||||
|
Setting it is a promise that the caller draws rotated into a swapchain
|
||||||
|
sized in the panel's orientation; wgpu itself rotates nothing.
|
||||||
|
|
||||||
|
Unnecessary once wgpu exposes pre-rotation itself (#3345).
|
||||||
|
|
||||||
|
## i-slint-renderer-skia 1.17.1 — rotate the canvas to match
|
||||||
|
|
||||||
|
`wgpu_29_surface.rs` keeps the promise the wgpu-hal patch lets it make.
|
||||||
|
On Android it reads the surface's transform whenever it configures, sizes
|
||||||
|
the swapchain in the panel's orientation (width and height swapped for a
|
||||||
|
quarter turn), calls `set_pre_transform`, and concatenates the matching
|
||||||
|
rotation onto the Skia canvas before Slint draws — so the whole UI,
|
||||||
|
including an imported `wgpu::Texture`, is drawn pre-rotated. It checks the
|
||||||
|
transform before every frame too, because a half turn (landscape to
|
||||||
|
reverse landscape) changes it without resizing the window. Touch input is
|
||||||
|
untouched: only drawing is rotated.
|
||||||
|
|
||||||
|
`itemrenderer.rs` widens the pixel-alignment check from "pure translation"
|
||||||
|
to "any right-angle rotation or flip without scaling". Without that, a
|
||||||
|
rotated canvas silently loses pixel snapping everywhere.
|
||||||
|
|
||||||
|
Off Android every path is upstream's: the rotation is always `None`.
|
||||||
|
|
||||||
|
Unnecessary once Slint's Skia wgpu surface pre-rotates on its own —
|
||||||
|
worth offering upstream, since the linuxkms backend already renders
|
||||||
|
through the same rotate-and-translate in `render_to_canvas`.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"git": {
|
||||||
|
"sha1": "cf62c975c311e7036d599ed8ed0b7e6a8386a934"
|
||||||
|
},
|
||||||
|
"path_in_vcs": "internal/renderers/skia"
|
||||||
|
}
|
||||||
+339
@@ -0,0 +1,339 @@
|
|||||||
|
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||||
|
#
|
||||||
|
# When uploading crates to the registry Cargo will automatically
|
||||||
|
# "normalize" Cargo.toml files for maximal compatibility
|
||||||
|
# with all versions of Cargo and also rewrite `path` dependencies
|
||||||
|
# to registry (e.g., crates.io) dependencies.
|
||||||
|
#
|
||||||
|
# If you are reading this file be aware that the original Cargo.toml
|
||||||
|
# will likely look very different (and much more reasonable).
|
||||||
|
# See Cargo.toml.orig for the original contents.
|
||||||
|
|
||||||
|
[package]
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.92"
|
||||||
|
name = "i-slint-renderer-skia"
|
||||||
|
version = "1.17.1"
|
||||||
|
authors = ["Slint Developers <info@slint.dev>"]
|
||||||
|
build = "build.rs"
|
||||||
|
autolib = false
|
||||||
|
autobins = false
|
||||||
|
autoexamples = false
|
||||||
|
autotests = false
|
||||||
|
autobenches = false
|
||||||
|
description = "Skia based renderer for Slint"
|
||||||
|
homepage = "https://slint.dev"
|
||||||
|
readme = "README.md"
|
||||||
|
license = "GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0"
|
||||||
|
repository = "https://github.com/slint-ui/slint"
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
rustdoc-args = ["--generate-link-to-definition"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["softbuffer"]
|
||||||
|
kms = ["softbuffer/kms"]
|
||||||
|
opengl = []
|
||||||
|
unstable-wgpu-28 = [
|
||||||
|
"i-slint-core/unstable-wgpu-28",
|
||||||
|
"wgpu-28",
|
||||||
|
]
|
||||||
|
unstable-wgpu-29 = [
|
||||||
|
"i-slint-core/unstable-wgpu-29",
|
||||||
|
"wgpu-29",
|
||||||
|
]
|
||||||
|
vulkan = [
|
||||||
|
"skia-safe/vulkan",
|
||||||
|
"dep:ash",
|
||||||
|
"vulkano",
|
||||||
|
]
|
||||||
|
wayland = [
|
||||||
|
"glutin/wayland",
|
||||||
|
"softbuffer/wayland",
|
||||||
|
"softbuffer/wayland-dlopen",
|
||||||
|
]
|
||||||
|
wgpu-28 = [
|
||||||
|
"i-slint-core/wgpu-28",
|
||||||
|
"dep:wgpu-28",
|
||||||
|
"dep:spin_on",
|
||||||
|
"dep:foreign-types",
|
||||||
|
"dep:ash",
|
||||||
|
"dep:windows-core",
|
||||||
|
]
|
||||||
|
wgpu-29 = [
|
||||||
|
"i-slint-core/wgpu-29",
|
||||||
|
"dep:wgpu-29",
|
||||||
|
"dep:spin_on",
|
||||||
|
"dep:ash",
|
||||||
|
"dep:windows-core",
|
||||||
|
]
|
||||||
|
x11 = [
|
||||||
|
"glutin/x11",
|
||||||
|
"glutin/glx",
|
||||||
|
"softbuffer/x11",
|
||||||
|
"softbuffer/x11-dlopen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "i_slint_renderer_skia"
|
||||||
|
path = "lib.rs"
|
||||||
|
|
||||||
|
[dependencies.ash]
|
||||||
|
version = "^0.38.0"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.cfg-if]
|
||||||
|
version = "1"
|
||||||
|
|
||||||
|
[dependencies.clru]
|
||||||
|
version = "0.6.0"
|
||||||
|
|
||||||
|
[dependencies.const-field-offset]
|
||||||
|
version = "0.2"
|
||||||
|
|
||||||
|
[dependencies.derive_more]
|
||||||
|
version = "2.0.0"
|
||||||
|
features = [
|
||||||
|
"deref",
|
||||||
|
"deref_mut",
|
||||||
|
"into",
|
||||||
|
"from",
|
||||||
|
"add",
|
||||||
|
"add_assign",
|
||||||
|
"mul",
|
||||||
|
"not",
|
||||||
|
"display",
|
||||||
|
]
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[dependencies.glow]
|
||||||
|
version = "0.17"
|
||||||
|
|
||||||
|
[dependencies.i-slint-common]
|
||||||
|
version = "=1.17.1"
|
||||||
|
features = ["default"]
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[dependencies.i-slint-core]
|
||||||
|
version = "=1.17.1"
|
||||||
|
features = [
|
||||||
|
"default",
|
||||||
|
"box-shadow-cache",
|
||||||
|
"shared-parley",
|
||||||
|
]
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[dependencies.i-slint-core-macros]
|
||||||
|
version = "=1.17.1"
|
||||||
|
features = ["default"]
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[dependencies.lyon_path]
|
||||||
|
version = "1.0"
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[dependencies.pin-weak]
|
||||||
|
version = "1"
|
||||||
|
|
||||||
|
[dependencies.raw-window-handle]
|
||||||
|
version = "0.6"
|
||||||
|
features = ["std"]
|
||||||
|
|
||||||
|
[dependencies.scoped-tls-hkt]
|
||||||
|
version = "0.1"
|
||||||
|
|
||||||
|
[dependencies.skia-safe]
|
||||||
|
version = "0.99.0"
|
||||||
|
features = ["gl"]
|
||||||
|
|
||||||
|
[dependencies.spin_on]
|
||||||
|
version = "0.1"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.unicode-segmentation]
|
||||||
|
version = "1.12.0"
|
||||||
|
|
||||||
|
[dependencies.vtable]
|
||||||
|
version = "0.4"
|
||||||
|
|
||||||
|
[dependencies.vulkano]
|
||||||
|
version = "0.35.0"
|
||||||
|
optional = true
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[dependencies.wgpu-28]
|
||||||
|
version = "28"
|
||||||
|
optional = true
|
||||||
|
default-features = false
|
||||||
|
package = "wgpu"
|
||||||
|
|
||||||
|
[dependencies.wgpu-29]
|
||||||
|
version = "29.0.4"
|
||||||
|
optional = true
|
||||||
|
default-features = false
|
||||||
|
package = "wgpu"
|
||||||
|
|
||||||
|
[build-dependencies.cfg_aliases]
|
||||||
|
version = "0.2.0"
|
||||||
|
|
||||||
|
[target.aarch64-apple-ios-sim.dependencies.objc2]
|
||||||
|
version = "0.6.0"
|
||||||
|
features = ["disable-encoding-assertions"]
|
||||||
|
|
||||||
|
[target.'cfg(all(any(target_os = "ios", target_os="macos", target_os="windows", target_os="android", target_os="linux"), not(target_arch = "arm")))'.dependencies.skia-safe]
|
||||||
|
version = "0.99.0"
|
||||||
|
features = ["textlayout"]
|
||||||
|
|
||||||
|
[target.'cfg(any(not(target_vendor = "apple"), target_os = "macos"))'.dependencies.glutin]
|
||||||
|
version = "0.32.0"
|
||||||
|
features = [
|
||||||
|
"egl",
|
||||||
|
"wgl",
|
||||||
|
]
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[target.'cfg(not(any(target_vendor = "apple", target_family = "windows")))'.dependencies.skia-safe]
|
||||||
|
version = "0.99.0"
|
||||||
|
features = [
|
||||||
|
"gl",
|
||||||
|
"vulkan",
|
||||||
|
]
|
||||||
|
|
||||||
|
[target.'cfg(not(any(target_vendor = "apple", target_family = "windows")))'.dependencies.wgpu-28]
|
||||||
|
version = "28"
|
||||||
|
features = ["vulkan"]
|
||||||
|
optional = true
|
||||||
|
default-features = false
|
||||||
|
package = "wgpu"
|
||||||
|
|
||||||
|
[target.'cfg(not(any(target_vendor = "apple", target_family = "windows")))'.dependencies.wgpu-29]
|
||||||
|
version = "29.0.4"
|
||||||
|
features = ["vulkan"]
|
||||||
|
optional = true
|
||||||
|
default-features = false
|
||||||
|
package = "wgpu"
|
||||||
|
|
||||||
|
[target.'cfg(not(target_os = "android"))'.dependencies.bytemuck]
|
||||||
|
version = "1.13.1"
|
||||||
|
|
||||||
|
[target.'cfg(not(target_os = "android"))'.dependencies.softbuffer]
|
||||||
|
version = "0.4.4"
|
||||||
|
optional = true
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[target.'cfg(target_family = "windows")'.dependencies.skia-safe]
|
||||||
|
version = "0.99.0"
|
||||||
|
features = ["d3d"]
|
||||||
|
|
||||||
|
[target.'cfg(target_family = "windows")'.dependencies.wgpu-28]
|
||||||
|
version = "28"
|
||||||
|
features = ["dx12"]
|
||||||
|
optional = true
|
||||||
|
default-features = false
|
||||||
|
package = "wgpu"
|
||||||
|
|
||||||
|
[target.'cfg(target_family = "windows")'.dependencies.wgpu-29]
|
||||||
|
version = "29.0.4"
|
||||||
|
features = ["dx12"]
|
||||||
|
optional = true
|
||||||
|
default-features = false
|
||||||
|
package = "wgpu"
|
||||||
|
|
||||||
|
[target.'cfg(target_family = "windows")'.dependencies.windows]
|
||||||
|
version = "0.62"
|
||||||
|
features = [
|
||||||
|
"Win32",
|
||||||
|
"Win32_System_Com",
|
||||||
|
"Win32_Graphics",
|
||||||
|
"Win32_Graphics_Dxgi",
|
||||||
|
"Win32_Graphics_Direct3D12",
|
||||||
|
"Win32_Graphics_Direct3D",
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Graphics_Dxgi_Common",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
"Win32_Security",
|
||||||
|
]
|
||||||
|
|
||||||
|
[target.'cfg(target_family = "windows")'.dependencies.windows-core]
|
||||||
|
version = "0.62.0"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[target.'cfg(target_vendor = "apple")'.dependencies.foreign-types]
|
||||||
|
version = "0.5.0"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[target.'cfg(target_vendor = "apple")'.dependencies.objc2]
|
||||||
|
version = "0.6.0"
|
||||||
|
|
||||||
|
[target.'cfg(target_vendor = "apple")'.dependencies.objc2-app-kit]
|
||||||
|
version = "0.3.2"
|
||||||
|
features = [
|
||||||
|
"std",
|
||||||
|
"NSResponder",
|
||||||
|
"NSView",
|
||||||
|
]
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[target.'cfg(target_vendor = "apple")'.dependencies.objc2-core-foundation]
|
||||||
|
version = "0.3.2"
|
||||||
|
features = ["CFCGTypes"]
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[target.'cfg(target_vendor = "apple")'.dependencies.objc2-foundation]
|
||||||
|
version = "0.3.2"
|
||||||
|
features = [
|
||||||
|
"std",
|
||||||
|
"NSGeometry",
|
||||||
|
]
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[target.'cfg(target_vendor = "apple")'.dependencies.objc2-metal]
|
||||||
|
version = "0.3.2"
|
||||||
|
features = [
|
||||||
|
"std",
|
||||||
|
"MTLCommandQueue",
|
||||||
|
"MTLCommandBuffer",
|
||||||
|
"MTLDevice",
|
||||||
|
"MTLResource",
|
||||||
|
"MTLTexture",
|
||||||
|
"MTLTypes",
|
||||||
|
]
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[target.'cfg(target_vendor = "apple")'.dependencies.objc2-quartz-core]
|
||||||
|
version = "0.3.2"
|
||||||
|
features = [
|
||||||
|
"std",
|
||||||
|
"objc2-metal",
|
||||||
|
"CALayer",
|
||||||
|
"CAMetalLayer",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[target.'cfg(target_vendor = "apple")'.dependencies.raw-window-metal]
|
||||||
|
version = "1.0"
|
||||||
|
|
||||||
|
[target.'cfg(target_vendor = "apple")'.dependencies.read-fonts]
|
||||||
|
version = "0.39"
|
||||||
|
|
||||||
|
[target.'cfg(target_vendor = "apple")'.dependencies.skia-safe]
|
||||||
|
version = "0.99.0"
|
||||||
|
features = ["metal"]
|
||||||
|
|
||||||
|
[target.'cfg(target_vendor = "apple")'.dependencies.wgpu-28]
|
||||||
|
version = "28"
|
||||||
|
features = ["metal"]
|
||||||
|
optional = true
|
||||||
|
default-features = false
|
||||||
|
package = "wgpu"
|
||||||
|
|
||||||
|
[target.'cfg(target_vendor = "apple")'.dependencies.wgpu-29]
|
||||||
|
version = "29.0.4"
|
||||||
|
features = ["metal"]
|
||||||
|
optional = true
|
||||||
|
default-features = false
|
||||||
|
package = "wgpu"
|
||||||
|
|
||||||
|
[target.'cfg(target_vendor = "apple")'.dependencies.write-fonts]
|
||||||
|
version = "0.48"
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
# Copyright © SixtyFPS GmbH <info@slint.dev>
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
|
||||||
|
|
||||||
|
# cSpell: ignore CFCG
|
||||||
|
[package]
|
||||||
|
name = "i-slint-renderer-skia"
|
||||||
|
description = "Skia based renderer for Slint"
|
||||||
|
authors.workspace = true
|
||||||
|
edition = "2024"
|
||||||
|
homepage.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "lib.rs"
|
||||||
|
|
||||||
|
# Note, these features need to be kept in sync (along with their defaults) in
|
||||||
|
# the C++ crate's CMakeLists.txt
|
||||||
|
[features]
|
||||||
|
wayland = ["glutin/wayland", "softbuffer/wayland", "softbuffer/wayland-dlopen"]
|
||||||
|
x11 = ["glutin/x11", "glutin/glx", "softbuffer/x11", "softbuffer/x11-dlopen"]
|
||||||
|
opengl = []
|
||||||
|
vulkan = ["skia-safe/vulkan", "dep:ash", "vulkano"]
|
||||||
|
kms = ["softbuffer/kms"]
|
||||||
|
# wgpu-{28,29} enables the wgpu surface module for internal use (e.g. linuxkms DRM rendering).
|
||||||
|
# unstable-wgpu-{28,29} additionally exposes public API integration (GraphicsAPI, texture import).
|
||||||
|
wgpu-28 = ["i-slint-core/wgpu-28", "dep:wgpu-28", "dep:spin_on", "dep:foreign-types", "dep:ash", "dep:windows-core"]
|
||||||
|
unstable-wgpu-28 = ["i-slint-core/unstable-wgpu-28", "wgpu-28"]
|
||||||
|
wgpu-29 = ["i-slint-core/wgpu-29", "dep:wgpu-29", "dep:spin_on", "dep:ash", "dep:windows-core"]
|
||||||
|
unstable-wgpu-29 = ["i-slint-core/unstable-wgpu-29", "wgpu-29"]
|
||||||
|
default = ["softbuffer"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
i-slint-core = { workspace = true, features = ["default", "box-shadow-cache", "shared-parley"] }
|
||||||
|
i-slint-core-macros = { workspace = true, features = ["default"] }
|
||||||
|
i-slint-common = { workspace = true, features = ["default"] }
|
||||||
|
|
||||||
|
const-field-offset = { version = "0.2", path = "../../../helper_crates/const-field-offset" }
|
||||||
|
vtable = { workspace = true }
|
||||||
|
|
||||||
|
cfg-if = "1"
|
||||||
|
derive_more = { workspace = true }
|
||||||
|
lyon_path = { workspace = true }
|
||||||
|
pin-weak = "1"
|
||||||
|
scoped-tls-hkt = "0.1"
|
||||||
|
raw-window-handle = { version = "0.6", features = ["std"] }
|
||||||
|
clru = { workspace = true }
|
||||||
|
|
||||||
|
skia-safe = { version = "0.99.0", features = ["gl"] }
|
||||||
|
glow = { workspace = true }
|
||||||
|
unicode-segmentation = { workspace = true }
|
||||||
|
|
||||||
|
ash = { version = "^0.38.0", optional = true }
|
||||||
|
vulkano = { version = "0.35.0", optional = true, default-features = false }
|
||||||
|
|
||||||
|
wgpu-28 = { workspace = true, optional = true }
|
||||||
|
wgpu-29 = { workspace = true, optional = true }
|
||||||
|
spin_on = { version = "0.1", optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(any(not(target_vendor = "apple"), target_os = "macos"))'.dependencies]
|
||||||
|
glutin = { workspace = true, default-features = false, features = ["egl", "wgl"] }
|
||||||
|
|
||||||
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
|
# software renderer fallback
|
||||||
|
softbuffer = { workspace = true, default-features = false, optional = true }
|
||||||
|
bytemuck = { workspace = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_family = "windows")'.dependencies]
|
||||||
|
windows = { workspace = true, features = ["Win32", "Win32_System_Com", "Win32_Graphics", "Win32_Graphics_Dxgi", "Win32_Graphics_Direct3D12", "Win32_Graphics_Direct3D", "Win32_Foundation", "Win32_Graphics_Dxgi_Common", "Win32_System_Threading", "Win32_Security"] }
|
||||||
|
windows-core = { workspace = true, optional = true }
|
||||||
|
skia-safe = { version = "0.99.0", features = ["d3d"] }
|
||||||
|
wgpu-28 = { workspace = true, optional = true, features = ["dx12"] }
|
||||||
|
wgpu-29 = { workspace = true, optional = true, features = ["dx12"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_vendor = "apple")'.dependencies]
|
||||||
|
objc2 = { version = "0.6.0" }
|
||||||
|
objc2-metal = { version = "0.3.2", default-features = false, features = ["std", "MTLCommandQueue", "MTLCommandBuffer", "MTLDevice", "MTLResource", "MTLTexture", "MTLTypes"] }
|
||||||
|
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSGeometry"] }
|
||||||
|
objc2-quartz-core = { version = "0.3.2", default-features = false, features = ["std", "objc2-metal", "CALayer", "CAMetalLayer", "objc2-core-foundation"] }
|
||||||
|
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSResponder", "NSView"] }
|
||||||
|
objc2-core-foundation = { version = "0.3.2", default-features = false, features = ["CFCGTypes"] }
|
||||||
|
skia-safe = { version = "0.99.0", features = ["metal"] }
|
||||||
|
raw-window-metal = "1.0"
|
||||||
|
|
||||||
|
foreign-types = { version = "0.5.0", optional = true }
|
||||||
|
wgpu-28 = { workspace = true, optional = true, features = ["metal"] }
|
||||||
|
wgpu-29 = { workspace = true, optional = true, features = ["metal"] }
|
||||||
|
|
||||||
|
read-fonts = { workspace = true }
|
||||||
|
# Pinned to 0.48 to align with read-fonts from parley and avoid duplicated dependencies
|
||||||
|
write-fonts = { version = "0.48" }
|
||||||
|
|
||||||
|
[target.'cfg(not(any(target_vendor = "apple", target_family = "windows")))'.dependencies]
|
||||||
|
skia-safe = { version = "0.99.0", features = ["gl", "vulkan"] }
|
||||||
|
wgpu-28 = { workspace = true, optional = true, features = ["vulkan"] }
|
||||||
|
wgpu-29 = { workspace = true, optional = true, features = ["vulkan"] }
|
||||||
|
|
||||||
|
[target.'cfg(all(any(target_os = "ios", target_os="macos", target_os="windows", target_os="android", target_os="linux"), not(target_arch = "arm")))'.dependencies]
|
||||||
|
skia-safe = { version = "0.99.0", features = ["textlayout"] }
|
||||||
|
|
||||||
|
[target.aarch64-apple-ios-sim.dependencies]
|
||||||
|
# Disabling encoding assertions until https://github.com/madsmtm/objc2/issues/795 is fixed.
|
||||||
|
objc2 = { version = "0.6.0", features = ["disable-encoding-assertions"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
cfg_aliases = { workspace = true }
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
rustdoc-args = ["--generate-link-to-definition"]
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright © 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
“This License” refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||||
|
|
||||||
|
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||||
|
|
||||||
|
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||||
|
|
||||||
|
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||||
|
|
||||||
|
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||||
|
|
||||||
|
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||||
|
|
||||||
|
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||||
|
|
||||||
|
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||||
|
|
||||||
|
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||||
|
|
||||||
|
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# Slint Royalty-free Desktop, Mobile, and Web Applications License
|
||||||
|
|
||||||
|
Version 2.0
|
||||||
|
|
||||||
|
## Preamble
|
||||||
|
|
||||||
|
Slint is a toolkit that can be used to build user interfaces for applications. Slint (hereafter referred to as **Software**) is made available under different licenses by SixtyFPS GmbH incorporated at Oranienburger Str. 44, 16540 Hohen Neuendorf, Germany (**SixtyFPS**). The **Slint Royalty-free Desktop, Mobile, and Web Applications License** is suitable for those who develop desktop, mobile, or web applications and do not want to use open source components under copyleft licenses.
|
||||||
|
|
||||||
|
## 1. Grant of Rights
|
||||||
|
|
||||||
|
SixtyFPS hereby grants You a world-wide, royalty-free, non-exclusive license to use, reproduce, make available, modify, display, perform, distribute the Software as part of a Desktop, Mobile, or Web Application.
|
||||||
|
|
||||||
|
A **Desktop Application** is a computer program that is designed to run on a general-purpose computer (PC or notebook), typically installed and executed locally on the computer's operating system.
|
||||||
|
|
||||||
|
A **Mobile Application** is a computer program that is designed to run on a general-purpose mobile computer (mobile phone or tablet), typically installed and executed locally on the computer's operating system.
|
||||||
|
|
||||||
|
A **Web Application** is a computer program that is designed to run in the sandbox environment provided by a web browser.
|
||||||
|
|
||||||
|
Desktop Application, Mobile Application, and Web Application are hereafter referred to as **Application**.
|
||||||
|
|
||||||
|
## 2. License Conditions - Attribution
|
||||||
|
|
||||||
|
You may distribute the Software as part of an Application, modified or unmodified, provided that You do either of the following:
|
||||||
|
|
||||||
|
(a) Display the [`AboutSlint`](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/misc/aboutslint/) widget in an "About" screen or dialog that is accessible from the top level menu of the Application. In the absence of such a screen or dialog, display the widget in the "Splash Screen" of the Application.
|
||||||
|
|
||||||
|
(b) Display the [Slint attribution badge](https://github.com/slint-ui/slint/tree/master/logo/MadeWithSlint-logo-whitebg.png) on a public webpage, preferably where the binaries of your Application can be downloaded from, in such a way that it can be easily found by any visitor to that page.
|
||||||
|
|
||||||
|
## 3. Limitations
|
||||||
|
|
||||||
|
The License does not permit to distribute or make the Software publicly available alone and without integration into an Application. For this purpose you may use the Software under the GNU General Public License, version 3.
|
||||||
|
|
||||||
|
The License does not permit the use of the Software within Embedded Systems. An **Embedded System** is a computer system designed to perform a specific task within a larger mechanical or electrical system.
|
||||||
|
|
||||||
|
The License does not permit the distribution of Application that exposes the APIs, in part or in total, of the Software.
|
||||||
|
|
||||||
|
You may not remove or alter any license notices (including copyright notices, disclaimers of warranty, or limitations of liability) contained within the source code form of the Software.
|
||||||
|
|
||||||
|
## 4. Warranty and Liability
|
||||||
|
|
||||||
|
SixtyFPS is only liable for conflicting rights of third parties if SixtyFPS was aware of these rights without informing you. Unless required by applicable law or agreed to in writing, SixtyFPS provides the Software on an "as is" basis, without warranties or conditions of any kind, either express or implied, including, without limitation, any warranties or conditions of merchantability, or fitness for a particular purpose.
|
||||||
|
|
||||||
|
Unless required by law, SixtyFPS won't be liable for any direct, indirect, incidental, or consequential damages arising in any way out of the use of the Software.
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
# Slint Software License
|
||||||
|
|
||||||
|
Version 3.0.5
|
||||||
|
|
||||||
|
## Preamble
|
||||||
|
|
||||||
|
Slint is a toolkit that can be used to build user interfaces for applications. Slint (hereafter referred to as **Software**) is made available under different licenses by SixtyFPS GmbH incorporated at Oranienburger Str. 44, 16540 Hohen Neuendorf, Germany (**SixtyFPS**). The **Slint Software License** is suitable for those who do not want to use open source components under copyleft licenses.
|
||||||
|
|
||||||
|
## 1. Grant of Rights
|
||||||
|
|
||||||
|
SixtyFPS hereby grants You a world-wide, non-exclusive license to use, reproduce, make available, modify, display, perform, distribute the Software as part of a Desktop, Mobile, or Web Application or as part of an Embedded System (each of which is defined below).
|
||||||
|
|
||||||
|
A **Desktop Application** is a computer program that is designed to run on a general-purpose computer (PC or notebook), typically installed and executed locally on the computer's operating system.
|
||||||
|
|
||||||
|
A **Mobile Application** is a computer program that is designed to run on a general-purpose mobile computer (mobile phone or tablet), typically installed and executed locally on the computer's operating system.
|
||||||
|
|
||||||
|
A **Web Application** is a computer program that is designed to run in the sandbox environment provided by a web browser.
|
||||||
|
|
||||||
|
An **Embedded System** is a computer system designed to perform a specific task within a larger mechanical or electrical system.
|
||||||
|
|
||||||
|
Desktop Application, Mobile Application, and Web Application are hereafter referred to as **Application**.
|
||||||
|
|
||||||
|
## 2. License Conditions
|
||||||
|
|
||||||
|
The grant of rights in section 1 are conditional, provided that You do all of the following:
|
||||||
|
|
||||||
|
(a) You have purchased an appropriate **Paid License Plan** ([see Annex 1](#annex-1-paid-license-plans)) and the required amount of seats to cover all individual users of the Software associated with the designing, developing, or testing your Application or Embedded System. For clarity, each individual user is counted as one seat.
|
||||||
|
|
||||||
|
(b) In the case that You are distributing the Software as part of an Embedded System, You have purchased an appropriate quantity of **Royalties**, one Royalty for each Embedded System. Royalties become due and payable upon manufacture of the Embedded System, regardless of whether such is subsequently sold, shipped, returned, replaced under warranty, or recalled. Payment of royalties is non-refundable under any circumstances. Royalties are not necessary for non-commercial projects, personal projects, and open source projects.
|
||||||
|
|
||||||
|
## 3. Limitations
|
||||||
|
|
||||||
|
The License does not permit to distribute or make the Software publicly available alone and without integration into an Application or into an Embedded System. For this purpose you may use the Software under the GNU General Public License, version 3.
|
||||||
|
|
||||||
|
The License is limited to only the versions of Software that were made available to you under the Paid License Plan. For all other versions, you may use the Software under either the GNU General Public License, version 3 or the Slint Royalty-free Desktop, Mobile, and Web Applications License.
|
||||||
|
|
||||||
|
The License does not permit the distribution of Application that exposes the APIs, in part or in total, of the Software.
|
||||||
|
|
||||||
|
You may not remove or alter any license notices (including copyright notices, disclaimers of warranty, or limitations of liability) contained within the source code form of the Software.
|
||||||
|
|
||||||
|
## 4. Audit Rights
|
||||||
|
|
||||||
|
SixtyFPS or an independent certified auditor on SixtyFPS's behalf, may, upon its reasonable request, with 30 (thirty) days written notice, and at its sole expense, examine your books and records solely with respect to your use of the Software. Any such audit shall be conducted during regular business hours at your facilities and shall not unreasonably interfere with your business activities. The auditor shall not remove, copy, or redistribute any electronic material during an audit. If an audit reveals that you are using the Software in a way that is in material violation of the terms of this License, then you shall pay SixtyFPS reasonable costs of conducting the audit. The auditor shall only be allowed to report violations of the terms of this License, with a copy to you. You shall be provided the right to provide comments to the report before it is finalized.
|
||||||
|
|
||||||
|
## 5. Termination
|
||||||
|
|
||||||
|
(a) SixtyFPS may terminate this License if You materially breach any obligation hereunder, provided You have been provided notice of such breach and an opportunity to cure such breach during a period of not less than sixty (60) days following such notice.
|
||||||
|
|
||||||
|
(b) You may terminate this License with or without cause upon no less than thirty (30) days advance written notice to SixtyFPS.
|
||||||
|
|
||||||
|
(c) Upon termination of this License, You will immediately cease using, reproducing, making available, modifying, displaying, performing, distributing the Software and pay immediately any unpaid Fees and contractual penalties.
|
||||||
|
|
||||||
|
(d) Sections 3 through 8 of this License will survive any termination of the License to the extent necessary to implement their objectives.
|
||||||
|
|
||||||
|
## 6. Assignment
|
||||||
|
|
||||||
|
You may assign this License, in whole or in part (whether by operation of law or otherwise), with prior consent from SixtyFPS, which shall not be unreasonably withheld or delayed. SixtyFPS may assign any of its rights or delegate any of its obligations hereunder with prior notice to You, provided that the successor maintains at least the same level of security, confidentiality, and data protection measures as in place at the time of assignment or delegation. Any attempt to assign this License other than in accordance with this Section 6 shall be null and void.
|
||||||
|
|
||||||
|
## 7. Severability
|
||||||
|
|
||||||
|
In the event that any provision of this License will, for any reason, be determined by any court of competent jurisdiction to be invalid, illegal or unenforceable in any respect, such invalidity, illegality or unenforceability will be interpreted as closely as possible so as not affect any other provision of this License, and such provision will further be modified by said court to permit its enforcement to the maximum extent permitted by law.
|
||||||
|
|
||||||
|
## 8. Governing Law
|
||||||
|
|
||||||
|
This Agreement shall be construed, interpreted, and governed by the laws of the Federal Republic of Germany.
|
||||||
|
|
||||||
|
## Annex 1: Paid License Plans
|
||||||
|
|
||||||
|
### Enterprise Plan
|
||||||
|
|
||||||
|
The following is included as part of the plan
|
||||||
|
|
||||||
|
(a) No restriction on the number of applications that are developed with Slint.
|
||||||
|
|
||||||
|
(b) Live Preview.
|
||||||
|
|
||||||
|
(c) Standard Support that includes addressing technical queries, troubleshooting, and rectifying bugs or errors (faults) present in the latest official stable release.
|
||||||
|
|
||||||
|
(d) Perpetual Fallback License that allows continued use of a specific Slint version, including all bugfix updates (i.e., all Z releases within the X.Y.Z version), without an active subscription. This license applies only to those versions of Slint for which at least 12 consecutive months of subscription have been paid.
|
||||||
|
|
||||||
|
(e) GUI Test Framework.
|
||||||
|
|
||||||
|
### Small Enterprise Plan
|
||||||
|
|
||||||
|
This plan is limited to individual companies with a staff headcount of upto 50 and either a turnover or balance sheet total of 10 million EUR or less. If You are a Small Enterprise, You are required to submit the self-assessment report generated from the EU SME Self-Assessment Tool (https://ec.europa.eu/info/funding-tenders/opportunities/portal/sme/public/organisation-name).
|
||||||
|
|
||||||
|
The following is included as part of the plan
|
||||||
|
|
||||||
|
(a) No restriction on the number of applications that are developed with Slint.
|
||||||
|
|
||||||
|
(b) Live Preview.
|
||||||
|
|
||||||
|
(c) Standard Support that includes addressing technical queries, troubleshooting, and rectifying bugs or errors (faults) present in the latest official stable release.
|
||||||
|
|
||||||
|
The following can be purchased as an Add-On
|
||||||
|
|
||||||
|
(a) Perpetual Fallback License that allows continued use of a specific Slint version, including all bugfix updates (i.e., all Z releases within the X.Y.Z version), without an active subscription. This license applies only to those versions of Slint for which at least 12 consecutive months of subscription have been paid.
|
||||||
|
|
||||||
|
(b) GUI Test Framework.
|
||||||
|
|
||||||
|
### Startup & Individual Plan
|
||||||
|
|
||||||
|
This plan is limited to individuals and individual companies with a staff headcount of less than 10 and either a turnover or balance sheet total of 2 million EUR or less. If You are a Startup, you are required to submit the self-assessment report generated from the EU SME Self-Assessment Tool (https://ec.europa.eu/info/funding-tenders/opportunities/portal/sme/public/organisation-name).
|
||||||
|
|
||||||
|
The following is included as part of the plan
|
||||||
|
|
||||||
|
(a) No restriction on the number of applications that are developed with Slint.
|
||||||
|
|
||||||
|
(b) Live Preview.
|
||||||
|
|
||||||
|
The following can be purchased as an Add-On
|
||||||
|
|
||||||
|
(a) Standard Support that includes addressing technical queries, troubleshooting, and rectifying bugs or errors (faults) present in the latest official stable release.
|
||||||
|
|
||||||
|
(b) Perpetual Fallback License that allows continued use of a specific Slint version, including all bugfix updates (i.e., all Z releases within the X.Y.Z version), without an active subscription. This license applies only to those versions of Slint for which at least 12 consecutive months of subscription have been paid.
|
||||||
|
|
||||||
|
(c) GUI Test Framework.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
**NOTE**: This library is an **internal** crate of the [Slint project](https://slint.dev).
|
||||||
|
This crate should **not be used directly** by applications using Slint.
|
||||||
|
You should use the `slint` crate instead.
|
||||||
|
|
||||||
|
**WARNING**: This crate does not follow the semver convention for versioning and can
|
||||||
|
only be used with `version = "=x.y.z"` in Cargo.toml.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Copyright © SixtyFPS GmbH <info@slint.dev>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
|
||||||
|
|
||||||
|
use cfg_aliases::cfg_aliases;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Setup cfg aliases
|
||||||
|
cfg_aliases! {
|
||||||
|
skia_backend_opengl: { any(feature = "opengl", not(any(target_vendor = "apple", target_family = "windows", target_arch = "wasm32"))) },
|
||||||
|
skia_backend_metal: { all(target_vendor = "apple", not(feature = "opengl")) },
|
||||||
|
skia_backend_vulkan: { feature = "vulkan" },
|
||||||
|
skia_backend_software: { not(target_os = "android") },
|
||||||
|
skia_backend_softbuffer: { all(skia_backend_software, feature = "softbuffer") },
|
||||||
|
skia_windowed: { any(skia_backend_vulkan, skia_backend_opengl, skia_backend_metal, skia_backend_softbuffer) },
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("cargo:rustc-check-cfg=cfg(slint_nightly_test)");
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Copyright © SixtyFPS GmbH <info@slint.dev>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
|
||||||
|
|
||||||
|
use crate::PhysicalSize;
|
||||||
|
use i_slint_core::graphics::{
|
||||||
|
Image, ImageCacheKey, ImageInner, IntRect, IntSize, OpaqueImage, OpaqueImageVTable,
|
||||||
|
SharedImageBuffer, cache as core_cache,
|
||||||
|
};
|
||||||
|
use i_slint_core::items::ImageFit;
|
||||||
|
use i_slint_core::lengths::{LogicalSize, ScaleFactor};
|
||||||
|
|
||||||
|
struct SkiaCachedImage {
|
||||||
|
image: skia_safe::Image,
|
||||||
|
cache_key: ImageCacheKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
i_slint_core::OpaqueImageVTable_static! {
|
||||||
|
static SKIA_CACHED_IMAGE_VT for SkiaCachedImage
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpaqueImage for SkiaCachedImage {
|
||||||
|
fn size(&self) -> IntSize {
|
||||||
|
IntSize::new(self.image.width() as u32, self.image.height() as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_key(&self) -> ImageCacheKey {
|
||||||
|
self.cache_key.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn as_skia_image(
|
||||||
|
image: Image,
|
||||||
|
target_size_fn: &dyn Fn() -> LogicalSize,
|
||||||
|
image_fit: ImageFit,
|
||||||
|
scale_factor: ScaleFactor,
|
||||||
|
canvas: &skia_safe::Canvas,
|
||||||
|
surface: Option<&dyn crate::Surface>,
|
||||||
|
) -> Option<skia_safe::Image> {
|
||||||
|
let image_inner: &ImageInner = (&image).into();
|
||||||
|
match image_inner {
|
||||||
|
ImageInner::None => None,
|
||||||
|
ImageInner::EmbeddedImage { buffer, cache_key } => {
|
||||||
|
let result = image_buffer_to_skia_image(buffer);
|
||||||
|
if let Some(img) = result.as_ref() {
|
||||||
|
core_cache::replace_cached_image(
|
||||||
|
cache_key.clone(),
|
||||||
|
ImageInner::BackendStorage(vtable::VRc::into_dyn(vtable::VRc::new(
|
||||||
|
SkiaCachedImage { image: img.clone(), cache_key: cache_key.clone() },
|
||||||
|
))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
ImageInner::Svg(svg) => {
|
||||||
|
// Query target_width/height here again to ensure that changes will invalidate the item rendering cache.
|
||||||
|
let svg_size = svg.size();
|
||||||
|
let fit = i_slint_core::graphics::fit(
|
||||||
|
image_fit,
|
||||||
|
target_size_fn() * scale_factor,
|
||||||
|
IntRect::from_size(svg_size.cast()),
|
||||||
|
scale_factor,
|
||||||
|
Default::default(), // We only care about the size, so alignments don't matter
|
||||||
|
Default::default(),
|
||||||
|
);
|
||||||
|
let target_size = PhysicalSize::new(
|
||||||
|
svg_size.cast::<f32>().width * fit.source_to_target_x,
|
||||||
|
svg_size.cast::<f32>().height * fit.source_to_target_y,
|
||||||
|
);
|
||||||
|
let pixels = match svg.render(Some(target_size.cast())).ok()? {
|
||||||
|
SharedImageBuffer::RGB8(_) => unreachable!(),
|
||||||
|
SharedImageBuffer::RGBA8(_) => unreachable!(),
|
||||||
|
SharedImageBuffer::RGBA8Premultiplied(pixels) => pixels,
|
||||||
|
};
|
||||||
|
|
||||||
|
let image_info = skia_safe::ImageInfo::new(
|
||||||
|
skia_safe::ISize::new(pixels.width() as i32, pixels.height() as i32),
|
||||||
|
skia_safe::ColorType::RGBA8888,
|
||||||
|
skia_safe::AlphaType::Premul,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
skia_safe::images::raster_from_data(
|
||||||
|
&image_info,
|
||||||
|
skia_safe::Data::new_copy(pixels.as_bytes()),
|
||||||
|
pixels.width() as usize * 4,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ImageInner::StaticTextures(_) => todo!(),
|
||||||
|
ImageInner::BackendStorage(x) => {
|
||||||
|
vtable::VRc::borrow(x).downcast::<SkiaCachedImage>().map(|x| x.image.clone())
|
||||||
|
}
|
||||||
|
ImageInner::BorrowedOpenGLTexture(texture) => {
|
||||||
|
surface.and_then(|surface| surface.import_opengl_texture(canvas, texture))
|
||||||
|
}
|
||||||
|
ImageInner::NineSlice(n) => as_skia_image(
|
||||||
|
n.image(),
|
||||||
|
target_size_fn,
|
||||||
|
ImageFit::Preserve,
|
||||||
|
scale_factor,
|
||||||
|
canvas,
|
||||||
|
surface,
|
||||||
|
),
|
||||||
|
#[cfg(feature = "unstable-wgpu-29")]
|
||||||
|
ImageInner::WGPUTexture(any_wgpu_texture) => {
|
||||||
|
surface.and_then(|surface| surface.import_wgpu_texture(canvas, any_wgpu_texture))
|
||||||
|
}
|
||||||
|
#[allow(unreachable_patterns)]
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_buffer_to_skia_image(buffer: &SharedImageBuffer) -> Option<skia_safe::Image> {
|
||||||
|
let (data, bpl, size, color_type, alpha_type) = match buffer {
|
||||||
|
SharedImageBuffer::RGB8(pixels) => {
|
||||||
|
// RGB888 with one byte per component is not supported by Skia right now. Convert once to RGBA8 :-(
|
||||||
|
let rgba = pixels
|
||||||
|
.as_bytes()
|
||||||
|
.chunks(3)
|
||||||
|
.flat_map(|rgb| IntoIterator::into_iter([rgb[0], rgb[1], rgb[2], 255]))
|
||||||
|
.collect::<Vec<u8>>();
|
||||||
|
(
|
||||||
|
skia_safe::Data::new_copy(&rgba),
|
||||||
|
pixels.width() as usize * 4,
|
||||||
|
pixels.size(),
|
||||||
|
skia_safe::ColorType::RGBA8888,
|
||||||
|
skia_safe::AlphaType::Unpremul,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SharedImageBuffer::RGBA8(pixels) => (
|
||||||
|
skia_safe::Data::new_copy(pixels.as_bytes()),
|
||||||
|
pixels.width() as usize * 4,
|
||||||
|
pixels.size(),
|
||||||
|
skia_safe::ColorType::RGBA8888,
|
||||||
|
skia_safe::AlphaType::Unpremul,
|
||||||
|
),
|
||||||
|
SharedImageBuffer::RGBA8Premultiplied(pixels) => (
|
||||||
|
skia_safe::Data::new_copy(pixels.as_bytes()),
|
||||||
|
pixels.width() as usize * 4,
|
||||||
|
pixels.size(),
|
||||||
|
skia_safe::ColorType::RGBA8888,
|
||||||
|
skia_safe::AlphaType::Premul,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let image_info = skia_safe::ImageInfo::new(
|
||||||
|
skia_safe::ISize::new(size.width as i32, size.height as i32),
|
||||||
|
color_type,
|
||||||
|
alpha_type,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
skia_safe::images::raster_from_data(&image_info, data, bpl)
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
// Copyright © SixtyFPS GmbH <info@slint.dev>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
|
||||||
|
|
||||||
|
// cSpell: ignore HRESULT
|
||||||
|
use i_slint_core::api::{PhysicalSize as PhysicalWindowSize, Window};
|
||||||
|
use i_slint_core::graphics::RequestedGraphicsAPI;
|
||||||
|
use i_slint_core::partial_renderer::DirtyRegion;
|
||||||
|
use i_slint_core::platform::PlatformError;
|
||||||
|
use i_slint_core::renderer::DrawOutcome;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use windows::Win32::Graphics::Direct3D::D3D_FEATURE_LEVEL_11_0;
|
||||||
|
use windows::Win32::Graphics::Dxgi::Common::DXGI_STANDARD_MULTISAMPLE_QUALITY_PATTERN;
|
||||||
|
use windows::core::Interface;
|
||||||
|
|
||||||
|
use windows::Win32::Foundation::{DXGI_STATUS_OCCLUDED, HANDLE, HWND, S_OK};
|
||||||
|
use windows::Win32::Graphics::Direct3D12::{
|
||||||
|
D3D12_COMMAND_LIST_TYPE_DIRECT, D3D12_COMMAND_QUEUE_DESC, D3D12_FENCE_FLAG_NONE,
|
||||||
|
D3D12_RESOURCE_STATE_PRESENT, D3D12CreateDevice, ID3D12CommandQueue, ID3D12Device, ID3D12Fence,
|
||||||
|
ID3D12Resource,
|
||||||
|
};
|
||||||
|
use windows::Win32::Graphics::Dxgi::{
|
||||||
|
Common::{DXGI_FORMAT, DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_SAMPLE_DESC},
|
||||||
|
CreateDXGIFactory2, DXGI_ADAPTER_FLAG, DXGI_ADAPTER_FLAG_NONE, DXGI_ADAPTER_FLAG_SOFTWARE,
|
||||||
|
DXGI_CREATE_FACTORY_FLAGS, DXGI_PRESENT, DXGI_SWAP_CHAIN_DESC1, DXGI_SWAP_CHAIN_FLAG,
|
||||||
|
DXGI_SWAP_EFFECT_FLIP_DISCARD, DXGI_USAGE_RENDER_TARGET_OUTPUT, IDXGIFactory4, IDXGISwapChain3,
|
||||||
|
};
|
||||||
|
use windows::Win32::System::Threading::{CreateEventW, INFINITE, WaitForSingleObjectEx};
|
||||||
|
|
||||||
|
use crate::SkiaSharedContext;
|
||||||
|
|
||||||
|
trait MapToPlatformError<T> {
|
||||||
|
fn map_platform_error(self, msg: &str) -> std::result::Result<T, PlatformError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> MapToPlatformError<T> for windows::core::Result<T> {
|
||||||
|
fn map_platform_error(self, msg: &str) -> std::result::Result<T, PlatformError> {
|
||||||
|
match self {
|
||||||
|
Ok(r) => Ok(r),
|
||||||
|
Err(hr) => Err(format!("{} failed. {:x}", msg, hr.code().0).into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SURFACE_FORMAT: DXGI_FORMAT = DXGI_FORMAT_R8G8B8A8_UNORM;
|
||||||
|
|
||||||
|
struct SwapChain {
|
||||||
|
command_queue: ID3D12CommandQueue,
|
||||||
|
swap_chain: IDXGISwapChain3,
|
||||||
|
surfaces: Option<[skia_safe::Surface; 2]>,
|
||||||
|
current_buffer_index: usize,
|
||||||
|
fence: ID3D12Fence,
|
||||||
|
fence_values: [u64; 2],
|
||||||
|
fence_event: HANDLE,
|
||||||
|
gr_context: skia_safe::gpu::DirectContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SwapChain {
|
||||||
|
fn new(
|
||||||
|
command_queue: ID3D12CommandQueue,
|
||||||
|
device: &ID3D12Device,
|
||||||
|
mut gr_context: skia_safe::gpu::DirectContext,
|
||||||
|
window_handle: raw_window_handle::WindowHandle<'_>,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
dxgi_factory: &IDXGIFactory4,
|
||||||
|
) -> Result<Self, PlatformError> {
|
||||||
|
let swap_chain_desc = DXGI_SWAP_CHAIN_DESC1 {
|
||||||
|
Width: size.width,
|
||||||
|
Height: size.height,
|
||||||
|
Format: DEFAULT_SURFACE_FORMAT,
|
||||||
|
BufferCount: 2,
|
||||||
|
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
|
||||||
|
SwapEffect: DXGI_SWAP_EFFECT_FLIP_DISCARD,
|
||||||
|
SampleDesc: DXGI_SAMPLE_DESC { Count: 1, ..Default::default() },
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let hwnd = match window_handle.as_raw() {
|
||||||
|
raw_window_handle::RawWindowHandle::Win32(raw_window_handle::Win32WindowHandle {
|
||||||
|
hwnd,
|
||||||
|
..
|
||||||
|
}) => HWND(hwnd.get() as _),
|
||||||
|
_ => {
|
||||||
|
return Err(
|
||||||
|
format!("Metal surface is only supported with Win32WindowHandle").into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let swap_chain1 = unsafe {
|
||||||
|
dxgi_factory.CreateSwapChainForHwnd(&command_queue, hwnd, &swap_chain_desc, None, None)
|
||||||
|
}
|
||||||
|
.map_platform_error("unable to create D3D swap chain")?;
|
||||||
|
|
||||||
|
let swap_chain: IDXGISwapChain3 =
|
||||||
|
swap_chain1.cast().map_platform_error("unable to cast swap chain 1 to v3")?;
|
||||||
|
|
||||||
|
let fence = unsafe { device.CreateFence(0, D3D12_FENCE_FLAG_NONE) }
|
||||||
|
.map_platform_error("unable to create D3D12 fence")?;
|
||||||
|
|
||||||
|
let fence_values = [0, 0];
|
||||||
|
|
||||||
|
let fence_event = unsafe { CreateEventW(None, false, false, None) }
|
||||||
|
.map_platform_error("error creating fence event")?;
|
||||||
|
|
||||||
|
let current_buffer_index = unsafe { swap_chain.GetCurrentBackBufferIndex() } as usize;
|
||||||
|
|
||||||
|
let surfaces = Some(Self::create_surfaces(
|
||||||
|
&swap_chain,
|
||||||
|
&mut gr_context,
|
||||||
|
size.width as _,
|
||||||
|
size.height as _,
|
||||||
|
)?);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
command_queue,
|
||||||
|
swap_chain,
|
||||||
|
surfaces,
|
||||||
|
current_buffer_index,
|
||||||
|
fence,
|
||||||
|
fence_event,
|
||||||
|
fence_values,
|
||||||
|
gr_context,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_and_present<T>(
|
||||||
|
&mut self,
|
||||||
|
callback: impl FnOnce(&mut skia_safe::Surface, &mut skia_safe::gpu::DirectContext, u8) -> T,
|
||||||
|
pre_present_callback: &RefCell<Option<Box<dyn FnMut()>>>,
|
||||||
|
) -> Result<T, PlatformError> {
|
||||||
|
let current_fence_value = self.fence_values[self.current_buffer_index];
|
||||||
|
|
||||||
|
self.current_buffer_index = unsafe { self.swap_chain.GetCurrentBackBufferIndex() } as usize;
|
||||||
|
self.wait_for_buffer(self.current_buffer_index)?;
|
||||||
|
|
||||||
|
self.fence_values[self.current_buffer_index] = current_fence_value + 1;
|
||||||
|
|
||||||
|
let surface = &mut (*self.surfaces.as_mut().unwrap())[self.current_buffer_index];
|
||||||
|
|
||||||
|
// TODO: pass correct buffer age
|
||||||
|
let result = callback(surface, &mut self.gr_context, 0);
|
||||||
|
|
||||||
|
let info = Default::default();
|
||||||
|
self.gr_context.flush_surface_with_access(
|
||||||
|
surface,
|
||||||
|
skia_safe::surface::BackendSurfaceAccess::Present,
|
||||||
|
&info,
|
||||||
|
);
|
||||||
|
self.gr_context.submit(None);
|
||||||
|
|
||||||
|
if let Some(pre_present_callback) = pre_present_callback.borrow_mut().as_mut() {
|
||||||
|
pre_present_callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
let present_result = unsafe { self.swap_chain.Present(1, DXGI_PRESENT(0)) };
|
||||||
|
if present_result != S_OK && present_result != DXGI_STATUS_OCCLUDED {
|
||||||
|
return Err(format!("Error presenting d3d swap chain: {:x}", present_result.0).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
self.command_queue.Signal(&self.fence, self.fence_values[self.current_buffer_index])
|
||||||
|
}
|
||||||
|
.map_platform_error("error setting up completion signal for d3d12 command queue")?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_surfaces(
|
||||||
|
swap_chain: &IDXGISwapChain3,
|
||||||
|
gr_context: &mut skia_safe::gpu::DirectContext,
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
) -> Result<[skia_safe::Surface; 2], PlatformError> {
|
||||||
|
let mut make_surface = |buffer_index| {
|
||||||
|
let buffer: ID3D12Resource = unsafe { swap_chain.GetBuffer(buffer_index) }
|
||||||
|
.map_err(|hr| format!("unable to retrieve swap chain back buffer: {hr}"))?;
|
||||||
|
|
||||||
|
debug_assert_eq!(unsafe { buffer.GetDesc().Width }, width as u64);
|
||||||
|
debug_assert_eq!(unsafe { buffer.GetDesc().Height }, height as u32);
|
||||||
|
|
||||||
|
let texture_info = skia_safe::gpu::d3d::TextureResourceInfo {
|
||||||
|
resource: buffer,
|
||||||
|
alloc: None,
|
||||||
|
resource_state: D3D12_RESOURCE_STATE_PRESENT,
|
||||||
|
format: DEFAULT_SURFACE_FORMAT,
|
||||||
|
sample_count: 1,
|
||||||
|
level_count: 1,
|
||||||
|
sample_quality_pattern: DXGI_STANDARD_MULTISAMPLE_QUALITY_PATTERN,
|
||||||
|
protected: skia_safe::gpu::Protected::No,
|
||||||
|
};
|
||||||
|
let backend_texture =
|
||||||
|
skia_safe::gpu::backend_render_targets::make_d3d((width, height), &texture_info);
|
||||||
|
|
||||||
|
skia_safe::gpu::surfaces::wrap_backend_render_target(
|
||||||
|
gr_context,
|
||||||
|
&backend_texture,
|
||||||
|
skia_safe::gpu::SurfaceOrigin::TopLeft,
|
||||||
|
skia_safe::ColorType::RGBA8888,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.ok_or_else(|| format!("unable to create d3d skia backend render target"))
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok([make_surface(0)?, make_surface(1)?])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize(
|
||||||
|
&mut self,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> Result<(), i_slint_core::platform::PlatformError> {
|
||||||
|
self.gr_context.flush_submit_and_sync_cpu();
|
||||||
|
|
||||||
|
self.wait_for_buffer(0)?;
|
||||||
|
self.wait_for_buffer(1)?;
|
||||||
|
|
||||||
|
drop(self.surfaces.take());
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
self.swap_chain.ResizeBuffers(
|
||||||
|
0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
DEFAULT_SURFACE_FORMAT,
|
||||||
|
DXGI_SWAP_CHAIN_FLAG(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.map_platform_error("Error resizing swap chain buffers")?;
|
||||||
|
|
||||||
|
self.surfaces = Some(Self::create_surfaces(
|
||||||
|
&self.swap_chain,
|
||||||
|
&mut self.gr_context,
|
||||||
|
width as i32,
|
||||||
|
height as i32,
|
||||||
|
)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_buffer(&mut self, buffer_index: usize) -> Result<(), PlatformError> {
|
||||||
|
if unsafe { self.fence.GetCompletedValue() } < self.fence_values[buffer_index] {
|
||||||
|
unsafe {
|
||||||
|
self.fence.SetEventOnCompletion(self.fence_values[buffer_index], self.fence_event)
|
||||||
|
}
|
||||||
|
.map_platform_error("error setting event on command queue completion")?;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
WaitForSingleObjectEx(self.fence_event, INFINITE, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This surface renders into the given window using Direct 3D. The provided display
|
||||||
|
/// argument is ignored, as it has no meaning on Windows.
|
||||||
|
pub struct D3DSurface {
|
||||||
|
swap_chain: RefCell<SwapChain>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::Surface for D3DSurface {
|
||||||
|
fn new(
|
||||||
|
_shared_context: &SkiaSharedContext,
|
||||||
|
window_handle: Arc<dyn raw_window_handle::HasWindowHandle + Send + Sync>,
|
||||||
|
_display_handle: Arc<dyn raw_window_handle::HasDisplayHandle + Send + Sync>,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
requested_graphics_api: Option<RequestedGraphicsAPI>,
|
||||||
|
) -> Result<Self, i_slint_core::platform::PlatformError> {
|
||||||
|
if requested_graphics_api
|
||||||
|
.map_or(false, |api| !matches!(api, RequestedGraphicsAPI::Direct3D))
|
||||||
|
{
|
||||||
|
return Err(format!("Requested non-Direct3D rendering with Direct3D renderer").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let factory_flags = 0;
|
||||||
|
/*
|
||||||
|
let factory_flags = dxgi1_3::DXGI_CREATE_FACTORY_DEBUG;
|
||||||
|
|
||||||
|
{
|
||||||
|
let maybe_debug_interface: Result<
|
||||||
|
ComPtr<winapi::um::d3d12sdklayers::ID3D12Debug>,
|
||||||
|
HRESULT,
|
||||||
|
> = resolve_interface(|iid, ptr| unsafe { d3d12::D3D12GetDebugInterface(iid, ptr) });
|
||||||
|
if let Ok(debug) = maybe_debug_interface {
|
||||||
|
unsafe { debug.EnableDebugLayer() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
let dxgi_factory: IDXGIFactory4 =
|
||||||
|
unsafe { CreateDXGIFactory2(DXGI_CREATE_FACTORY_FLAGS(factory_flags)) }
|
||||||
|
.map_platform_error("unable to create DXGIFactory4")?;
|
||||||
|
|
||||||
|
let mut software_adapter_index = None;
|
||||||
|
let use_warp = std::env::var("SLINT_D3D_USE_WARP").is_ok();
|
||||||
|
|
||||||
|
let adapter = {
|
||||||
|
let mut i = 0;
|
||||||
|
loop {
|
||||||
|
let adapter = match unsafe { dxgi_factory.EnumAdapters1(i) } {
|
||||||
|
Ok(adapter) => adapter,
|
||||||
|
Err(_) => break None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(desc) = (unsafe { adapter.GetDesc1() }) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let adapter_is_warp = (DXGI_ADAPTER_FLAG(desc.Flags as i32)
|
||||||
|
& DXGI_ADAPTER_FLAG_SOFTWARE)
|
||||||
|
!= DXGI_ADAPTER_FLAG_NONE;
|
||||||
|
|
||||||
|
if adapter_is_warp {
|
||||||
|
if software_adapter_index.is_none() {
|
||||||
|
software_adapter_index = Some(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !use_warp {
|
||||||
|
i += 1;
|
||||||
|
// Select warp only if explicitly opted in via SLINT_D3D_USE_WARP
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// found warp adapter, requested warp? give it a try below
|
||||||
|
} else if use_warp {
|
||||||
|
// Don't select a non-warp adapter when warp is requested
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check to see whether the adapter supports Direct3D 12, but don't
|
||||||
|
// create the actual device yet.
|
||||||
|
if unsafe {
|
||||||
|
D3D12CreateDevice(
|
||||||
|
&adapter,
|
||||||
|
D3D_FEATURE_LEVEL_11_0,
|
||||||
|
std::ptr::null_mut::<Option<ID3D12Device>>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
break Some(adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let adapter = adapter.map_or_else(
|
||||||
|
|| {
|
||||||
|
let software_adapter_index = software_adapter_index
|
||||||
|
.ok_or_else(|| format!("unable to locate D3D software adapter"))?;
|
||||||
|
unsafe { dxgi_factory.EnumAdapters1(software_adapter_index) }
|
||||||
|
.map_err(|hr| format!("unable to create D3D software adapter: {hr}"))
|
||||||
|
},
|
||||||
|
|adapter| Ok(adapter),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut device: Option<ID3D12Device> = None;
|
||||||
|
unsafe { D3D12CreateDevice(&adapter, D3D_FEATURE_LEVEL_11_0, &mut device) }
|
||||||
|
.map_platform_error("error calling D3D12CreateDevice")?;
|
||||||
|
let device = device.unwrap();
|
||||||
|
|
||||||
|
let queue: ID3D12CommandQueue = {
|
||||||
|
let desc = D3D12_COMMAND_QUEUE_DESC {
|
||||||
|
Type: D3D12_COMMAND_LIST_TYPE_DIRECT,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
unsafe { device.CreateCommandQueue(&desc) }
|
||||||
|
.map_platform_error("Creating command queue")?
|
||||||
|
};
|
||||||
|
|
||||||
|
let backend_context = skia_safe::gpu::d3d::BackendContext {
|
||||||
|
adapter,
|
||||||
|
device: device.clone(),
|
||||||
|
queue: queue.clone(),
|
||||||
|
memory_allocator: None,
|
||||||
|
protected_context: skia_safe::gpu::Protected::No,
|
||||||
|
};
|
||||||
|
|
||||||
|
let gr_context =
|
||||||
|
unsafe { skia_safe::gpu::direct_contexts::make_d3d(&backend_context, None) }
|
||||||
|
.ok_or_else(|| format!("unable to create Skia D3D DirectContext"))?;
|
||||||
|
|
||||||
|
let window_handle = window_handle
|
||||||
|
.window_handle()
|
||||||
|
.map_err(|e| format!("error obtaining window handle for skia d3d renderer: {e}"))?;
|
||||||
|
|
||||||
|
let swap_chain = RefCell::new(SwapChain::new(
|
||||||
|
queue,
|
||||||
|
&device,
|
||||||
|
gr_context,
|
||||||
|
window_handle,
|
||||||
|
size,
|
||||||
|
&dxgi_factory,
|
||||||
|
)?);
|
||||||
|
|
||||||
|
Ok(Self { swap_chain })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"d3d"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_event(
|
||||||
|
&self,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
) -> Result<(), i_slint_core::platform::PlatformError> {
|
||||||
|
self.swap_chain.borrow_mut().resize(size.width, size.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
&self,
|
||||||
|
_window: &Window,
|
||||||
|
_size: PhysicalWindowSize,
|
||||||
|
callback: &dyn Fn(
|
||||||
|
&skia_safe::Canvas,
|
||||||
|
Option<&mut skia_safe::gpu::DirectContext>,
|
||||||
|
u8,
|
||||||
|
) -> Option<DirtyRegion>,
|
||||||
|
pre_present_callback: &RefCell<Option<Box<dyn FnMut()>>>,
|
||||||
|
) -> Result<DrawOutcome, i_slint_core::platform::PlatformError> {
|
||||||
|
self.swap_chain.borrow_mut().render_and_present(
|
||||||
|
|surface, gr_context, buffer_age| {
|
||||||
|
callback(surface.canvas(), Some(gr_context), buffer_age);
|
||||||
|
},
|
||||||
|
pre_present_callback,
|
||||||
|
)?;
|
||||||
|
Ok(DrawOutcome::Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bits_per_pixel(&self) -> Result<u8, i_slint_core::platform::PlatformError> {
|
||||||
|
let desc = unsafe { self.swap_chain.borrow().swap_chain.GetDesc() }
|
||||||
|
.map_platform_error("error getting swap chain description")?;
|
||||||
|
Ok(match desc.BufferDesc.Format {
|
||||||
|
DEFAULT_SURFACE_FORMAT => 32,
|
||||||
|
fmt @ _ => {
|
||||||
|
return Err(
|
||||||
|
format!("Skia D3D Renderer: Unsupported buffer format found {fmt:?}").into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Copyright © SixtyFPS GmbH <info@slint.dev>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
|
||||||
|
|
||||||
|
use clru::CLruCache;
|
||||||
|
use i_slint_common::sharedfontique::HashedBlob;
|
||||||
|
use i_slint_core::textlayout::sharedparley::{fontique, parley};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::num::NonZeroUsize;
|
||||||
|
|
||||||
|
const FONT_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(64).unwrap();
|
||||||
|
|
||||||
|
pub struct FontCache {
|
||||||
|
font_mgr: skia_safe::FontMgr,
|
||||||
|
// Use HashedBlob in key to keep strong reference to font data blob,
|
||||||
|
// preventing eviction from fontique's shared cache (see commit 30a03cf).
|
||||||
|
// The u64 is a hash of variation settings (0 for base typefaces).
|
||||||
|
fonts: CLruCache<(HashedBlob, u32, u64), Option<skia_safe::Typeface>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FontCache {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { font_mgr: skia_safe::FontMgr::new(), fonts: CLruCache::new(FONT_CACHE_CAPACITY) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FontCache {
|
||||||
|
pub fn font_with_variations(
|
||||||
|
&mut self,
|
||||||
|
font: &parley::FontData,
|
||||||
|
synthesis: &fontique::Synthesis,
|
||||||
|
) -> Option<skia_safe::Typeface> {
|
||||||
|
let variation_settings = synthesis.variation_settings();
|
||||||
|
|
||||||
|
let mut variations_hash = 0u64;
|
||||||
|
if !variation_settings.is_empty() {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
for &(tag, value) in variation_settings {
|
||||||
|
tag.to_be_bytes().hash(&mut hasher);
|
||||||
|
value.to_bits().hash(&mut hasher);
|
||||||
|
}
|
||||||
|
variations_hash = hasher.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = (font.data.clone().into(), font.index, variations_hash);
|
||||||
|
|
||||||
|
if let Some(cached) = self.fonts.get(&key) {
|
||||||
|
return cached.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut typeface = self.load_typeface_internal(font);
|
||||||
|
|
||||||
|
if !variation_settings.is_empty() {
|
||||||
|
typeface = typeface.and_then(|base| {
|
||||||
|
let coords: Vec<skia_safe::font_arguments::variation_position::Coordinate> =
|
||||||
|
variation_settings
|
||||||
|
.iter()
|
||||||
|
.map(|&(tag, value)| {
|
||||||
|
skia_safe::font_arguments::variation_position::Coordinate {
|
||||||
|
axis: skia_safe::FourByteTag::new(u32::from_be_bytes(
|
||||||
|
tag.to_be_bytes(),
|
||||||
|
)),
|
||||||
|
value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let position =
|
||||||
|
skia_safe::font_arguments::VariationPosition { coordinates: &coords };
|
||||||
|
let args = skia_safe::FontArguments::new().set_variation_design_position(position);
|
||||||
|
base.clone_with_arguments(&args).or(Some(base))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.fonts.put(key, typeface.clone());
|
||||||
|
typeface
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_typeface_internal(&self, font: &parley::FontData) -> Option<skia_safe::Typeface> {
|
||||||
|
let typeface = self.font_mgr.new_from_data(
|
||||||
|
font.data.as_ref(),
|
||||||
|
if font.index > 0 { Some(font.index as _) } else { None },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Due to https://issues.skia.org/issues/310510989, fonts from true type collections
|
||||||
|
// with an index > 0 fail to load on macOS. As a workaround, we manually extract the font from the
|
||||||
|
// collection and load it as a single font.
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
if font.index > 0
|
||||||
|
&& typeface.is_none()
|
||||||
|
&& let Some(typeface) = read_fonts::CollectionRef::new(font.data.as_ref())
|
||||||
|
.ok()
|
||||||
|
.and_then(|ttc| ttc.get(font.index).ok())
|
||||||
|
.map(|ttf| write_fonts::FontBuilder::new().copy_missing_tables(ttf).build())
|
||||||
|
.and_then(|new_ttf| self.font_mgr.new_from_data(&new_ttf, None))
|
||||||
|
{
|
||||||
|
return Some(typeface);
|
||||||
|
}
|
||||||
|
|
||||||
|
typeface
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
pub static FONT_CACHE: RefCell<FontCache> = RefCell::new(Default::default())
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+1154
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,270 @@
|
|||||||
|
// Copyright © SixtyFPS GmbH <info@slint.dev>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
|
||||||
|
|
||||||
|
// cSpell: ignore autoreleasepool drawables Snorm
|
||||||
|
use i_slint_core::api::{PhysicalSize as PhysicalWindowSize, Window};
|
||||||
|
use i_slint_core::graphics::RequestedGraphicsAPI;
|
||||||
|
use i_slint_core::partial_renderer::DirtyRegion;
|
||||||
|
use i_slint_core::renderer::DrawOutcome;
|
||||||
|
use objc2::rc::autoreleasepool;
|
||||||
|
use objc2::{rc::Retained, runtime::ProtocolObject};
|
||||||
|
use objc2_core_foundation::CGSize;
|
||||||
|
use objc2_metal::{MTLCommandBuffer, MTLCommandQueue, MTLDevice, MTLPixelFormat, MTLTexture};
|
||||||
|
use objc2_quartz_core::{CAMetalDrawable, CAMetalLayer};
|
||||||
|
|
||||||
|
use skia_safe::gpu::mtl;
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::SkiaSharedContext;
|
||||||
|
|
||||||
|
pub struct SharedMetalContext {
|
||||||
|
device: Retained<ProtocolObject<dyn objc2_metal::MTLDevice>>,
|
||||||
|
command_queue: Retained<ProtocolObject<dyn objc2_metal::MTLCommandQueue>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::SkiaSharedContextInner {
|
||||||
|
fn shared_metal_context(
|
||||||
|
&self,
|
||||||
|
) -> Result<&SharedMetalContext, i_slint_core::platform::PlatformError> {
|
||||||
|
if let Some(ctx) = self.metal_context.get() {
|
||||||
|
return Ok(ctx);
|
||||||
|
}
|
||||||
|
self.metal_context.set(SharedMetalContext::new()?).ok();
|
||||||
|
Ok(self.metal_context.get().unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedMetalContext {
|
||||||
|
fn new() -> Result<Self, i_slint_core::platform::PlatformError> {
|
||||||
|
let device = objc2_metal::MTLCreateSystemDefaultDevice().ok_or_else(|| {
|
||||||
|
"Skia Renderer: Unable to obtain metal system default device".to_string()
|
||||||
|
})?;
|
||||||
|
let command_queue = device
|
||||||
|
.newCommandQueue()
|
||||||
|
.ok_or_else(|| "Skia Renderer: Unable to create command queue".to_string())?;
|
||||||
|
Ok(Self { device, command_queue })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This surface renders into the given window using Metal. The provided display argument
|
||||||
|
/// is ignored, as it has no meaning on macOS.
|
||||||
|
pub struct MetalSurface {
|
||||||
|
command_queue: Retained<ProtocolObject<dyn objc2_metal::MTLCommandQueue>>,
|
||||||
|
layer: raw_window_metal::Layer,
|
||||||
|
gr_context: RefCell<skia_safe::gpu::DirectContext>,
|
||||||
|
// Map from drawable texture to age. Per https://developer.apple.com/documentation/quartzcore/cametallayer/maximumdrawablecount, CAMetalLayer
|
||||||
|
// can have either 2 or 3 drawables, but not more. That way, this vector is bound in growth.
|
||||||
|
drawable_ages: RefCell<Vec<(objc2_metal::MTLResourceID, u8)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::Surface for MetalSurface {
|
||||||
|
fn new(
|
||||||
|
shared_context: &SkiaSharedContext,
|
||||||
|
window_handle: Arc<dyn raw_window_handle::HasWindowHandle + Send + Sync>,
|
||||||
|
_display_handle: Arc<dyn raw_window_handle::HasDisplayHandle + Send + Sync>,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
requested_graphics_api: Option<RequestedGraphicsAPI>,
|
||||||
|
) -> Result<Self, i_slint_core::platform::PlatformError> {
|
||||||
|
if requested_graphics_api.is_some_and(|api| !matches!(api, RequestedGraphicsAPI::Metal)) {
|
||||||
|
return Err("Requested non-Metal rendering with Metal renderer".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let layer = match window_handle
|
||||||
|
.window_handle()
|
||||||
|
.map_err(|e| format!("Error obtaining window handle for skia metal renderer: {e}"))?
|
||||||
|
.as_raw()
|
||||||
|
{
|
||||||
|
raw_window_handle::RawWindowHandle::AppKit(handle) => unsafe {
|
||||||
|
raw_window_metal::Layer::from_ns_view(handle.ns_view)
|
||||||
|
},
|
||||||
|
raw_window_handle::RawWindowHandle::UiKit(handle) => unsafe {
|
||||||
|
raw_window_metal::Layer::from_ui_view(handle.ui_view)
|
||||||
|
},
|
||||||
|
_ => return Err("Skia Renderer: Metal surface is only supported with AppKit".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// SAFETY: The pointer is a valid `CAMetalLayer`.
|
||||||
|
let ca_layer: &CAMetalLayer = unsafe { layer.as_ptr().cast().as_ref() };
|
||||||
|
|
||||||
|
let shared_context = shared_context.0.shared_metal_context()?;
|
||||||
|
|
||||||
|
let device = &shared_context.device;
|
||||||
|
|
||||||
|
ca_layer.setDevice(Some(device));
|
||||||
|
ca_layer.setPixelFormat(MTLPixelFormat::BGRA8Unorm);
|
||||||
|
ca_layer.setOpaque(false);
|
||||||
|
ca_layer.setPresentsWithTransaction(false);
|
||||||
|
|
||||||
|
ca_layer.setDrawableSize(CGSize::new(size.width as f64, size.height as f64));
|
||||||
|
|
||||||
|
let flipped = ca_layer.contentsAreFlipped();
|
||||||
|
let gravity = if !flipped {
|
||||||
|
unsafe { objc2_quartz_core::kCAGravityTopLeft }
|
||||||
|
} else {
|
||||||
|
unsafe { objc2_quartz_core::kCAGravityBottomLeft }
|
||||||
|
};
|
||||||
|
ca_layer.setContentsGravity(gravity);
|
||||||
|
|
||||||
|
let command_queue = shared_context.command_queue.clone();
|
||||||
|
|
||||||
|
let backend = unsafe {
|
||||||
|
mtl::BackendContext::new(
|
||||||
|
Retained::as_ptr(device) as mtl::Handle,
|
||||||
|
Retained::as_ptr(&command_queue) as mtl::Handle,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let gr_context =
|
||||||
|
skia_safe::gpu::direct_contexts::make_metal(&backend, None).unwrap().into();
|
||||||
|
|
||||||
|
Ok(Self { command_queue, layer, gr_context, drawable_ages: Default::default() })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"metal"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_event(
|
||||||
|
&self,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
) -> Result<(), i_slint_core::platform::PlatformError> {
|
||||||
|
// SAFETY: The pointer is a valid `CAMetalLayer`.
|
||||||
|
let ca_layer: &CAMetalLayer = unsafe { self.layer.as_ptr().cast().as_ref() };
|
||||||
|
ca_layer.setDrawableSize(CGSize::new(size.width as f64, size.height as f64));
|
||||||
|
self.drawable_ages.borrow_mut().clear();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
&self,
|
||||||
|
_window: &Window,
|
||||||
|
_size: PhysicalWindowSize,
|
||||||
|
callback: &dyn Fn(
|
||||||
|
&skia_safe::Canvas,
|
||||||
|
Option<&mut skia_safe::gpu::DirectContext>,
|
||||||
|
u8,
|
||||||
|
) -> Option<DirtyRegion>,
|
||||||
|
pre_present_callback: &RefCell<Option<Box<dyn FnMut()>>>,
|
||||||
|
) -> Result<DrawOutcome, i_slint_core::platform::PlatformError> {
|
||||||
|
autoreleasepool(|_| {
|
||||||
|
// SAFETY: The pointer is a valid `CAMetalLayer`.
|
||||||
|
let ca_layer: &CAMetalLayer = unsafe { self.layer.as_ptr().cast().as_ref() };
|
||||||
|
let drawable = match ca_layer.nextDrawable() {
|
||||||
|
Some(drawable) => drawable,
|
||||||
|
None => {
|
||||||
|
return Err(
|
||||||
|
"Skia Metal Renderer: Failed to retrieve next drawable for rendering"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let gr_context = &mut self.gr_context.borrow_mut();
|
||||||
|
|
||||||
|
let size = ca_layer.drawableSize();
|
||||||
|
|
||||||
|
let mut surface = unsafe {
|
||||||
|
let texture = drawable.texture();
|
||||||
|
let texture_info = mtl::TextureInfo::new(Retained::as_ptr(&texture) as mtl::Handle);
|
||||||
|
|
||||||
|
let backend_render_target = skia_safe::gpu::backend_render_targets::make_mtl(
|
||||||
|
(size.width as i32, size.height as i32),
|
||||||
|
&texture_info,
|
||||||
|
);
|
||||||
|
|
||||||
|
skia_safe::gpu::surfaces::wrap_backend_render_target(
|
||||||
|
gr_context,
|
||||||
|
&backend_render_target,
|
||||||
|
skia_safe::gpu::SurfaceOrigin::TopLeft,
|
||||||
|
skia_safe::ColorType::BGRA8888,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
let texture: Retained<ProtocolObject<dyn MTLTexture>> = drawable.texture();
|
||||||
|
let texture_id = texture.gpuResourceID();
|
||||||
|
let age = {
|
||||||
|
let mut drawables = self.drawable_ages.borrow_mut();
|
||||||
|
if let Some(existing_age) =
|
||||||
|
drawables.iter().find_map(|(id, age)| (*id == texture_id).then_some(*age))
|
||||||
|
{
|
||||||
|
existing_age
|
||||||
|
} else {
|
||||||
|
drawables.push((texture_id, 0));
|
||||||
|
0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
callback(surface.canvas(), Some(gr_context), age);
|
||||||
|
|
||||||
|
drop(surface);
|
||||||
|
|
||||||
|
gr_context.submit(None);
|
||||||
|
|
||||||
|
if let Some(pre_present_callback) = pre_present_callback.borrow_mut().as_mut() {
|
||||||
|
pre_present_callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
let command_buffer = self.command_queue.commandBuffer().ok_or_else(|| {
|
||||||
|
"Skia Renderer: Unable to obtain command queue's command buffer".to_string()
|
||||||
|
})?;
|
||||||
|
command_buffer.presentDrawable(ProtocolObject::from_ref(&*drawable));
|
||||||
|
command_buffer.commit();
|
||||||
|
|
||||||
|
self.drawable_ages.borrow_mut().retain_mut(|(id, age)| {
|
||||||
|
if *id == texture_id {
|
||||||
|
*age = 1;
|
||||||
|
} else {
|
||||||
|
let Some(new_age) = age.checked_add(1) else {
|
||||||
|
// texture became too old, remove it.
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
*age = new_age;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(DrawOutcome::Success)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bits_per_pixel(&self) -> Result<u8, i_slint_core::platform::PlatformError> {
|
||||||
|
// SAFETY: The pointer is a valid `CAMetalLayer`.
|
||||||
|
let ca_layer: &CAMetalLayer = unsafe { self.layer.as_ptr().cast().as_ref() };
|
||||||
|
|
||||||
|
// From https://developer.apple.com/documentation/metal/mtlpixelformat:
|
||||||
|
// The storage size of each pixel format is determined by the sum of its components.
|
||||||
|
// For example, the storage size of BGRA8Unorm is 32 bits (four 8-bit components) and
|
||||||
|
// the storage size of BGR5A1Unorm is 16 bits (three 5-bit components and one 1-bit component).
|
||||||
|
Ok(match ca_layer.pixelFormat() {
|
||||||
|
MTLPixelFormat::B5G6R5Unorm
|
||||||
|
| MTLPixelFormat::A1BGR5Unorm
|
||||||
|
| MTLPixelFormat::ABGR4Unorm
|
||||||
|
| MTLPixelFormat::BGR5A1Unorm => 16,
|
||||||
|
MTLPixelFormat::RGBA8Unorm
|
||||||
|
| MTLPixelFormat::RGBA8Unorm_sRGB
|
||||||
|
| MTLPixelFormat::RGBA8Snorm
|
||||||
|
| MTLPixelFormat::RGBA8Uint
|
||||||
|
| MTLPixelFormat::RGBA8Sint
|
||||||
|
| MTLPixelFormat::BGRA8Unorm
|
||||||
|
| MTLPixelFormat::BGRA8Unorm_sRGB => 32,
|
||||||
|
MTLPixelFormat::RGB10A2Unorm
|
||||||
|
| MTLPixelFormat::RGB10A2Uint
|
||||||
|
| MTLPixelFormat::BGR10A2Unorm => 32,
|
||||||
|
MTLPixelFormat::RGBA16Unorm
|
||||||
|
| MTLPixelFormat::RGBA16Snorm
|
||||||
|
| MTLPixelFormat::RGBA16Uint
|
||||||
|
| MTLPixelFormat::RGBA16Sint => 64,
|
||||||
|
MTLPixelFormat::RGBA32Uint | MTLPixelFormat::RGBA32Sint => 128,
|
||||||
|
fmt => {
|
||||||
|
return Err(format!(
|
||||||
|
"Skia Metal Renderer: Unsupported layer pixel format found {fmt:?}"
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
// Copyright © SixtyFPS GmbH <info@slint.dev>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
|
||||||
|
|
||||||
|
// cSpell: ignore fboid
|
||||||
|
use std::num::NonZeroU32;
|
||||||
|
use std::{cell::RefCell, sync::Arc};
|
||||||
|
|
||||||
|
use glutin::{
|
||||||
|
config::GetGlConfig,
|
||||||
|
context::{ContextApi, ContextAttributesBuilder},
|
||||||
|
display::GetGlDisplay,
|
||||||
|
prelude::*,
|
||||||
|
surface::{SurfaceAttributesBuilder, WindowSurface},
|
||||||
|
};
|
||||||
|
use i_slint_core::api::{GraphicsAPI, PhysicalSize as PhysicalWindowSize, Window};
|
||||||
|
use i_slint_core::graphics::{BorrowedOpenGLTexture, RequestedGraphicsAPI, RequestedOpenGLVersion};
|
||||||
|
use i_slint_core::partial_renderer::DirtyRegion;
|
||||||
|
use i_slint_core::platform::PlatformError;
|
||||||
|
use i_slint_core::renderer::DrawOutcome;
|
||||||
|
|
||||||
|
use crate::SkiaSharedContext;
|
||||||
|
|
||||||
|
/// Wraps a [`glutin::context::PossiblyCurrentContext`] and makes it not-current on drop,
|
||||||
|
/// so that no stale thread-local state remains after the context is destroyed.
|
||||||
|
struct GlutinContext(Option<glutin::context::PossiblyCurrentContext>);
|
||||||
|
|
||||||
|
impl GlutinContext {
|
||||||
|
fn new(context: glutin::context::PossiblyCurrentContext) -> Self {
|
||||||
|
Self(Some(context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for GlutinContext {
|
||||||
|
type Target = glutin::context::PossiblyCurrentContext;
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
self.0.as_ref().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for GlutinContext {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(context) = self.0.take()
|
||||||
|
&& let Err(e) = context.make_not_current()
|
||||||
|
{
|
||||||
|
i_slint_core::debug_log!(
|
||||||
|
"Skia OpenGL Renderer: Failed to make context not current: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This surface type renders into the given window with OpenGL, using glutin and glow libraries.
|
||||||
|
pub struct OpenGLSurface {
|
||||||
|
fb_info: skia_safe::gpu::gl::FramebufferInfo,
|
||||||
|
surface: RefCell<skia_safe::Surface>,
|
||||||
|
gr_context: RefCell<skia_safe::gpu::DirectContext>,
|
||||||
|
glutin_context: GlutinContext,
|
||||||
|
glutin_surface: glutin::surface::Surface<glutin::surface::WindowSurface>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::Surface for OpenGLSurface {
|
||||||
|
fn new(
|
||||||
|
_shared_context: &SkiaSharedContext,
|
||||||
|
window_handle: Arc<dyn raw_window_handle::HasWindowHandle + Send + Sync>,
|
||||||
|
display_handle: Arc<dyn raw_window_handle::HasDisplayHandle + Send + Sync>,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
requested_graphics_api: Option<RequestedGraphicsAPI>,
|
||||||
|
) -> Result<Self, PlatformError> {
|
||||||
|
Self::new_with_config(
|
||||||
|
window_handle,
|
||||||
|
display_handle,
|
||||||
|
size,
|
||||||
|
requested_graphics_api.as_ref().map(TryInto::try_into).transpose()?,
|
||||||
|
glutin::config::ConfigTemplateBuilder::new(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"opengl"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_graphics_api(&self, callback: &mut dyn FnMut(GraphicsAPI<'_>)) {
|
||||||
|
let api = GraphicsAPI::NativeOpenGL {
|
||||||
|
get_proc_address: &|name| {
|
||||||
|
self.glutin_context.display().get_proc_address(name) as *const _
|
||||||
|
},
|
||||||
|
};
|
||||||
|
callback(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_active_surface(&self, callback: &mut dyn FnMut()) -> Result<(), PlatformError> {
|
||||||
|
self.ensure_context_current()?;
|
||||||
|
callback();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
&self,
|
||||||
|
_window: &Window,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
callback: &dyn Fn(
|
||||||
|
&skia_safe::Canvas,
|
||||||
|
Option<&mut skia_safe::gpu::DirectContext>,
|
||||||
|
u8,
|
||||||
|
) -> Option<DirtyRegion>,
|
||||||
|
pre_present_callback: &RefCell<Option<Box<dyn FnMut()>>>,
|
||||||
|
) -> Result<DrawOutcome, PlatformError> {
|
||||||
|
self.ensure_context_current()?;
|
||||||
|
|
||||||
|
let current_context = &self.glutin_context;
|
||||||
|
|
||||||
|
let gr_context = &mut self.gr_context.borrow_mut();
|
||||||
|
|
||||||
|
let mut surface = self.surface.borrow_mut();
|
||||||
|
|
||||||
|
let width = size.width.try_into().ok();
|
||||||
|
let height = size.height.try_into().ok();
|
||||||
|
|
||||||
|
if let Some((width, height)) = width.zip(height)
|
||||||
|
&& (width != surface.width() || height != surface.height())
|
||||||
|
{
|
||||||
|
*surface = Self::create_internal_surface(
|
||||||
|
self.fb_info,
|
||||||
|
current_context,
|
||||||
|
gr_context,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let skia_canvas = surface.canvas();
|
||||||
|
|
||||||
|
skia_canvas.save();
|
||||||
|
callback(
|
||||||
|
skia_canvas,
|
||||||
|
Some(gr_context),
|
||||||
|
u8::try_from(self.glutin_surface.buffer_age()).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
skia_canvas.restore();
|
||||||
|
|
||||||
|
if let Some(pre_present_callback) = pre_present_callback.borrow_mut().as_mut() {
|
||||||
|
pre_present_callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.glutin_surface.swap_buffers(current_context).map(|_| DrawOutcome::Success).map_err(
|
||||||
|
|glutin_error| {
|
||||||
|
format!("Skia OpenGL Renderer: Error swapping buffers: {glutin_error}").into()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_event(&self, size: PhysicalWindowSize) -> Result<(), PlatformError> {
|
||||||
|
self.ensure_context_current()?;
|
||||||
|
|
||||||
|
if let Some((width, height)) = size.width.try_into().ok().zip(size.height.try_into().ok()) {
|
||||||
|
self.glutin_surface.resize(&self.glutin_context, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bits_per_pixel(&self) -> Result<u8, PlatformError> {
|
||||||
|
let config = self.glutin_context.config();
|
||||||
|
let rgb_bits = match config.color_buffer_type() {
|
||||||
|
Some(glutin::config::ColorBufferType::Rgb { r_size, g_size, b_size }) => {
|
||||||
|
r_size + g_size + b_size
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(format!(
|
||||||
|
"Skia OpenGL Renderer: unsupported color buffer {other:?} encountered"
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(rgb_bits + config.alpha_size())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_opengl_texture(
|
||||||
|
&self,
|
||||||
|
canvas: &skia_safe::Canvas,
|
||||||
|
BorrowedOpenGLTexture { texture_id, size, origin, .. }: &BorrowedOpenGLTexture,
|
||||||
|
) -> Option<skia_safe::Image> {
|
||||||
|
unsafe {
|
||||||
|
let mut texture_info = skia_safe::gpu::gl::TextureInfo::from_target_and_id(
|
||||||
|
glow::TEXTURE_2D,
|
||||||
|
texture_id.get(),
|
||||||
|
);
|
||||||
|
texture_info.format = glow::RGBA8;
|
||||||
|
let backend_texture = skia_safe::gpu::backend_textures::make_gl(
|
||||||
|
(size.width as _, size.height as _),
|
||||||
|
skia_safe::gpu::Mipmapped::No,
|
||||||
|
texture_info,
|
||||||
|
"Borrowed GL texture",
|
||||||
|
);
|
||||||
|
skia_safe::image::Image::from_texture(
|
||||||
|
canvas.recording_context().as_mut().unwrap(),
|
||||||
|
&backend_texture,
|
||||||
|
match origin {
|
||||||
|
i_slint_core::graphics::BorrowedOpenGLTextureOrigin::TopLeft => {
|
||||||
|
skia_safe::gpu::SurfaceOrigin::TopLeft
|
||||||
|
}
|
||||||
|
i_slint_core::graphics::BorrowedOpenGLTextureOrigin::BottomLeft => {
|
||||||
|
skia_safe::gpu::SurfaceOrigin::BottomLeft
|
||||||
|
}
|
||||||
|
_ => unimplemented!(
|
||||||
|
"internal error: missing implementation for BorrowedOpenGLTextureOrigin"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
skia_safe::ColorType::RGBA8888,
|
||||||
|
skia_safe::AlphaType::Unpremul,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenGLSurface {
|
||||||
|
pub fn new_with_config(
|
||||||
|
window_handle: Arc<dyn raw_window_handle::HasWindowHandle>,
|
||||||
|
display_handle: Arc<dyn raw_window_handle::HasDisplayHandle>,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
requested_opengl_version: Option<RequestedOpenGLVersion>,
|
||||||
|
config_builder: glutin::config::ConfigTemplateBuilder,
|
||||||
|
config_filter: Option<&dyn Fn(&glutin::config::Config) -> bool>,
|
||||||
|
) -> Result<Self, PlatformError> {
|
||||||
|
let width: std::num::NonZeroU32 = size.width.try_into().map_err(|_| {
|
||||||
|
format!("Attempting to create window surface with an invalid width: {}", size.width)
|
||||||
|
})?;
|
||||||
|
let height: std::num::NonZeroU32 = size.height.try_into().map_err(|_| {
|
||||||
|
format!("Attempting to create window surface with an invalid height: {}", size.height)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let window_handle = window_handle
|
||||||
|
.window_handle()
|
||||||
|
.map_err(|e| format!("error obtaining window handle for skia opengl renderer: {e}"))?;
|
||||||
|
let display_handle = display_handle
|
||||||
|
.display_handle()
|
||||||
|
.map_err(|e| format!("error obtaining display handle for skia opengl renderer: {e}"))?;
|
||||||
|
|
||||||
|
let (current_glutin_context, glutin_surface) = Self::init_glutin(
|
||||||
|
window_handle,
|
||||||
|
display_handle,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
requested_opengl_version,
|
||||||
|
config_builder,
|
||||||
|
config_filter,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
glutin_surface.resize(¤t_glutin_context, width, height);
|
||||||
|
|
||||||
|
let fb_info = {
|
||||||
|
use glow::HasContext;
|
||||||
|
|
||||||
|
let gl = unsafe {
|
||||||
|
glow::Context::from_loader_function_cstr(|name| {
|
||||||
|
current_glutin_context.display().get_proc_address(name) as *const _
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let fboid = unsafe { gl.get_parameter_i32(glow::FRAMEBUFFER_BINDING) };
|
||||||
|
|
||||||
|
skia_safe::gpu::gl::FramebufferInfo {
|
||||||
|
fboid: fboid.try_into().map_err(|_| {
|
||||||
|
"Skia Renderer: Internal error, framebuffer binding returned signed id"
|
||||||
|
.to_string()
|
||||||
|
})?,
|
||||||
|
format: skia_safe::gpu::gl::Format::RGBA8.into(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let gl_interface = skia_safe::gpu::gl::Interface::new_load_with_cstr(|name| {
|
||||||
|
current_glutin_context.display().get_proc_address(name) as *const _
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
"Skia Renderer: Internal Error: Could not create OpenGL Interface".to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut gr_context =
|
||||||
|
skia_safe::gpu::direct_contexts::make_gl(gl_interface, None).ok_or_else(|| {
|
||||||
|
"Skia Renderer: Internal Error: Could not create Skia Direct Context from GL interface".to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let width: i32 = size.width.try_into().map_err(|e| {
|
||||||
|
format!("Attempting to create window surface with width that doesn't fit into non-zero i32: {e}")
|
||||||
|
})?;
|
||||||
|
let height: i32 = size.height.try_into().map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"Attempting to create window surface with height that doesn't fit into non-zero i32: {e}"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let surface = Self::create_internal_surface(
|
||||||
|
fb_info,
|
||||||
|
¤t_glutin_context,
|
||||||
|
&mut gr_context,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
)?
|
||||||
|
.into();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
fb_info,
|
||||||
|
surface,
|
||||||
|
gr_context: RefCell::new(gr_context),
|
||||||
|
glutin_context: GlutinContext::new(current_glutin_context),
|
||||||
|
glutin_surface,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_glutin(
|
||||||
|
_window_handle: raw_window_handle::WindowHandle<'_>,
|
||||||
|
_display_handle: raw_window_handle::DisplayHandle<'_>,
|
||||||
|
width: NonZeroU32,
|
||||||
|
height: NonZeroU32,
|
||||||
|
requested_opengl_version: Option<RequestedOpenGLVersion>,
|
||||||
|
config_template_builder: glutin::config::ConfigTemplateBuilder,
|
||||||
|
config_filter: Option<&dyn Fn(&glutin::config::Config) -> bool>,
|
||||||
|
) -> Result<
|
||||||
|
(
|
||||||
|
glutin::context::PossiblyCurrentContext,
|
||||||
|
glutin::surface::Surface<glutin::surface::WindowSurface>,
|
||||||
|
),
|
||||||
|
PlatformError,
|
||||||
|
> {
|
||||||
|
cfg_if::cfg_if! {
|
||||||
|
if #[cfg(target_os = "macos")] {
|
||||||
|
let display_api_preference = glutin::display::DisplayApiPreference::Cgl;
|
||||||
|
} else if #[cfg(not(target_family = "windows"))] {
|
||||||
|
let display_api_preference = glutin::display::DisplayApiPreference::Egl;
|
||||||
|
} else {
|
||||||
|
let display_api_preference = glutin::display::DisplayApiPreference::EglThenWgl(Some(_window_handle.as_raw()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let gl_display = unsafe {
|
||||||
|
glutin::display::Display::new(_display_handle.as_raw(), display_api_preference)
|
||||||
|
.map_err(|glutin_error| {
|
||||||
|
format!(
|
||||||
|
"Error creating glutin display for native display {:?}: {}",
|
||||||
|
_display_handle.as_raw(),
|
||||||
|
glutin_error
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
// On macOS, there's only one GL config and that's initialized based on the values in the config template
|
||||||
|
// builder. So if that one has transparency enabled, it'll show up in the config, and will be set on the
|
||||||
|
// context later. So we must enable it here, there's no way of enabling it later.
|
||||||
|
// On EGL/GLX/WGL there are system provided configs that may or may not support transparency. Here in case
|
||||||
|
// the system doesn't support transparency, we want to fall back to a config that doesn't - better than not
|
||||||
|
// rendering anything at all. So we don't want to limit the configurations we get to see early on.
|
||||||
|
// Commented out due to https://github.com/rust-windowing/glutin/issues/1640
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let config_template_builder = config_template_builder.with_transparency(true);
|
||||||
|
|
||||||
|
// Upstream advises to use this only on Windows.
|
||||||
|
#[cfg(target_family = "windows")]
|
||||||
|
let config_template_builder =
|
||||||
|
config_template_builder.compatible_with_native_window(_window_handle.as_raw());
|
||||||
|
|
||||||
|
let config_template = config_template_builder.build();
|
||||||
|
|
||||||
|
let config = unsafe {
|
||||||
|
gl_display
|
||||||
|
.find_configs(config_template)
|
||||||
|
.map_err(|e| format!("Could not find valid OpenGL display configurations: {e}"))?
|
||||||
|
.filter(|config| config_filter.as_ref().is_none_or(|filter_fn| filter_fn(config)))
|
||||||
|
.reduce(|accum, config| {
|
||||||
|
let transparency_check = config.supports_transparency().unwrap_or(false)
|
||||||
|
& !accum.supports_transparency().unwrap_or(false);
|
||||||
|
|
||||||
|
if transparency_check || config.num_samples() < accum.num_samples() {
|
||||||
|
config
|
||||||
|
} else {
|
||||||
|
accum
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok_or("Unable to find suitable GL config")?
|
||||||
|
};
|
||||||
|
|
||||||
|
let requested_opengl_version =
|
||||||
|
requested_opengl_version.unwrap_or(RequestedOpenGLVersion::OpenGLES(Some((3, 0))));
|
||||||
|
let preferred_context_attributes = match requested_opengl_version {
|
||||||
|
RequestedOpenGLVersion::OpenGL(version) => {
|
||||||
|
let version =
|
||||||
|
version.map(|(major, minor)| glutin::context::Version { major, minor });
|
||||||
|
ContextAttributesBuilder::new()
|
||||||
|
.with_context_api(ContextApi::OpenGl(version))
|
||||||
|
.build(Some(_window_handle.as_raw()))
|
||||||
|
}
|
||||||
|
RequestedOpenGLVersion::OpenGLES(version) => {
|
||||||
|
let version =
|
||||||
|
version.map(|(major, minor)| glutin::context::Version { major, minor });
|
||||||
|
|
||||||
|
ContextAttributesBuilder::new()
|
||||||
|
.with_context_api(ContextApi::Gles(version))
|
||||||
|
.build(Some(_window_handle.as_raw()))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let gles2_fallback_context_attributes = ContextAttributesBuilder::new()
|
||||||
|
.with_context_api(ContextApi::Gles(Some(glutin::context::Version {
|
||||||
|
major: 2,
|
||||||
|
minor: 0,
|
||||||
|
})))
|
||||||
|
.build(Some(_window_handle.as_raw()));
|
||||||
|
|
||||||
|
let fallback_context_attributes =
|
||||||
|
ContextAttributesBuilder::new().build(Some(_window_handle.as_raw()));
|
||||||
|
|
||||||
|
let not_current_gl_context = unsafe {
|
||||||
|
gl_display
|
||||||
|
.create_context(&config, &preferred_context_attributes)
|
||||||
|
.or_else(|_| gl_display.create_context(&config, &gles2_fallback_context_attributes))
|
||||||
|
.or_else(|_| gl_display.create_context(&config, &fallback_context_attributes))
|
||||||
|
.map_err(|e| format!("Error creating OpenGL context: {e}"))
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let attrs = SurfaceAttributesBuilder::<WindowSurface>::new().build(
|
||||||
|
_window_handle.as_raw(),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
);
|
||||||
|
|
||||||
|
let surface = unsafe {
|
||||||
|
config
|
||||||
|
.display()
|
||||||
|
.create_window_surface(&config, &attrs)
|
||||||
|
.map_err(|e| format!("Error creating OpenGL window surface: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let context = not_current_gl_context.make_current(&surface)
|
||||||
|
.map_err(|glutin_error: glutin::error::Error| -> PlatformError {
|
||||||
|
format!("FemtoVG Renderer: Failed to make newly created OpenGL context current: {glutin_error}")
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Align the GL layer to the top-left, so that resizing only invalidates the bottom/right
|
||||||
|
// part of the window.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if let raw_window_handle::RawWindowHandle::AppKit(raw_window_handle::AppKitWindowHandle {
|
||||||
|
ns_view,
|
||||||
|
..
|
||||||
|
}) = _window_handle.as_raw()
|
||||||
|
{
|
||||||
|
let ns_view: &objc2_app_kit::NSView = unsafe { ns_view.cast().as_ref() };
|
||||||
|
ns_view.setLayerContentsPlacement(objc2_app_kit::NSViewLayerContentsPlacement::TopLeft);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity check, as all this might succeed on Windows without working GL drivers, but this will fail:
|
||||||
|
if context
|
||||||
|
.display()
|
||||||
|
.get_proc_address(&std::ffi::CString::new("glCreateShader").unwrap())
|
||||||
|
.is_null()
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"Failed to initialize OpenGL driver: Could not locate glCreateShader symbol"
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to default to vsync and ignore if the driver doesn't support it.
|
||||||
|
surface
|
||||||
|
.set_swap_interval(
|
||||||
|
&context,
|
||||||
|
glutin::surface::SwapInterval::Wait(NonZeroU32::new(1).unwrap()),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
Ok((context, surface))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_internal_surface(
|
||||||
|
fb_info: skia_safe::gpu::gl::FramebufferInfo,
|
||||||
|
gl_context: &glutin::context::PossiblyCurrentContext,
|
||||||
|
gr_context: &mut skia_safe::gpu::DirectContext,
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
) -> Result<skia_safe::Surface, PlatformError> {
|
||||||
|
let config = gl_context.config();
|
||||||
|
|
||||||
|
let backend_render_target = skia_safe::gpu::backend_render_targets::make_gl(
|
||||||
|
(width, height),
|
||||||
|
Some(config.num_samples() as _),
|
||||||
|
config.stencil_size() as _,
|
||||||
|
fb_info,
|
||||||
|
);
|
||||||
|
match skia_safe::gpu::surfaces::wrap_backend_render_target(
|
||||||
|
gr_context,
|
||||||
|
&backend_render_target,
|
||||||
|
skia_safe::gpu::SurfaceOrigin::BottomLeft,
|
||||||
|
skia_safe::ColorType::RGBA8888,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
) {
|
||||||
|
Some(surface) => Ok(surface),
|
||||||
|
None => {
|
||||||
|
Err("Skia OpenGL Renderer: Failed to allocate internal backend rendering target"
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_context_current(&self) -> Result<(), PlatformError> {
|
||||||
|
if !self.glutin_context.is_current() {
|
||||||
|
self.glutin_context.make_current(&self.glutin_surface).map_err(
|
||||||
|
|glutin_error| -> PlatformError {
|
||||||
|
format!("Skia Renderer: Error making context current: {glutin_error}").into()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for OpenGLSurface {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Make sure that the context is current before Skia calls glDelete***
|
||||||
|
// In the event that this fails for some reason (lost GL context), convey that to Skia so that it doesn't try to call
|
||||||
|
// glDelete***
|
||||||
|
if self.ensure_context_current().is_err() {
|
||||||
|
i_slint_core::debug_log!(
|
||||||
|
"Skia OpenGL Renderer warning: Failed to make context current for destruction - considering context abandoned."
|
||||||
|
);
|
||||||
|
self.gr_context.borrow_mut().abandon();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// Copyright © SixtyFPS GmbH <info@slint.dev>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
|
||||||
|
|
||||||
|
use i_slint_core::api::{PhysicalSize as PhysicalWindowSize, Window};
|
||||||
|
use i_slint_core::graphics::RequestedGraphicsAPI;
|
||||||
|
use i_slint_core::partial_renderer::DirtyRegion;
|
||||||
|
use i_slint_core::renderer::DrawOutcome;
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::num::NonZeroU32;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::SkiaSharedContext;
|
||||||
|
|
||||||
|
pub trait RenderBuffer {
|
||||||
|
fn with_buffer(
|
||||||
|
&self,
|
||||||
|
window: &Window,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
render_callback: &mut dyn FnMut(
|
||||||
|
NonZeroU32,
|
||||||
|
NonZeroU32,
|
||||||
|
skia_safe::ColorType,
|
||||||
|
u8,
|
||||||
|
&mut [u8],
|
||||||
|
) -> Result<
|
||||||
|
Option<DirtyRegion>,
|
||||||
|
i_slint_core::platform::PlatformError,
|
||||||
|
>,
|
||||||
|
) -> Result<(), i_slint_core::platform::PlatformError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "softbuffer")]
|
||||||
|
struct SoftbufferRenderBuffer {
|
||||||
|
_context: softbuffer::Context<Arc<dyn raw_window_handle::HasDisplayHandle + Send + Sync>>,
|
||||||
|
surface: RefCell<
|
||||||
|
softbuffer::Surface<
|
||||||
|
Arc<dyn raw_window_handle::HasDisplayHandle + Send + Sync>,
|
||||||
|
Arc<dyn raw_window_handle::HasWindowHandle + Send + Sync>,
|
||||||
|
>,
|
||||||
|
>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "softbuffer")]
|
||||||
|
impl RenderBuffer for SoftbufferRenderBuffer {
|
||||||
|
fn with_buffer(
|
||||||
|
&self,
|
||||||
|
window: &Window,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
render_callback: &mut dyn FnMut(
|
||||||
|
NonZeroU32,
|
||||||
|
NonZeroU32,
|
||||||
|
skia_safe::ColorType,
|
||||||
|
u8,
|
||||||
|
&mut [u8],
|
||||||
|
) -> Result<
|
||||||
|
Option<DirtyRegion>,
|
||||||
|
i_slint_core::platform::PlatformError,
|
||||||
|
>,
|
||||||
|
) -> Result<(), i_slint_core::platform::PlatformError> {
|
||||||
|
let Some((width, height)) = size.width.try_into().ok().zip(size.height.try_into().ok())
|
||||||
|
else {
|
||||||
|
// Nothing to render
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut surface = self.surface.borrow_mut();
|
||||||
|
|
||||||
|
surface
|
||||||
|
.resize(width, height)
|
||||||
|
.map_err(|e| format!("Error resizing softbuffer surface: {e}"))?;
|
||||||
|
|
||||||
|
let mut target_buffer = surface
|
||||||
|
.buffer_mut()
|
||||||
|
.map_err(|e| format!("Error retrieving softbuffer rendering buffer: {e}"))?;
|
||||||
|
|
||||||
|
let dirty_region = render_callback(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
skia_safe::ColorType::BGRA8888,
|
||||||
|
target_buffer.age(),
|
||||||
|
bytemuck::cast_slice_mut(target_buffer.as_mut()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if let Some(dirty_region) = dirty_region {
|
||||||
|
let scale_factor = i_slint_core::lengths::ScaleFactor::new(window.scale_factor());
|
||||||
|
|
||||||
|
let damage_rects = dirty_region
|
||||||
|
.iter()
|
||||||
|
.map(|logical| {
|
||||||
|
let physical_rect = (logical.to_rect() * scale_factor).round_out();
|
||||||
|
softbuffer::Rect {
|
||||||
|
x: physical_rect.min_x().ceil() as _,
|
||||||
|
y: physical_rect.min_y().ceil() as _,
|
||||||
|
width: ((physical_rect.width() as i32).max(1) as u32).try_into().unwrap(),
|
||||||
|
height: ((physical_rect.height() as i32).max(1) as u32).try_into().unwrap(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
target_buffer.present_with_damage(&damage_rects)
|
||||||
|
} else {
|
||||||
|
target_buffer.present()
|
||||||
|
}
|
||||||
|
.map_err(|e| format!("Error presenting softbuffer buffer after skia rendering: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This surface renders into the given window using Skia's software rasterize.
|
||||||
|
pub struct SoftwareSurface {
|
||||||
|
render_buffer: Box<dyn RenderBuffer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::Surface for SoftwareSurface {
|
||||||
|
#[cfg(feature = "softbuffer")]
|
||||||
|
fn new(
|
||||||
|
_shared_context: &SkiaSharedContext,
|
||||||
|
window_handle: Arc<dyn raw_window_handle::HasWindowHandle + Send + Sync>,
|
||||||
|
display_handle: Arc<dyn raw_window_handle::HasDisplayHandle + Send + Sync>,
|
||||||
|
_size: PhysicalWindowSize,
|
||||||
|
_requested_graphics_api: Option<RequestedGraphicsAPI>,
|
||||||
|
) -> Result<Self, i_slint_core::platform::PlatformError> {
|
||||||
|
let _context = softbuffer::Context::new(display_handle)
|
||||||
|
.map_err(|e| format!("Error creating softbuffer context: {e}"))?;
|
||||||
|
|
||||||
|
let surface =
|
||||||
|
softbuffer::Surface::new(&_context, window_handle).map_err(|softbuffer_error| {
|
||||||
|
format!("Error creating softbuffer surface: {softbuffer_error}")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let surface_access =
|
||||||
|
Box::new(SoftbufferRenderBuffer { _context, surface: RefCell::new(surface) });
|
||||||
|
|
||||||
|
Ok(Self { render_buffer: surface_access })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "softbuffer"))]
|
||||||
|
fn new(
|
||||||
|
_shared_context: &SkiaSharedContext,
|
||||||
|
_window_handle: Arc<dyn raw_window_handle::HasWindowHandle + Send + Sync>,
|
||||||
|
_display_handle: Arc<dyn raw_window_handle::HasDisplayHandle + Send + Sync>,
|
||||||
|
_size: PhysicalWindowSize,
|
||||||
|
_requested_graphics_api: Option<RequestedGraphicsAPI>,
|
||||||
|
) -> Result<Self, i_slint_core::platform::PlatformError> {
|
||||||
|
struct DummyBuffer;
|
||||||
|
impl RenderBuffer for DummyBuffer {
|
||||||
|
fn with_buffer(
|
||||||
|
&self,
|
||||||
|
_window: &Window,
|
||||||
|
_size: PhysicalWindowSize,
|
||||||
|
_render_callback: &mut dyn FnMut(
|
||||||
|
std::num::NonZeroU32,
|
||||||
|
std::num::NonZeroU32,
|
||||||
|
skia_safe::ColorType,
|
||||||
|
u8,
|
||||||
|
&mut [u8],
|
||||||
|
) -> Result<
|
||||||
|
Option<DirtyRegion>,
|
||||||
|
i_slint_core::platform::PlatformError,
|
||||||
|
>,
|
||||||
|
) -> Result<(), i_slint_core::platform::PlatformError> {
|
||||||
|
Err("Slint's Skia renderer compiled without the 'softbuffer' feature cannot render into a window".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(DummyBuffer.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"software"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_event(
|
||||||
|
&self,
|
||||||
|
_size: PhysicalWindowSize,
|
||||||
|
) -> Result<(), i_slint_core::platform::PlatformError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
&self,
|
||||||
|
window: &Window,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
callback: &dyn Fn(
|
||||||
|
&skia_safe::Canvas,
|
||||||
|
Option<&mut skia_safe::gpu::DirectContext>,
|
||||||
|
u8,
|
||||||
|
) -> Option<DirtyRegion>,
|
||||||
|
pre_present_callback: &RefCell<Option<Box<dyn FnMut()>>>,
|
||||||
|
) -> Result<DrawOutcome, i_slint_core::platform::PlatformError> {
|
||||||
|
self.render_buffer.with_buffer(
|
||||||
|
window,
|
||||||
|
size,
|
||||||
|
&mut |width, height, pixel_format, age, pixels| {
|
||||||
|
let mut surface_borrow = skia_safe::surfaces::wrap_pixels(
|
||||||
|
&skia_safe::ImageInfo::new(
|
||||||
|
(width.get() as i32, height.get() as i32),
|
||||||
|
pixel_format,
|
||||||
|
skia_safe::AlphaType::Opaque,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
pixels,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
"Error wrapping target buffer for rendering into with Skia".to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let dirty_region = callback(surface_borrow.canvas(), None, age);
|
||||||
|
|
||||||
|
if let Some(pre_present_callback) = pre_present_callback.borrow_mut().as_mut() {
|
||||||
|
pre_present_callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(dirty_region)
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(DrawOutcome::Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bits_per_pixel(&self) -> Result<u8, i_slint_core::platform::PlatformError> {
|
||||||
|
Ok(24)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn use_partial_rendering(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: RenderBuffer + 'static> From<T> for SoftwareSurface {
|
||||||
|
fn from(render_buffer: T) -> Self {
|
||||||
|
Self { render_buffer: Box::new(render_buffer) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: RenderBuffer + 'static> RenderBuffer for Rc<T> {
|
||||||
|
fn with_buffer(
|
||||||
|
&self,
|
||||||
|
window: &Window,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
render_callback: &mut dyn FnMut(
|
||||||
|
NonZeroU32,
|
||||||
|
NonZeroU32,
|
||||||
|
skia_safe::ColorType,
|
||||||
|
u8,
|
||||||
|
&mut [u8],
|
||||||
|
) -> Result<
|
||||||
|
Option<DirtyRegion>,
|
||||||
|
i_slint_core::platform::PlatformError,
|
||||||
|
>,
|
||||||
|
) -> Result<(), i_slint_core::platform::PlatformError> {
|
||||||
|
self.as_ref().with_buffer(window, size, render_callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
// Copyright © SixtyFPS GmbH <info@slint.dev>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
|
||||||
|
|
||||||
|
// cSpell: ignore madsmtm
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use i_slint_core::api::{PhysicalSize as PhysicalWindowSize, Window};
|
||||||
|
use i_slint_core::graphics::RequestedGraphicsAPI;
|
||||||
|
use i_slint_core::partial_renderer::DirtyRegion;
|
||||||
|
use i_slint_core::renderer::DrawOutcome;
|
||||||
|
|
||||||
|
use vulkano::device::physical::{PhysicalDevice, PhysicalDeviceType};
|
||||||
|
use vulkano::device::{
|
||||||
|
Device, DeviceCreateInfo, DeviceExtensions, Queue, QueueCreateInfo, QueueFlags,
|
||||||
|
};
|
||||||
|
use vulkano::image::view::ImageView;
|
||||||
|
use vulkano::image::{Image, ImageUsage};
|
||||||
|
use vulkano::instance::{Instance, InstanceCreateFlags, InstanceCreateInfo, InstanceExtensions};
|
||||||
|
use vulkano::swapchain::{Surface, Swapchain, SwapchainCreateInfo, SwapchainPresentInfo};
|
||||||
|
use vulkano::sync::GpuFuture;
|
||||||
|
use vulkano::{Handle, Validated, VulkanError, VulkanLibrary, VulkanObject, sync};
|
||||||
|
|
||||||
|
use crate::SkiaSharedContext;
|
||||||
|
|
||||||
|
pub struct SharedVulkanContext {
|
||||||
|
instance: Arc<Instance>,
|
||||||
|
// TODO: share also physical/logical device and queue, but their selection process is surface compatibility dependent.
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::SkiaSharedContextInner {
|
||||||
|
fn shared_vulkan_context(
|
||||||
|
&self,
|
||||||
|
) -> Result<&SharedVulkanContext, i_slint_core::platform::PlatformError> {
|
||||||
|
if let Some(ctx) = self.vulkan_context.get() {
|
||||||
|
return Ok(ctx);
|
||||||
|
}
|
||||||
|
self.vulkan_context.set(SharedVulkanContext::new()?).ok();
|
||||||
|
Ok(self.vulkan_context.get().unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedVulkanContext {
|
||||||
|
fn new() -> Result<Self, i_slint_core::platform::PlatformError> {
|
||||||
|
let library = VulkanLibrary::new()
|
||||||
|
.map_err(|load_err| format!("Error loading vulkan library: {load_err}"))?;
|
||||||
|
|
||||||
|
let required_extensions = InstanceExtensions {
|
||||||
|
khr_surface: true,
|
||||||
|
mvk_macos_surface: true,
|
||||||
|
ext_metal_surface: true,
|
||||||
|
khr_wayland_surface: true,
|
||||||
|
khr_xlib_surface: true,
|
||||||
|
khr_xcb_surface: true,
|
||||||
|
khr_win32_surface: true,
|
||||||
|
khr_get_surface_capabilities2: true,
|
||||||
|
khr_get_physical_device_properties2: true,
|
||||||
|
..InstanceExtensions::empty()
|
||||||
|
}
|
||||||
|
.intersection(library.supported_extensions());
|
||||||
|
|
||||||
|
let instance = Instance::new(
|
||||||
|
library.clone(),
|
||||||
|
InstanceCreateInfo {
|
||||||
|
flags: InstanceCreateFlags::ENUMERATE_PORTABILITY,
|
||||||
|
enabled_extensions: required_extensions,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|instance_err| format!("Error creating Vulkan instance: {instance_err}"))?;
|
||||||
|
Ok(Self { instance })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This surface renders into the given window using Vulkan.
|
||||||
|
pub struct VulkanSurface {
|
||||||
|
gr_context: RefCell<skia_safe::gpu::DirectContext>,
|
||||||
|
recreate_swapchain: Cell<bool>,
|
||||||
|
device: Arc<Device>,
|
||||||
|
previous_frame_end: RefCell<Option<Box<dyn GpuFuture>>>,
|
||||||
|
queue: Arc<Queue>,
|
||||||
|
swapchain: RefCell<Arc<Swapchain>>,
|
||||||
|
swapchain_images: RefCell<Vec<Arc<Image>>>,
|
||||||
|
swapchain_image_views: RefCell<Vec<Arc<ImageView>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VulkanSurface {
|
||||||
|
/// Creates a Skia Vulkan rendering surface from the given Vulkano device, queue family index, surface,
|
||||||
|
/// and size.
|
||||||
|
pub fn from_surface(
|
||||||
|
physical_device: Arc<PhysicalDevice>,
|
||||||
|
queue_family_index: u32,
|
||||||
|
surface: Arc<Surface>,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
) -> Result<Self, i_slint_core::platform::PlatformError> {
|
||||||
|
/*
|
||||||
|
eprintln!(
|
||||||
|
"Vulkan device: {} (type: {:?})",
|
||||||
|
physical_device.properties().device_name,
|
||||||
|
physical_device.properties().device_type,
|
||||||
|
);*/
|
||||||
|
|
||||||
|
let (device, mut queues) = Device::new(
|
||||||
|
physical_device.clone(),
|
||||||
|
DeviceCreateInfo {
|
||||||
|
enabled_extensions: DeviceExtensions {
|
||||||
|
khr_swapchain: true,
|
||||||
|
..DeviceExtensions::empty()
|
||||||
|
},
|
||||||
|
queue_create_infos: vec![QueueCreateInfo {
|
||||||
|
queue_family_index,
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|dev_err| format!("Failed to create suitable logical Vulkan device: {dev_err}"))?;
|
||||||
|
let queue = queues.next().ok_or_else(|| "Not Vulkan device queue found".to_string())?;
|
||||||
|
|
||||||
|
let (swapchain, swapchain_images) = {
|
||||||
|
let surface_capabilities = device
|
||||||
|
.physical_device()
|
||||||
|
.surface_capabilities(&surface, Default::default())
|
||||||
|
.map_err(|vke| format!("Error matching Vulkan surface capabilities: {vke}"))?;
|
||||||
|
let image_format = vulkano::format::Format::B8G8R8A8_UNORM;
|
||||||
|
|
||||||
|
Swapchain::new(
|
||||||
|
device.clone(),
|
||||||
|
surface.clone(),
|
||||||
|
SwapchainCreateInfo {
|
||||||
|
min_image_count: surface_capabilities.min_image_count,
|
||||||
|
image_format,
|
||||||
|
image_extent: [size.width, size.height],
|
||||||
|
image_usage: ImageUsage::COLOR_ATTACHMENT,
|
||||||
|
composite_alpha: surface_capabilities
|
||||||
|
.supported_composite_alpha
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
"fatal: Vulkan surface capabilities missing composite alpha descriptor"
|
||||||
|
.to_string()
|
||||||
|
})?,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|vke| format!("Error creating Vulkan swapchain: {vke}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut swapchain_image_views = Vec::with_capacity(swapchain_images.len());
|
||||||
|
|
||||||
|
for image in &swapchain_images {
|
||||||
|
swapchain_image_views.push(ImageView::new_default(image.clone()).map_err(|vke| {
|
||||||
|
format!("fatal: Error creating image view for swap chain image: {vke}")
|
||||||
|
})?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let instance = physical_device.instance();
|
||||||
|
let library = instance.library();
|
||||||
|
|
||||||
|
let get_proc = |of| unsafe {
|
||||||
|
let result = match of {
|
||||||
|
skia_safe::gpu::vk::GetProcOf::Instance(instance, name) => {
|
||||||
|
library.get_instance_proc_addr(ash::vk::Instance::from_raw(instance as _), name)
|
||||||
|
}
|
||||||
|
skia_safe::gpu::vk::GetProcOf::Device(device, name) => {
|
||||||
|
(instance.fns().v1_0.get_device_proc_addr)(
|
||||||
|
ash::vk::Device::from_raw(device as _),
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Some(f) => f as _,
|
||||||
|
None => {
|
||||||
|
//println!("resolve of {} failed", of.name().to_str().unwrap());
|
||||||
|
core::ptr::null()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cap the Vulkan API version Skia uses. Skia otherwise assumes the highest version reported
|
||||||
|
// by the physical device and tries to load functions and request features for it. That fails
|
||||||
|
// on drivers where the logical device we created only negotiated a lower version, so limit it
|
||||||
|
// to the version vulkano negotiated for this physical device under our instance.
|
||||||
|
let api_version = physical_device.api_version();
|
||||||
|
let max_api_version = skia_safe::gpu::vk::Version::new(
|
||||||
|
api_version.major as _,
|
||||||
|
api_version.minor as _,
|
||||||
|
api_version.patch as _,
|
||||||
|
);
|
||||||
|
|
||||||
|
let backend_context = unsafe {
|
||||||
|
skia_safe::gpu::vk::BackendContext::new_builder(
|
||||||
|
instance.handle().as_raw() as _,
|
||||||
|
physical_device.handle().as_raw() as _,
|
||||||
|
device.handle().as_raw() as _,
|
||||||
|
(queue.handle().as_raw() as _, queue.queue_index() as _),
|
||||||
|
&get_proc,
|
||||||
|
Some(max_api_version),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
};
|
||||||
|
|
||||||
|
let gr_context = skia_safe::gpu::direct_contexts::make_vulkan(&backend_context, None)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Error creating Skia Vulkan context (max api version {}.{}.{})",
|
||||||
|
api_version.major, api_version.minor, api_version.patch
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let previous_frame_end = RefCell::new(Some(sync::now(device.clone()).boxed()));
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
gr_context: RefCell::new(gr_context),
|
||||||
|
recreate_swapchain: Cell::new(false),
|
||||||
|
device,
|
||||||
|
previous_frame_end,
|
||||||
|
queue,
|
||||||
|
swapchain: RefCell::new(swapchain),
|
||||||
|
swapchain_images: RefCell::new(swapchain_images),
|
||||||
|
swapchain_image_views: RefCell::new(swapchain_image_views),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a clone of the shared swapchain.
|
||||||
|
pub fn swapchain(&self) -> Arc<Swapchain> {
|
||||||
|
self.swapchain.borrow().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::Surface for VulkanSurface {
|
||||||
|
fn new(
|
||||||
|
shared_context: &SkiaSharedContext,
|
||||||
|
window_handle: Arc<dyn raw_window_handle::HasWindowHandle + Send + Sync>,
|
||||||
|
display_handle: Arc<dyn raw_window_handle::HasDisplayHandle + Send + Sync>,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
requested_graphics_api: Option<RequestedGraphicsAPI>,
|
||||||
|
) -> Result<Self, i_slint_core::platform::PlatformError> {
|
||||||
|
if requested_graphics_api.is_some_and(|api| !matches!(api, RequestedGraphicsAPI::Vulkan)) {
|
||||||
|
return Err("Requested non-Vulkan rendering with Vulkan renderer".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let instance = shared_context.0.shared_vulkan_context()?.instance.clone();
|
||||||
|
|
||||||
|
let window_handle = window_handle
|
||||||
|
.window_handle()
|
||||||
|
.map_err(|e| format!("error obtaining window handle for skia vulkan renderer: {e}"))?;
|
||||||
|
let display_handle = display_handle
|
||||||
|
.display_handle()
|
||||||
|
.map_err(|e| format!("error obtaining display handle for skia vulkan renderer: {e}"))?;
|
||||||
|
|
||||||
|
let surface = create_surface(&instance, window_handle, display_handle)
|
||||||
|
.map_err(|surface_err| format!("Error creating Vulkan surface: {surface_err}"))?;
|
||||||
|
|
||||||
|
let device_extensions =
|
||||||
|
DeviceExtensions { khr_swapchain: true, ..DeviceExtensions::empty() };
|
||||||
|
let (physical_device, queue_family_index) = instance
|
||||||
|
.enumerate_physical_devices()
|
||||||
|
.map_err(|vke| format!("Error enumerating physical Vulkan devices: {vke}"))?
|
||||||
|
.filter(|p| p.supported_extensions().contains(&device_extensions))
|
||||||
|
.filter_map(|p| {
|
||||||
|
p.queue_family_properties()
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.position(|(i, q)| {
|
||||||
|
q.queue_flags.intersects(QueueFlags::GRAPHICS)
|
||||||
|
&& p.surface_support(i as u32, &surface).unwrap_or(false)
|
||||||
|
})
|
||||||
|
.map(|i| (p, i as u32))
|
||||||
|
})
|
||||||
|
.min_by_key(|(p, _)| match p.properties().device_type {
|
||||||
|
PhysicalDeviceType::DiscreteGpu => 0,
|
||||||
|
PhysicalDeviceType::IntegratedGpu => 1,
|
||||||
|
PhysicalDeviceType::VirtualGpu => 2,
|
||||||
|
PhysicalDeviceType::Cpu => 3,
|
||||||
|
PhysicalDeviceType::Other => 4,
|
||||||
|
_ => 5,
|
||||||
|
})
|
||||||
|
.ok_or_else(|| "Vulkan: Failed to find suitable physical device".to_string())?;
|
||||||
|
|
||||||
|
Self::from_surface(physical_device, queue_family_index, surface, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"vulkan"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_event(
|
||||||
|
&self,
|
||||||
|
_size: PhysicalWindowSize,
|
||||||
|
) -> Result<(), i_slint_core::platform::PlatformError> {
|
||||||
|
self.recreate_swapchain.set(true);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
&self,
|
||||||
|
_window: &Window,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
callback: &dyn Fn(
|
||||||
|
&skia_safe::Canvas,
|
||||||
|
Option<&mut skia_safe::gpu::DirectContext>,
|
||||||
|
u8,
|
||||||
|
) -> Option<DirtyRegion>,
|
||||||
|
pre_present_callback: &RefCell<Option<Box<dyn FnMut()>>>,
|
||||||
|
) -> Result<DrawOutcome, i_slint_core::platform::PlatformError> {
|
||||||
|
let gr_context = &mut self.gr_context.borrow_mut();
|
||||||
|
|
||||||
|
let device = self.device.clone();
|
||||||
|
|
||||||
|
self.previous_frame_end.borrow_mut().as_mut().unwrap().cleanup_finished();
|
||||||
|
|
||||||
|
if self.recreate_swapchain.take() {
|
||||||
|
let mut swapchain = self.swapchain.borrow_mut();
|
||||||
|
let (new_swapchain, new_images) = swapchain
|
||||||
|
.recreate(SwapchainCreateInfo {
|
||||||
|
image_extent: [size.width, size.height],
|
||||||
|
..swapchain.create_info()
|
||||||
|
})
|
||||||
|
.map_err(|vke| format!("Error re-creating Vulkan swap chain: {vke}"))?;
|
||||||
|
|
||||||
|
*swapchain = new_swapchain;
|
||||||
|
|
||||||
|
let mut new_swapchain_image_views = Vec::with_capacity(new_images.len());
|
||||||
|
|
||||||
|
for image in &new_images {
|
||||||
|
new_swapchain_image_views.push(ImageView::new_default(image.clone()).map_err(
|
||||||
|
|vke| format!("fatal: Error creating image view for swap chain image: {vke}"),
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
*self.swapchain_images.borrow_mut() = new_images;
|
||||||
|
*self.swapchain_image_views.borrow_mut() = new_swapchain_image_views;
|
||||||
|
}
|
||||||
|
|
||||||
|
let swapchain = self.swapchain.borrow().clone();
|
||||||
|
|
||||||
|
#[cfg_attr(slint_nightly_test, allow(non_exhaustive_omitted_patterns))]
|
||||||
|
let (image_index, suboptimal, acquire_future) =
|
||||||
|
match vulkano::swapchain::acquire_next_image(swapchain.clone(), None)
|
||||||
|
.map_err(Validated::unwrap)
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(VulkanError::OutOfDate) => {
|
||||||
|
self.recreate_swapchain.set(true);
|
||||||
|
return Ok(DrawOutcome::Occluded); // Try again next frame
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("Vulkan: failed to acquire next image: {e}").into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if suboptimal {
|
||||||
|
self.recreate_swapchain.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = swapchain.image_extent()[0];
|
||||||
|
let width: i32 = width
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| format!("internal error: invalid swapchain image width {width}"))?;
|
||||||
|
let height = swapchain.image_extent()[1];
|
||||||
|
let height: i32 = height
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| format!("internal error: invalid swapchain image height {height}"))?;
|
||||||
|
|
||||||
|
let image_view = self.swapchain_image_views.borrow()[image_index as usize].clone();
|
||||||
|
let image_object = image_view.image();
|
||||||
|
|
||||||
|
let format = image_view.format();
|
||||||
|
|
||||||
|
debug_assert_eq!(format, vulkano::format::Format::B8G8R8A8_UNORM);
|
||||||
|
let (vk_format, color_type) =
|
||||||
|
(skia_safe::gpu::vk::Format::B8G8R8A8_UNORM, skia_safe::ColorType::BGRA8888);
|
||||||
|
|
||||||
|
let alloc = skia_safe::gpu::vk::Alloc::default();
|
||||||
|
let image_info = &unsafe {
|
||||||
|
skia_safe::gpu::vk::ImageInfo::new(
|
||||||
|
image_object.handle().as_raw() as _,
|
||||||
|
alloc,
|
||||||
|
skia_safe::gpu::vk::ImageTiling::OPTIMAL,
|
||||||
|
skia_safe::gpu::vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL,
|
||||||
|
vk_format,
|
||||||
|
1,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let render_target =
|
||||||
|
&skia_safe::gpu::backend_render_targets::make_vk((width, height), image_info);
|
||||||
|
|
||||||
|
let mut skia_surface = skia_safe::gpu::surfaces::wrap_backend_render_target(
|
||||||
|
gr_context,
|
||||||
|
render_target,
|
||||||
|
skia_safe::gpu::SurfaceOrigin::TopLeft,
|
||||||
|
color_type,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.ok_or_else(|| "Error creating Skia Vulkan surface".to_string())?;
|
||||||
|
|
||||||
|
callback(skia_surface.canvas(), Some(gr_context), 0);
|
||||||
|
|
||||||
|
drop(skia_surface);
|
||||||
|
|
||||||
|
gr_context.submit(None);
|
||||||
|
|
||||||
|
if let Some(pre_present_callback) = pre_present_callback.borrow_mut().as_mut() {
|
||||||
|
pre_present_callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
let future = self
|
||||||
|
.previous_frame_end
|
||||||
|
.borrow_mut()
|
||||||
|
.take()
|
||||||
|
.unwrap()
|
||||||
|
.join(acquire_future)
|
||||||
|
.then_swapchain_present(
|
||||||
|
self.queue.clone(),
|
||||||
|
SwapchainPresentInfo::swapchain_image_index(swapchain.clone(), image_index),
|
||||||
|
)
|
||||||
|
.then_signal_fence_and_flush();
|
||||||
|
|
||||||
|
#[cfg_attr(slint_nightly_test, allow(non_exhaustive_omitted_patterns))]
|
||||||
|
match future.map_err(Validated::unwrap) {
|
||||||
|
Ok(future) => {
|
||||||
|
*self.previous_frame_end.borrow_mut() = Some(future.boxed());
|
||||||
|
}
|
||||||
|
Err(VulkanError::OutOfDate) => {
|
||||||
|
self.recreate_swapchain.set(true);
|
||||||
|
*self.previous_frame_end.borrow_mut() = Some(sync::now(device.clone()).boxed());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
*self.previous_frame_end.borrow_mut() = Some(sync::now(device.clone()).boxed());
|
||||||
|
return Err(format!("Skia Vulkan renderer: failed to flush future: {e}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DrawOutcome::Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bits_per_pixel(&self) -> Result<u8, i_slint_core::platform::PlatformError> {
|
||||||
|
#[cfg_attr(slint_nightly_test, allow(non_exhaustive_omitted_patterns))]
|
||||||
|
Ok(match self.swapchain.borrow().image_format() {
|
||||||
|
vulkano::format::Format::B8G8R8A8_UNORM => 32,
|
||||||
|
fmt => {
|
||||||
|
return Err(format!(
|
||||||
|
"Skia Vulkan Renderer: Unsupported swapchain image format found {fmt:?}"
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any(&self) -> &dyn core::any::Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME(madsmtm): Why are we doing this instead of using `Surface::from_window`?
|
||||||
|
fn create_surface(
|
||||||
|
instance: &Arc<Instance>,
|
||||||
|
window_handle: raw_window_handle::WindowHandle<'_>,
|
||||||
|
display_handle: raw_window_handle::DisplayHandle<'_>,
|
||||||
|
) -> Result<Arc<Surface>, vulkano::Validated<vulkano::VulkanError>> {
|
||||||
|
#[cfg_attr(slint_nightly_test, allow(non_exhaustive_omitted_patterns))]
|
||||||
|
match (window_handle.as_raw(), display_handle.as_raw()) {
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
(raw_window_handle::RawWindowHandle::AppKit(handle), _) => unsafe {
|
||||||
|
let layer = raw_window_metal::Layer::from_ns_view(handle.ns_view);
|
||||||
|
Surface::from_metal(instance.clone(), layer.as_ptr().as_ptr(), None)
|
||||||
|
},
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
(raw_window_handle::RawWindowHandle::UiKit(handle), _) => unsafe {
|
||||||
|
let layer = raw_window_metal::Layer::from_ui_view(handle.ui_view);
|
||||||
|
Surface::from_metal(instance.clone(), layer.as_ptr().as_ptr(), None)
|
||||||
|
},
|
||||||
|
(
|
||||||
|
raw_window_handle::RawWindowHandle::Xlib(raw_window_handle::XlibWindowHandle {
|
||||||
|
window,
|
||||||
|
..
|
||||||
|
}),
|
||||||
|
raw_window_handle::RawDisplayHandle::Xlib(display),
|
||||||
|
) => unsafe {
|
||||||
|
Surface::from_xlib(instance.clone(), display.display.unwrap().as_ptr(), window, None)
|
||||||
|
},
|
||||||
|
(
|
||||||
|
raw_window_handle::RawWindowHandle::Xcb(raw_window_handle::XcbWindowHandle {
|
||||||
|
window,
|
||||||
|
..
|
||||||
|
}),
|
||||||
|
raw_window_handle::RawDisplayHandle::Xcb(raw_window_handle::XcbDisplayHandle {
|
||||||
|
connection,
|
||||||
|
..
|
||||||
|
}),
|
||||||
|
) => unsafe {
|
||||||
|
Surface::from_xcb(instance.clone(), connection.unwrap().as_ptr(), window.get(), None)
|
||||||
|
},
|
||||||
|
(
|
||||||
|
raw_window_handle::RawWindowHandle::Wayland(raw_window_handle::WaylandWindowHandle {
|
||||||
|
surface,
|
||||||
|
..
|
||||||
|
}),
|
||||||
|
raw_window_handle::RawDisplayHandle::Wayland(raw_window_handle::WaylandDisplayHandle {
|
||||||
|
display,
|
||||||
|
..
|
||||||
|
}),
|
||||||
|
) => unsafe {
|
||||||
|
Surface::from_wayland(instance.clone(), display.as_ptr(), surface.as_ptr(), None)
|
||||||
|
},
|
||||||
|
(
|
||||||
|
raw_window_handle::RawWindowHandle::Win32(raw_window_handle::Win32WindowHandle {
|
||||||
|
hwnd,
|
||||||
|
hinstance,
|
||||||
|
..
|
||||||
|
}),
|
||||||
|
_,
|
||||||
|
) => unsafe {
|
||||||
|
Surface::from_win32(instance.clone(), hinstance.unwrap().get(), hwnd.get(), None)
|
||||||
|
},
|
||||||
|
_ => unimplemented!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
// Copyright © SixtyFPS GmbH <info@slint.dev>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
|
||||||
|
|
||||||
|
#[cfg(feature = "unstable-wgpu-28")]
|
||||||
|
use i_slint_core::api::GraphicsAPI;
|
||||||
|
use i_slint_core::api::{PhysicalSize as PhysicalWindowSize, Window};
|
||||||
|
use i_slint_core::graphics::RequestedGraphicsAPI;
|
||||||
|
use i_slint_core::partial_renderer::DirtyRegion;
|
||||||
|
use i_slint_core::platform::PlatformError;
|
||||||
|
use i_slint_core::renderer::DrawOutcome;
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use wgpu_28 as wgpu;
|
||||||
|
|
||||||
|
use crate::SkiaSharedContext;
|
||||||
|
|
||||||
|
#[cfg(target_family = "windows")]
|
||||||
|
mod dx12;
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
mod metal;
|
||||||
|
#[cfg(all(target_family = "unix", not(target_vendor = "apple")))]
|
||||||
|
mod vulkan;
|
||||||
|
|
||||||
|
/// Skia rendering surface backed by WGPU. Supports both on-screen rendering (with a
|
||||||
|
/// window surface) and offscreen rendering into caller-provided textures.
|
||||||
|
pub struct WGPUSurface {
|
||||||
|
pub(crate) gr_context: RefCell<skia_safe::gpu::DirectContext>,
|
||||||
|
instance: wgpu::Instance,
|
||||||
|
device: wgpu::Device,
|
||||||
|
queue: wgpu::Queue,
|
||||||
|
surface_config: RefCell<Option<wgpu::SurfaceConfiguration>>,
|
||||||
|
surface: Option<wgpu::Surface<'static>>,
|
||||||
|
textures_to_transition_for_sampling: RefCell<Vec<wgpu::Texture>>,
|
||||||
|
pub(crate) backend: Backend,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WGPUSurface {
|
||||||
|
pub fn new_with_surface(
|
||||||
|
surface_target: impl Into<i_slint_core::graphics::wgpu_28::SurfaceTarget>,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
requested_graphics_api: Option<RequestedGraphicsAPI>,
|
||||||
|
) -> Result<Self, PlatformError> {
|
||||||
|
let (instance, adapter, device, queue, surface) =
|
||||||
|
i_slint_core::graphics::wgpu_28::init_instance_adapter_device_queue_surface(
|
||||||
|
surface_target,
|
||||||
|
requested_graphics_api,
|
||||||
|
wgpu::Backends::GL /* we're not mapping that to skia because we can't save/restore state */
|
||||||
|
.union(if cfg!(target_os = "windows") {
|
||||||
|
wgpu::Backends::VULKAN
|
||||||
|
} else {
|
||||||
|
wgpu::Backends::empty()
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut surface_config =
|
||||||
|
surface.get_default_config(&adapter, size.width, size.height).unwrap();
|
||||||
|
|
||||||
|
let swapchain_capabilities = surface.get_capabilities(&adapter);
|
||||||
|
let swapchain_format = swapchain_capabilities
|
||||||
|
.formats
|
||||||
|
.iter()
|
||||||
|
.find(|f| {
|
||||||
|
matches!(f, wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Bgra8Unorm)
|
||||||
|
})
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_else(|| swapchain_capabilities.formats[0]);
|
||||||
|
surface_config.format = swapchain_format;
|
||||||
|
surface.configure(&device, &surface_config);
|
||||||
|
|
||||||
|
let backend: Backend = adapter.get_info().backend.try_into()?;
|
||||||
|
|
||||||
|
let gr_context = backend.make_context(&adapter, &device, &queue);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
gr_context: RefCell::new(
|
||||||
|
gr_context.ok_or_else(|| {
|
||||||
|
PlatformError::from("Failed to create Skia context from WGPU")
|
||||||
|
})?,
|
||||||
|
),
|
||||||
|
instance,
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
surface_config: Some(surface_config).into(),
|
||||||
|
surface: Some(surface),
|
||||||
|
textures_to_transition_for_sampling: RefCell::new(Vec::new()),
|
||||||
|
backend,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only used by SkiaWGPURenderer, which is gated on wgpu-29 — the wgpu-28
|
||||||
|
// path doesn't currently expose an offscreen renderer publicly.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn new_offscreen(
|
||||||
|
instance: wgpu::Instance,
|
||||||
|
device: wgpu::Device,
|
||||||
|
queue: wgpu::Queue,
|
||||||
|
backend: Backend,
|
||||||
|
gr_context: skia_safe::gpu::DirectContext,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
gr_context: RefCell::new(gr_context),
|
||||||
|
instance,
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
surface_config: None.into(),
|
||||||
|
surface: None,
|
||||||
|
textures_to_transition_for_sampling: RefCell::new(Vec::new()),
|
||||||
|
backend,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transitions any imported wgpu textures to sampling state and flushes
|
||||||
|
/// the Skia graphics context. Must be called after rendering to ensure
|
||||||
|
/// Skia's GPU work is submitted.
|
||||||
|
pub(crate) fn flush_and_submit(&self, gr_context: &mut skia_safe::gpu::DirectContext) {
|
||||||
|
let textures_to_transition = self.textures_to_transition_for_sampling.take();
|
||||||
|
if !textures_to_transition.is_empty() {
|
||||||
|
let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("Skia texture transition encoder"),
|
||||||
|
});
|
||||||
|
encoder.transition_resources(
|
||||||
|
std::iter::empty(),
|
||||||
|
textures_to_transition.iter().map(|texture| wgpu::TextureTransition {
|
||||||
|
texture,
|
||||||
|
selector: None,
|
||||||
|
state: wgpu::TextureUses::RESOURCE,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
self.queue.submit(Some(encoder.finish()));
|
||||||
|
}
|
||||||
|
|
||||||
|
gr_context.submit(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::Surface for WGPUSurface {
|
||||||
|
fn new(
|
||||||
|
_shared_context: &SkiaSharedContext,
|
||||||
|
window_handle: Arc<dyn raw_window_handle::HasWindowHandle + Send + Sync>,
|
||||||
|
display_handle: Arc<dyn raw_window_handle::HasDisplayHandle + Send + Sync>,
|
||||||
|
size: PhysicalWindowSize,
|
||||||
|
requested_graphics_api: Option<RequestedGraphicsAPI>,
|
||||||
|
) -> Result<Self, PlatformError> {
|
||||||
|
Self::new_with_surface(
|
||||||
|
Box::new(WindowAndDisplayHandle(window_handle, display_handle))
|
||||||
|
as Box<dyn wgpu::WindowHandle + 'static>,
|
||||||
|
size,
|
||||||
|
requested_graphics_api,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
if self.surface.is_some() { "wgpu" } else { "wgpu-texture" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_event(&self, size: PhysicalWindowSize) -> Result<(), PlatformError> {
|
||||||
|
let mut surface_config_opt = self.surface_config.borrow_mut();
|
||||||
|
let (Some(surface_config), Some(surface)) = (surface_config_opt.as_mut(), &self.surface)
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skip reconfigure if size hasn't changed — DRM/KMS surfaces don't
|
||||||
|
// support being reconfigured.
|
||||||
|
if surface_config.width == size.width && surface_config.height == size.height {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let gr_context = &mut self.gr_context.borrow_mut();
|
||||||
|
// This is brute force, but for the lack of access to the fences this seems to work: Avoid any pending work so that
|
||||||
|
// IDXGISwapChain::ResizeBuffers doesn't complain that the surface is still in use.
|
||||||
|
gr_context.flush_submit_and_sync_cpu();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer FIFO modes over possible Mailbox setting for frame pacing and better energy efficiency.
|
||||||
|
surface_config.present_mode = wgpu::PresentMode::AutoVsync;
|
||||||
|
surface_config.width = size.width;
|
||||||
|
surface_config.height = size.height;
|
||||||
|
|
||||||
|
surface.configure(&self.device, surface_config);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
&self,
|
||||||
|
_window: &Window,
|
||||||
|
_size: PhysicalWindowSize,
|
||||||
|
callback: &dyn Fn(
|
||||||
|
&skia_safe::Canvas,
|
||||||
|
Option<&mut skia_safe::gpu::DirectContext>,
|
||||||
|
u8,
|
||||||
|
) -> Option<DirtyRegion>,
|
||||||
|
pre_present_callback: &RefCell<Option<Box<dyn FnMut()>>>,
|
||||||
|
) -> Result<DrawOutcome, PlatformError> {
|
||||||
|
let (Some(surface), Some(surface_config)) = (&self.surface, &*self.surface_config.borrow())
|
||||||
|
else {
|
||||||
|
return Err("WGPUSurface::render() called on offscreen surface".into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let gr_context = &mut self.gr_context.borrow_mut();
|
||||||
|
|
||||||
|
let frame = match surface.get_current_texture() {
|
||||||
|
Ok(texture) => texture,
|
||||||
|
Err(wgpu::SurfaceError::Timeout) => return Ok(DrawOutcome::Timeout),
|
||||||
|
// Outdated or lost: re-configure and try once. If the surface still
|
||||||
|
// doesn't yield a texture, treat it as occluded so the caller re-arms.
|
||||||
|
Err(_) => {
|
||||||
|
surface.configure(&self.device, surface_config);
|
||||||
|
match surface.get_current_texture() {
|
||||||
|
Ok(texture) => texture,
|
||||||
|
Err(_) => return Ok(DrawOutcome::Occluded),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let skia_surface = self.backend.make_surface(gr_context, &frame.texture);
|
||||||
|
|
||||||
|
let mut skia_surface = skia_surface
|
||||||
|
.ok_or_else(|| PlatformError::from("Failed to create Skia surface from WGPU"))?;
|
||||||
|
|
||||||
|
callback(skia_surface.canvas(), Some(gr_context), 0);
|
||||||
|
|
||||||
|
self.flush_and_submit(gr_context);
|
||||||
|
|
||||||
|
if let Some(pre_present_callback) = pre_present_callback.borrow_mut().as_mut() {
|
||||||
|
pre_present_callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.present();
|
||||||
|
|
||||||
|
Ok(DrawOutcome::Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bits_per_pixel(&self) -> Result<u8, PlatformError> {
|
||||||
|
if let Some(surface_config) = &*self.surface_config.borrow() {
|
||||||
|
Ok(match surface_config.format {
|
||||||
|
wgpu_28::TextureFormat::Rgba8Unorm
|
||||||
|
| wgpu_28::TextureFormat::Rgba8UnormSrgb
|
||||||
|
| wgpu_28::TextureFormat::Bgra8Unorm
|
||||||
|
| wgpu_28::TextureFormat::Bgra8UnormSrgb => 32,
|
||||||
|
fmt => return Err(format!("Unsupported surface format {:#?}", fmt).into()),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// All supported render-target formats (Rgba8Unorm, Bgra8Unorm, and sRGB variants) are 32bpp.
|
||||||
|
Ok(32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "unstable-wgpu-28")]
|
||||||
|
fn with_graphics_api(&self, callback: &mut dyn FnMut(GraphicsAPI<'_>)) {
|
||||||
|
let api = i_slint_core::graphics::create_graphics_api_wgpu_28(
|
||||||
|
self.instance.clone(),
|
||||||
|
self.device.clone(),
|
||||||
|
self.queue.clone(),
|
||||||
|
);
|
||||||
|
callback(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(feature = "unstable-wgpu-28", feature = "unstable-wgpu-29"))]
|
||||||
|
fn import_wgpu_texture(
|
||||||
|
&self,
|
||||||
|
canvas: &skia_safe::Canvas,
|
||||||
|
any_wgpu_texture: &i_slint_core::graphics::WGPUTexture,
|
||||||
|
) -> Option<skia_safe::Image> {
|
||||||
|
let texture = match any_wgpu_texture {
|
||||||
|
#[cfg(feature = "unstable-wgpu-28")]
|
||||||
|
i_slint_core::graphics::WGPUTexture::WGPU28Texture(texture) => texture.clone(),
|
||||||
|
#[cfg(feature = "unstable-wgpu-29")]
|
||||||
|
i_slint_core::graphics::WGPUTexture::WGPU29Texture(..) => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skia won't submit commands right away, so remember the texture and transition before
|
||||||
|
// submitting.
|
||||||
|
self.textures_to_transition_for_sampling.borrow_mut().push(texture.clone());
|
||||||
|
|
||||||
|
self.backend.import_texture(canvas, texture)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WindowAndDisplayHandle(
|
||||||
|
Arc<dyn raw_window_handle::HasWindowHandle + Send + Sync>,
|
||||||
|
Arc<dyn raw_window_handle::HasDisplayHandle + Send + Sync>,
|
||||||
|
);
|
||||||
|
|
||||||
|
impl raw_window_handle::HasWindowHandle for WindowAndDisplayHandle {
|
||||||
|
fn window_handle(
|
||||||
|
&self,
|
||||||
|
) -> Result<raw_window_handle::WindowHandle<'_>, raw_window_handle::HandleError> {
|
||||||
|
self.0.window_handle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl raw_window_handle::HasDisplayHandle for WindowAndDisplayHandle {
|
||||||
|
fn display_handle(
|
||||||
|
&self,
|
||||||
|
) -> Result<raw_window_handle::DisplayHandle<'_>, raw_window_handle::HandleError> {
|
||||||
|
self.1.display_handle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum Backend {
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
Metal,
|
||||||
|
#[cfg(target_family = "windows")]
|
||||||
|
Dx12,
|
||||||
|
#[cfg(all(target_family = "unix", not(target_vendor = "apple")))]
|
||||||
|
Vulkan,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<wgpu::Backend> for Backend {
|
||||||
|
type Error = PlatformError;
|
||||||
|
|
||||||
|
fn try_from(wgpu_backend: wgpu::Backend) -> Result<Self, Self::Error> {
|
||||||
|
match wgpu_backend {
|
||||||
|
wgpu_28::Backend::Noop => {
|
||||||
|
Err(PlatformError::from("Cannot use WGPU Noop backend with Skia"))
|
||||||
|
}
|
||||||
|
#[cfg(all(target_family = "unix", not(target_vendor = "apple")))]
|
||||||
|
wgpu_28::Backend::Vulkan => Ok(Self::Vulkan),
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
wgpu_28::Backend::Metal => Ok(Self::Metal),
|
||||||
|
#[cfg(target_family = "windows")]
|
||||||
|
wgpu_28::Backend::Dx12 => Ok(Self::Dx12),
|
||||||
|
other => Err(PlatformError::from(format!(
|
||||||
|
"Unsupported WGPU backend for use with Skia: {}",
|
||||||
|
other
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend {
|
||||||
|
pub(crate) fn make_context(
|
||||||
|
&self,
|
||||||
|
_adapter: &wgpu::Adapter,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
) -> Option<skia_safe::gpu::DirectContext> {
|
||||||
|
match self {
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
Self::Metal => metal::make_metal_context(device, queue),
|
||||||
|
#[cfg(target_family = "windows")]
|
||||||
|
Self::Dx12 => unsafe { dx12::make_dx12_context(&_adapter, &device, &queue) },
|
||||||
|
#[cfg(all(target_family = "unix", not(target_vendor = "apple")))]
|
||||||
|
Self::Vulkan => unsafe { vulkan::make_vulkan_context(device, queue) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn make_surface(
|
||||||
|
&self,
|
||||||
|
gr_context: &mut skia_safe::gpu::DirectContext,
|
||||||
|
texture: &wgpu::Texture,
|
||||||
|
) -> Option<skia_safe::Surface> {
|
||||||
|
match self {
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
Self::Metal => unsafe { metal::make_metal_surface(gr_context, texture) },
|
||||||
|
#[cfg(target_family = "windows")]
|
||||||
|
Self::Dx12 => unsafe { dx12::make_dx12_surface(gr_context, texture) },
|
||||||
|
#[cfg(all(target_family = "unix", not(target_vendor = "apple")))]
|
||||||
|
Self::Vulkan => unsafe { vulkan::make_vulkan_surface(gr_context, texture) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn import_texture(
|
||||||
|
&self,
|
||||||
|
canvas: &skia_safe::Canvas,
|
||||||
|
texture: wgpu::Texture,
|
||||||
|
) -> Option<skia_safe::Image> {
|
||||||
|
match self {
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
Self::Metal => unsafe { metal::import_metal_texture(canvas, texture) },
|
||||||
|
#[cfg(target_family = "windows")]
|
||||||
|
Self::Dx12 => unsafe { dx12::import_dx12_texture(canvas, texture) },
|
||||||
|
#[cfg(all(target_family = "unix", not(target_vendor = "apple")))]
|
||||||
|
Self::Vulkan => unsafe { vulkan::import_vulkan_texture(canvas, texture) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// Copyright © SixtyFPS GmbH <info@slint.dev>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
|
||||||
|
|
||||||
|
use windows::Win32::Graphics::Direct3D12::{D3D12_RESOURCE_STATE_PRESENT, ID3D12Resource};
|
||||||
|
use windows::Win32::Graphics::Dxgi::Common::DXGI_STANDARD_MULTISAMPLE_QUALITY_PATTERN;
|
||||||
|
use windows::Win32::Graphics::Dxgi::Common::{
|
||||||
|
DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_FORMAT_R8G8B8A8_UNORM_SRGB,
|
||||||
|
};
|
||||||
|
|
||||||
|
use wgpu_28 as wgpu;
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `resource` must be a valid D3D12 resource for the lifetime of the returned Surface.
|
||||||
|
unsafe fn wrap_dx12_texture(
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
gr_context: &mut skia_safe::gpu::DirectContext,
|
||||||
|
resource: ID3D12Resource,
|
||||||
|
dxgi_format: windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT,
|
||||||
|
color_type: skia_safe::ColorType,
|
||||||
|
) -> Option<skia_safe::Surface> {
|
||||||
|
unsafe {
|
||||||
|
let texture_info = skia_safe::gpu::d3d::TextureResourceInfo {
|
||||||
|
resource,
|
||||||
|
alloc: None,
|
||||||
|
resource_state: D3D12_RESOURCE_STATE_PRESENT,
|
||||||
|
format: dxgi_format,
|
||||||
|
sample_count: 1,
|
||||||
|
level_count: 1,
|
||||||
|
sample_quality_pattern: DXGI_STANDARD_MULTISAMPLE_QUALITY_PATTERN,
|
||||||
|
protected: skia_safe::gpu::Protected::No,
|
||||||
|
};
|
||||||
|
let backend_render_target =
|
||||||
|
skia_safe::gpu::BackendRenderTarget::new_d3d((width, height), &texture_info);
|
||||||
|
skia_safe::gpu::surfaces::wrap_backend_render_target(
|
||||||
|
gr_context,
|
||||||
|
&backend_render_target,
|
||||||
|
skia_safe::gpu::SurfaceOrigin::TopLeft,
|
||||||
|
color_type,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// The caller must ensure `texture` was created by a DX12-backed wgpu device and remains
|
||||||
|
/// valid for the lifetime of the returned `skia_safe::Surface`.
|
||||||
|
pub unsafe fn make_dx12_surface(
|
||||||
|
gr_context: &mut skia_safe::gpu::DirectContext,
|
||||||
|
texture: &wgpu::Texture,
|
||||||
|
) -> Option<skia_safe::Surface> {
|
||||||
|
// SAFETY: texture is borrowed for the duration of this call; the D3D12 resource is
|
||||||
|
// ref-counted (COM) and cloned into Skia's internal BackendRenderTarget via wrap_dx12_texture.
|
||||||
|
unsafe {
|
||||||
|
let dx12_texture = texture.as_hal::<wgpu::wgc::api::Dx12>()?;
|
||||||
|
let resource = windows_core::Interface::from_raw(windows_core::Interface::into_raw(
|
||||||
|
dx12_texture.raw_resource().clone(),
|
||||||
|
));
|
||||||
|
let size = texture.size();
|
||||||
|
let (dxgi_format, color_type) = match texture.format() {
|
||||||
|
wgpu::TextureFormat::Rgba8Unorm => {
|
||||||
|
(DXGI_FORMAT_R8G8B8A8_UNORM, skia_safe::ColorType::RGBA8888)
|
||||||
|
}
|
||||||
|
wgpu::TextureFormat::Rgba8UnormSrgb => {
|
||||||
|
(DXGI_FORMAT_R8G8B8A8_UNORM_SRGB, skia_safe::ColorType::SRGBA8888)
|
||||||
|
}
|
||||||
|
wgpu::TextureFormat::Bgra8Unorm => {
|
||||||
|
(DXGI_FORMAT_B8G8R8A8_UNORM, skia_safe::ColorType::BGRA8888)
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
wrap_dx12_texture(
|
||||||
|
size.width as i32,
|
||||||
|
size.height as i32,
|
||||||
|
gr_context,
|
||||||
|
resource,
|
||||||
|
dxgi_format,
|
||||||
|
color_type,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
pub unsafe fn import_dx12_texture(
|
||||||
|
canvas: &skia_safe::Canvas,
|
||||||
|
texture: wgpu::Texture,
|
||||||
|
) -> Option<skia_safe::Image> {
|
||||||
|
unsafe {
|
||||||
|
let dx12_texture = texture.as_hal::<wgpu::wgc::api::Dx12>();
|
||||||
|
|
||||||
|
let resource: ID3D12Resource = windows_core::Interface::from_raw(
|
||||||
|
windows_core::Interface::into_raw(dx12_texture.unwrap().raw_resource().clone()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let dxgi_texture_format = resource.GetDesc().Format;
|
||||||
|
|
||||||
|
let color_type = match dxgi_texture_format {
|
||||||
|
DXGI_FORMAT_R8G8B8A8_UNORM => skia_safe::ColorType::RGBA8888,
|
||||||
|
DXGI_FORMAT_R8G8B8A8_UNORM_SRGB => skia_safe::ColorType::SRGBA8888,
|
||||||
|
DXGI_FORMAT_B8G8R8A8_UNORM => skia_safe::ColorType::BGRA8888,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let texture_info = skia_safe::gpu::d3d::TextureResourceInfo {
|
||||||
|
resource,
|
||||||
|
alloc: None,
|
||||||
|
resource_state: D3D12_RESOURCE_STATE_PRESENT,
|
||||||
|
format: dxgi_texture_format,
|
||||||
|
sample_count: 1,
|
||||||
|
level_count: 1,
|
||||||
|
sample_quality_pattern: DXGI_STANDARD_MULTISAMPLE_QUALITY_PATTERN,
|
||||||
|
protected: skia_safe::gpu::Protected::No,
|
||||||
|
};
|
||||||
|
let size = texture.size();
|
||||||
|
|
||||||
|
let backend_texture = skia_safe::gpu::BackendTexture::new_d3d(
|
||||||
|
(size.width as i32, size.height as i32),
|
||||||
|
&texture_info,
|
||||||
|
);
|
||||||
|
|
||||||
|
Some(
|
||||||
|
skia_safe::image::Image::from_texture(
|
||||||
|
canvas.recording_context().as_mut().unwrap(),
|
||||||
|
&backend_texture,
|
||||||
|
skia_safe::gpu::SurfaceOrigin::TopLeft,
|
||||||
|
color_type,
|
||||||
|
skia_safe::AlphaType::Unpremul,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub unsafe fn make_dx12_context(
|
||||||
|
adapter: &wgpu::Adapter,
|
||||||
|
_device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
) -> Option<skia_safe::gpu::DirectContext> {
|
||||||
|
let backend = unsafe {
|
||||||
|
let maybe_dx12_queue = queue.as_hal::<wgpu::wgc::api::Dx12>();
|
||||||
|
let dx12_adapter = adapter.as_hal::<wgpu::wgc::api::Dx12>().unwrap();
|
||||||
|
|
||||||
|
maybe_dx12_queue.map(|dx12_queue| {
|
||||||
|
let dx12_queue_raw = dx12_queue.as_raw();
|
||||||
|
let mut dx12_device_old: Option<windows::Win32::Graphics::Direct3D12::ID3D12Device> =
|
||||||
|
None;
|
||||||
|
dx12_queue_raw.GetDevice(&mut dx12_device_old as _).unwrap();
|
||||||
|
let dx12_device_old = dx12_device_old.unwrap();
|
||||||
|
let dx12_device = windows_core::Interface::from_raw(windows_core::Interface::into_raw(
|
||||||
|
dx12_device_old,
|
||||||
|
));
|
||||||
|
|
||||||
|
let idxgiadapter_3: windows::Win32::Graphics::Dxgi::IDXGIAdapter3 =
|
||||||
|
dx12_adapter.as_raw().clone().into();
|
||||||
|
|
||||||
|
skia_safe::gpu::d3d::BackendContext {
|
||||||
|
adapter: windows_core::Interface::from_raw(windows_core::Interface::into_raw(
|
||||||
|
idxgiadapter_3,
|
||||||
|
)),
|
||||||
|
device: dx12_device,
|
||||||
|
queue: windows_core::Interface::from_raw(windows_core::Interface::into_raw(
|
||||||
|
dx12_queue_raw.clone(),
|
||||||
|
)),
|
||||||
|
memory_allocator: None,
|
||||||
|
protected_context: skia_safe::gpu::Protected::No,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
skia_safe::gpu::DirectContext::new_d3d(&backend.unwrap(), None)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user