Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
993bc0f88f | ||
|
|
a2be421974 | ||
|
|
78ce1ed590 | ||
|
|
81b99709bb | ||
|
|
6671960d5e | ||
|
|
326465952a | ||
|
|
9908dde9f8 | ||
|
|
d7e122711a | ||
|
|
1e34ec47ad | ||
|
|
cc3b6f903f | ||
|
|
6f34fd080f | ||
|
|
8e9a5838ab | ||
|
|
f21167e1a2 | ||
|
|
e03d491353 | ||
|
|
3d08d64c41 | ||
|
|
392e9cfeb0 | ||
|
|
979afbd213 | ||
|
|
b66b19e4d9 | ||
|
|
ad00e55fe4 | ||
|
|
ab11269246 | ||
|
|
e64ab52190 | ||
|
|
417f78aa94 | ||
|
|
f5fff6d930 | ||
|
|
ad1207b417 | ||
|
|
6509cb714a | ||
|
|
910f7cc188 | ||
|
|
e9f36efdb6 | ||
|
|
835aadc3eb | ||
|
|
533cd74c94 | ||
|
|
f49f62583d | ||
|
|
a8ef3967a1 | ||
|
|
df36d1c9b0 | ||
|
|
2248ee6846 | ||
|
|
d18f0fd8be | ||
|
|
8bc82a58a2 | ||
|
|
4f78cca71e | ||
|
|
1a484adead | ||
|
|
1b51ba83b4 | ||
|
|
34f2f07301 | ||
|
|
f2e728b503 | ||
|
|
a7fcd69897 | ||
|
|
23e280a6b4 | ||
|
|
1b4db0e681 | ||
|
|
610478f8c7 | ||
|
|
4661f595f8 | ||
|
|
97bbf1b866 | ||
|
|
90879145b2 | ||
|
|
17df20089b | ||
|
|
de6e66460d | ||
|
|
4fb383eaa3 | ||
|
|
afd46c0ea5 | ||
|
|
2c9bb85f2c | ||
|
|
53ba599918 | ||
|
|
90564e6564 | ||
|
|
63a3e41400 | ||
|
|
0be35b7f8a | ||
|
|
4bf556dedc | ||
|
|
8f1efd37f0 | ||
|
|
cc38436bce | ||
|
|
f228a08ff9 | ||
|
|
77fa25e5f7 | ||
|
|
a62f1367bd | ||
|
|
c8dfb3074d | ||
|
|
9251522a69 | ||
|
|
b5f7793870 | ||
|
|
f449c19099 | ||
|
|
d73d5f08f9 | ||
|
|
0589f5685b | ||
|
|
e980bb2e43 | ||
|
|
216289d66c | ||
|
|
a5e8ba65e2 | ||
|
|
07a8e50f47 | ||
|
|
bbc2f226d8 | ||
|
|
4d1d1af413 | ||
|
|
d8bdb68b2a | ||
|
|
2a400b26d0 | ||
|
|
9163374cdb | ||
|
|
f0620e317b | ||
|
|
305f49a62f | ||
|
|
f7e5efb245 | ||
|
|
5d9673aad3 | ||
|
|
8007f7a107 | ||
|
|
f57c68454a | ||
|
|
b552288dc1 | ||
|
|
eb84104940 | ||
|
|
eaeabebe73 | ||
|
|
042698ea61 | ||
|
|
a3974c2dda | ||
|
|
038b8884fc | ||
|
|
bfa78f62f2 | ||
|
|
7d58eeb65d | ||
|
|
6b0d903ad0 | ||
|
|
7a7df0106c | ||
|
|
b31618b749 | ||
|
|
e22f0ebef9 | ||
|
|
42c3b290dd | ||
|
|
1b9fabbbe4 | ||
|
|
587602b493 | ||
|
|
91e2f227d2 | ||
|
|
a447136b01 | ||
|
|
216ef38972 | ||
|
|
b356f63b93 | ||
|
|
78c7168a92 | ||
|
|
649cf49427 | ||
|
|
0a48c07e86 | ||
|
|
9c3893b36d | ||
|
|
cf540f1feb | ||
|
|
65fcaef13f | ||
|
|
d4c8c10b39 | ||
|
|
dbd6c6d446 | ||
|
|
1a02267266 | ||
|
|
977f281fb2 | ||
|
|
5804562c84 | ||
|
|
966c8988eb | ||
|
|
dcbcec6ba4 | ||
|
|
a6f24e8188 | ||
|
|
a00f1c6f3a | ||
|
|
4a9880eaad | ||
|
|
76112b59ac | ||
|
|
ce024d4794 | ||
|
|
97026a0db7 | ||
|
|
cfdd2a148a | ||
|
|
bbc5b27c25 | ||
|
|
02ec7e5199 | ||
|
|
a6f83689f8 | ||
|
|
ed23949a95 | ||
|
|
8a0dd7615e | ||
|
|
73f21e20ce | ||
|
|
405c74af9f | ||
|
|
5706a71941 | ||
|
|
3ba1f3522d | ||
|
|
004a5cdefe | ||
|
|
90e5c3543d | ||
|
|
49e54f9a63 | ||
|
|
950ba723ab | ||
|
|
643fd5fef1 | ||
|
|
4cdce60d62 | ||
|
|
4dd425f1f1 | ||
|
|
3533fac521 | ||
|
|
f81d8d50ea | ||
|
|
5c321addec | ||
|
|
831ac3a77f | ||
|
|
20395007a5 | ||
|
|
3c40854077 | ||
|
|
a2f5a7ffba | ||
|
|
d5a829eb16 | ||
|
|
c1801e1f07 | ||
|
|
d75c93db85 | ||
|
|
03a265b97f | ||
|
|
453ec16583 | ||
|
|
bffd538dd4 | ||
|
|
e6e1a2a8cf | ||
|
|
1d7a472847 | ||
|
|
64a80dc069 | ||
|
|
f8bc17e913 | ||
|
|
259f7e93b6 | ||
|
|
934380d095 | ||
|
|
ee686507c5 | ||
|
|
cc0bddbec1 | ||
|
|
ccf7497b6f | ||
|
|
8195b82073 | ||
|
|
4c3e884438 | ||
|
|
c3acb5af5a | ||
|
|
f5de30d037 | ||
|
|
647eaf4ad9 | ||
|
|
629414e0fb | ||
|
|
4166c1ea29 | ||
|
|
3a79121308 | ||
|
|
96f5ee442e | ||
|
|
5100d533fb | ||
|
|
d14cd3cbd2 | ||
|
|
ac184576f6 | ||
|
|
bfec60848d | ||
|
|
50c5f94007 | ||
|
|
f770147c6d | ||
|
|
17243464f6 | ||
|
|
bbb1eac189 | ||
|
|
428267a714 | ||
|
|
0420e59cbe | ||
|
|
73da6c68d2 | ||
|
|
b45917c41d | ||
|
|
8ad5293178 | ||
|
|
570984a39b | ||
|
|
8bb0b1da9c | ||
|
|
181b7bce9a | ||
|
|
79a968b0a6 | ||
|
|
96b0a9d097 | ||
|
|
aecf54bc76 | ||
|
|
bb78801720 | ||
|
|
2f63cae54c | ||
|
|
6b2974d0fa | ||
|
|
aa54ab1b2b | ||
|
|
325835f40a | ||
|
|
3d6cee125d | ||
|
|
95c8940008 | ||
|
|
092a1f9560 | ||
|
|
ab0c174948 | ||
|
|
6f2951c6b0 | ||
|
|
28e03a2624 | ||
|
|
42f022fde5 | ||
|
|
da60ecb719 |
No files matched your search
@@ -1,13 +1,5 @@
|
||||
name: Deploy website to GitHub Pages
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "site/**"
|
||||
- "docs/**"
|
||||
- "packaging/icons/ferese.svg"
|
||||
- "scripts/build-site.py"
|
||||
- ".github/workflows/pages.yml"
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -20,7 +12,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/configure-pages@v5
|
||||
- name: Build website and handbook
|
||||
- name: Build homepage and documentation redirects
|
||||
run: |
|
||||
python3 -m venv .site-venv
|
||||
.site-venv/bin/pip install -r site/requirements.txt
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Workspace release checks
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: release-checks-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
workspace:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: fedora:44
|
||||
options: --init
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
CARGO_PROFILE_TEST_DEBUG: "0"
|
||||
XDG_RUNTIME_DIR: /tmp/ferese-release-runtime
|
||||
steps:
|
||||
- name: Install desktop build and test dependencies
|
||||
run: |
|
||||
dnf install -y --allowerasing python3 python3-gobject python3-pillow git curl gcc gcc-c++ make cmake pkgconf-pkg-config \
|
||||
wayland-devel wayland-protocols-devel libxkbcommon-devel libinput-devel systemd-devel libseat-devel \
|
||||
mesa-libgbm-devel mesa-libEGL-devel mesa-dri-drivers mesa-vulkan-drivers libdrm-devel fontconfig-devel \
|
||||
freetype-devel expat-devel dbus-daemon dbus-tools desktop-file-utils foot pam xorg-x11-xauth \
|
||||
clang clang-devel pipewire-devel pipewire wireplumber polkit polkit-libs libei \
|
||||
xdg-desktop-portal xdg-desktop-portal-gtk weston \
|
||||
gstreamer1-devel gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer
|
||||
- name: Create private test runtime directory
|
||||
run: install -d -m 0700 "$XDG_RUNTIME_DIR"
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install pinned Rust and checks
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup.sh
|
||||
sh /tmp/rustup.sh -y --profile minimal --default-toolchain 1.95.0 --component rustfmt --component clippy
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- name: Check formatting and all workspace lints
|
||||
run: |
|
||||
cargo fmt --all --check
|
||||
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||
- name: Build desktop binaries and test the complete workspace
|
||||
run: |
|
||||
cargo build --locked --workspace
|
||||
cargo test --locked --workspace --no-fail-fast
|
||||
- name: Check release tag matches the workspace version
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
python3 - <<'PYTHON'
|
||||
import os, tomllib
|
||||
with open("Cargo.toml", "rb") as source:
|
||||
version = tomllib.load(source)["workspace"]["package"]["version"]
|
||||
assert os.environ["RELEASE_TAG"] == "v" + version, "Tag must match workspace.package.version"
|
||||
PYTHON
|
||||
- name: Test capture privacy and failure recovery
|
||||
env:
|
||||
LIBGL_ALWAYS_SOFTWARE: "1"
|
||||
EGL_PLATFORM: surfaceless
|
||||
run: |
|
||||
cargo test --locked -p ferese state::capture_privacy::tests -- --ignored
|
||||
cargo test --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored
|
||||
cargo test --locked -p ferese winit::tests::capture_failure_restores_display_target_and_subsequent_frames_render -- --ignored
|
||||
- name: Test recorder audio, cropping and stream revocation
|
||||
run: |
|
||||
cargo test --locked -p xdg-desktop-portal-ferese --bin ferese-record encoder_finishes_a_real_webm -- --ignored
|
||||
cargo test --locked -p xdg-desktop-portal-ferese --bin ferese-record encoder_saves_cropped_video_and_mixed_audio_when_stopped -- --ignored
|
||||
cargo test --locked -p xdg-desktop-portal-ferese --bin ferese-record video_eos_finishes_live_audio_without_waiting_for_the_user -- --ignored
|
||||
- name: Test native shell, visibility and resize behavior
|
||||
run: |
|
||||
install -m 0644 packaging/pam.d/ferese-lock.fedora /etc/pam.d/ferese-lock
|
||||
python3 scripts/ci/run-native-checks.py --suite desktop --log-dir native-logs
|
||||
- name: Upload native test logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: native-test-logs
|
||||
path: native-logs
|
||||
if-no-files-found: ignore
|
||||
- name: Test installer, session recovery and private portal contracts
|
||||
run: python3 -m unittest discover -s scripts/tests -v
|
||||
@@ -0,0 +1,201 @@
|
||||
name: Release packages
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_BUILD_JOBS: '2'
|
||||
CARGO_INCREMENTAL: '0'
|
||||
CARGO_PROFILE_RELEASE_DEBUG: '0'
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'release')
|
||||
uses: ./.github/workflows/release-checks.yml
|
||||
|
||||
source:
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'release')
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Read release version
|
||||
id: version
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import importlib.util, os
|
||||
from pathlib import Path
|
||||
spec = importlib.util.spec_from_file_location('package', 'scripts/package.py')
|
||||
package = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(package)
|
||||
version = package.version()
|
||||
package.check_tag(os.environ['RELEASE_TAG'], version)
|
||||
if os.environ['RELEASE_TAG']:
|
||||
notes = Path('docs/releases') / f"v{version}.md"
|
||||
if not notes.read_text().strip():
|
||||
raise ValueError('Release notes are empty')
|
||||
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
|
||||
print(f'version={version}', file=output)
|
||||
PY
|
||||
- name: Install pinned Rust toolchain
|
||||
run: rustup show
|
||||
- name: Package offline sources and recipes
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }}
|
||||
run: |
|
||||
python3 -m unittest discover -s scripts/tests -p test_package.py
|
||||
python3 scripts/package.py source --tag "$RELEASE_TAG"
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-source
|
||||
path: target/dist/*
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
|
||||
fedora:
|
||||
needs: source
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: fedora:44
|
||||
options: --init
|
||||
steps:
|
||||
- name: Install RPM tooling
|
||||
run: dnf install -y rpm-build dnf5-plugins git findutils tar gzip
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-source
|
||||
path: dist
|
||||
- name: Build and test Fedora package
|
||||
run: |
|
||||
dnf builddep -y dist/ferese.spec
|
||||
mkdir -p /tmp/rpmbuild/SOURCES
|
||||
cp dist/ferese-*.tar.gz /tmp/rpmbuild/SOURCES/
|
||||
rpmbuild -ba --define '_topdir /tmp/rpmbuild' dist/ferese.spec
|
||||
mkdir -p artifacts
|
||||
find /tmp/rpmbuild/RPMS /tmp/rpmbuild/SRPMS -name '*.rpm' -exec cp '{}' artifacts/ \;
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-fedora
|
||||
path: artifacts/*.rpm
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
|
||||
arch:
|
||||
needs: source
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: archlinux:base-devel
|
||||
options: --init
|
||||
steps:
|
||||
- name: Prepare Arch build user and dependencies
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed rust cargo cmake clang pkgconf python git desktop-file-utils \
|
||||
wayland libxkbcommon libinput systemd seatd mesa vulkan-swrast libdrm fontconfig freetype2 expat dbus \
|
||||
pam polkit foot pipewire wireplumber libpipewire gstreamer xdg-desktop-portal xdg-desktop-portal-gtk xorg-xauth
|
||||
useradd --create-home builder
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-source
|
||||
path: dist
|
||||
- name: Build and test Arch package
|
||||
run: |
|
||||
mkdir -p /home/builder/package
|
||||
cp dist/PKGBUILD dist/ferese-*.tar.gz /home/builder/package/
|
||||
chown -R builder:builder /home/builder/package
|
||||
runuser -u builder -- bash -c 'cd /home/builder/package && makepkg --cleanbuild --noconfirm'
|
||||
mkdir -p artifacts
|
||||
cp /home/builder/package/*.pkg.tar.zst artifacts/
|
||||
runuser -u builder -- bash -c 'cd /home/builder/package && makepkg --printsrcinfo' > artifacts/ferese.SRCINFO
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-arch
|
||||
path: artifacts/*
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'push' && github.ref_type == 'tag'
|
||||
needs: [source, checks, fedora, arch]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: release-*
|
||||
merge-multiple: true
|
||||
path: artifacts
|
||||
- name: Prepare draft release
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const tag = context.ref.slice('refs/tags/'.length);
|
||||
const repo = context.repo;
|
||||
const fs = require('node:fs');
|
||||
if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/.test(tag)) {
|
||||
throw new Error('Invalid release tag');
|
||||
}
|
||||
const body = fs.readFileSync(`docs/releases/${tag}.md`, 'utf8');
|
||||
if (!body.trim()) throw new Error('Release notes are empty');
|
||||
const commit = await github.rest.repos.getCommit({...repo, ref: tag});
|
||||
if (commit.data.sha !== context.sha) {
|
||||
throw new Error('Release tag moved after validation');
|
||||
}
|
||||
let release;
|
||||
try {
|
||||
release = (await github.rest.repos.getReleaseByTag({...repo, tag})).data;
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
if (release && !release.draft) {
|
||||
throw new Error(`Release ${tag} is already published`);
|
||||
}
|
||||
if (!release) {
|
||||
await github.rest.repos.createRelease({
|
||||
...repo, tag_name: tag, name: tag, draft: true,
|
||||
body, prerelease: tag.includes('-')
|
||||
});
|
||||
} else {
|
||||
await github.rest.repos.updateRelease({
|
||||
...repo, release_id: release.id, body,
|
||||
prerelease: tag.includes('-')
|
||||
});
|
||||
}
|
||||
- name: Upload packages and publish completed release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
cd artifacts
|
||||
# GitHub replaces '~' in uploaded filenames. Use those same download
|
||||
# names in SHA256SUMS so verification works after downloading RPMs.
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
for source in Path('.').glob('*.rpm'):
|
||||
destination = source.with_name(source.name.replace('~', '.'))
|
||||
if source != destination:
|
||||
if destination.exists():
|
||||
raise SystemExit(f'Duplicate release asset: {destination}')
|
||||
source.rename(destination)
|
||||
PY
|
||||
rm SHA256SUMS
|
||||
sha256sum * > SHA256SUMS
|
||||
gh release upload "$RELEASE_TAG" ./* --clobber
|
||||
test "$(gh api "repos/$GH_REPO/commits/$RELEASE_TAG" --jq .sha)" = "$GITHUB_SHA"
|
||||
gh release edit "$RELEASE_TAG" --draft=false
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Publish documentation to GitHub Wiki
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'site/README.md'
|
||||
- 'crates/ferese-theme/README.md'
|
||||
- 'scripts/build-wiki.py'
|
||||
- 'scripts/tests/test_wiki.py'
|
||||
- 'site/requirements.txt'
|
||||
- '.github/workflows/wiki.yml'
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency:
|
||||
group: wiki
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
publish:
|
||||
if: github.repository == 'ferese-wm/ferese' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
- name: Install Markdown parser and validate publisher
|
||||
run: |
|
||||
python3 -m venv .site-venv
|
||||
.site-venv/bin/pip install -r site/requirements.txt
|
||||
.site-venv/bin/python -m unittest discover -s scripts/tests -p test_wiki.py -v
|
||||
.site-venv/bin/python scripts/build-wiki.py
|
||||
- name: Publish changed wiki pages
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
gh auth setup-git --hostname github.com
|
||||
git clone --depth 1 https://github.com/ferese-wm/ferese.wiki.git build/wiki-checkout
|
||||
.site-venv/bin/python scripts/build-wiki.py --output build/wiki-checkout
|
||||
git -C build/wiki-checkout add -- '*.md' .ferese-pages.json
|
||||
if git -C build/wiki-checkout diff --cached --quiet; then
|
||||
echo 'Wiki is already current'
|
||||
exit 0
|
||||
fi
|
||||
git -C build/wiki-checkout -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' commit -m "Publish documentation from $(git rev-parse --short HEAD)"
|
||||
git -C build/wiki-checkout push origin HEAD
|
||||
@@ -0,0 +1,128 @@
|
||||
name: Workspace and native stability checks
|
||||
on:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: workspace-checks-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
workspace:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
CARGO_PROFILE_TEST_DEBUG: "0"
|
||||
XDG_RUNTIME_DIR: /tmp/ferese-workspace-runtime
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install workspace build and test dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential pkg-config dbus-daemon libwayland-dev \
|
||||
libxkbcommon-dev xkb-data libinput-dev libudev-dev libseat-dev libgbm-dev \
|
||||
python3-gi python3-pil libegl-dev libegl-mesa0 libgl1-mesa-dri mesa-vulkan-drivers libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev xauth \
|
||||
clang libclang-dev libpipewire-0.3-dev libspa-0.2-dev libglib2.0-dev libgstreamer1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good libpam-modules
|
||||
- name: Create private test runtime directory
|
||||
run: install -d -m 0700 "$XDG_RUNTIME_DIR"
|
||||
- name: Install Rust
|
||||
run: rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy
|
||||
- name: Check formatting and all workspace lints
|
||||
run: |
|
||||
cargo +1.95.0 fmt --all --check
|
||||
cargo +1.95.0 clippy --locked --workspace --all-targets -- -D warnings
|
||||
- name: Build desktop binaries
|
||||
run: cargo +1.95.0 build --locked --workspace
|
||||
- name: Test every workspace package
|
||||
run: cargo +1.95.0 test --locked --workspace --no-fail-fast -- --nocapture
|
||||
- name: Validate every Settings control against the compositor
|
||||
env:
|
||||
FERESE_TEST_BINARY: ${{ github.workspace }}/target/debug/ferese
|
||||
run: cargo +1.95.0 test --locked -p ferese-settings all_gui_controls_and_presets_pass_compositor_validation -- --ignored --nocapture
|
||||
- name: Fetch pinned upstream portal contracts
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: flatpak/xdg-desktop-portal
|
||||
ref: 23a76c392170dbbd26230f85ef56c3a57e52b857 # 1.20.3
|
||||
path: build/portal-contracts
|
||||
- name: Test installer, session recovery and private portal contracts
|
||||
env:
|
||||
FERESE_TEST_PORTAL_INTERFACES: ${{ github.workspace }}/build/portal-contracts/data
|
||||
FERESE_TEST_PORTAL_BACKGROUND: "1"
|
||||
run: python3 -m unittest discover -s scripts/tests -v
|
||||
- name: Test EGL initialization, capture privacy and cursor composition
|
||||
env:
|
||||
LIBGL_ALWAYS_SOFTWARE: "1"
|
||||
EGL_PLATFORM: surfaceless
|
||||
run: |
|
||||
cargo +1.95.0 test --locked -p ferese backends::direct::context::tests -- --include-ignored --nocapture
|
||||
cargo +1.95.0 test --locked -p ferese backends::direct::capture::tests::unchanged_capture_skips_preparation_without_losing_source_updates -- --ignored --nocapture
|
||||
cargo +1.95.0 test --locked -p ferese state::capture_privacy::tests -- --ignored --nocapture
|
||||
cargo +1.95.0 test --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored --nocapture
|
||||
cargo +1.95.0 test --locked -p ferese winit::tests::capture_failure_restores_display_target_and_subsequent_frames_render -- --ignored --nocapture
|
||||
cargo +1.95.0 test --locked -p ferese render::capture::stream_gpu_tests -- --ignored --nocapture
|
||||
- name: Preserve static recording duration
|
||||
run: cargo +1.95.0 test --locked -p xdg-desktop-portal-ferese --bin ferese-record recorder::tests::encoder_preserves_the_duration_of_a_static_capture -- --ignored --nocapture
|
||||
- name: Share desktop binaries with native checks
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: native-check-binaries
|
||||
path: |
|
||||
target/debug/ferese
|
||||
target/debug/ferese-shell
|
||||
target/debug/feresectl
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
native:
|
||||
name: Native panel and XWayland checks
|
||||
needs: workspace
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
LIBGL_ALWAYS_SOFTWARE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install native test dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y weston xwayland x11-utils xauth dbus-daemon \
|
||||
build-essential pkg-config clang libclang-dev libxcb1-dev libxcb-cursor-dev libxkbcommon-dev \
|
||||
libwayland-dev wayland-protocols libgbm1 libseat1 libinput10 libpipewire-0.3-0t64 \
|
||||
libegl1 libegl-mesa0 libgl1-mesa-dri mesa-vulkan-drivers fonts-dejavu-core
|
||||
- name: Create the shared X11 socket directory
|
||||
run: sudo install -d -m 1777 -o root -g root /tmp/.X11-unix
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: native-check-binaries
|
||||
path: target/debug
|
||||
- name: Restore executable permissions
|
||||
run: chmod +x target/debug/ferese target/debug/ferese-shell target/debug/feresectl
|
||||
- name: Install pinned Rust
|
||||
run: rustup toolchain install 1.95.0 --profile minimal
|
||||
- name: Build pinned XWayland Satellite with readiness support
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: Supreeeme/xwayland-satellite
|
||||
ref: b83eab900644e4c7c77982ce3d44cb490f0c5e1d # v0.8.3
|
||||
path: build/xwayland-satellite
|
||||
- name: Build XWayland Satellite
|
||||
run: cargo +1.95.0 build --manifest-path build/xwayland-satellite/Cargo.toml --locked --features systemd
|
||||
- name: Run isolated native checks on a headless Wayland host
|
||||
env:
|
||||
FERESE_TEST_BINARY: target/debug/ferese
|
||||
FERESE_TEST_SHELL: target/debug/ferese-shell
|
||||
FERESE_TEST_CTL: target/debug/feresectl
|
||||
FERESE_TEST_SATELLITE: build/xwayland-satellite/target/debug/xwayland-satellite
|
||||
run: python3 scripts/ci/run-native-checks.py --log-dir build/native-check-logs
|
||||
- name: Save native test logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: native-check-logs
|
||||
path: build/native-check-logs
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
@@ -1,12 +1,13 @@
|
||||
/target/
|
||||
/.artifacts/
|
||||
/tools/
|
||||
*.log
|
||||
ChatGPT Image*.png
|
||||
|
||||
# Local architecture decision notes
|
||||
/docs/adr/
|
||||
|
||||
# Generated GitHub Pages site
|
||||
/build/site/
|
||||
/build/wiki/
|
||||
/.site-venv/
|
||||
__pycache__/
|
||||
@@ -3,12 +3,17 @@ members = [
|
||||
"compositor",
|
||||
"feresectl",
|
||||
"crates/ferese-animation",
|
||||
"crates/ferese-activities",
|
||||
"crates/ferese-core",
|
||||
"crates/ferese-config",
|
||||
"crates/ferese-theme",
|
||||
"crates/ferese-theme-client",
|
||||
"crates/ferese-theme-model",
|
||||
"crates/ferese-ipc",
|
||||
"crates/ferese-layout",
|
||||
"crates/ferese-panel",
|
||||
"crates/ferese-protocols",
|
||||
"crates/ferese-rime",
|
||||
"shell/ferese-shell",
|
||||
"shell/ferese-settings",
|
||||
"shell/ferese-lock",
|
||||
@@ -16,20 +21,37 @@ members = [
|
||||
"portal",
|
||||
]
|
||||
resolver = "3"
|
||||
exclude = ["vendor/smithay"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
rust-version = "1.98"
|
||||
rust-version = "1.95"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_op_in_unsafe_fn = "deny"
|
||||
unused_must_use = "deny"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
borrowed_box = "deny"
|
||||
must_use_candidate = "warn"
|
||||
needless_for_each = "warn"
|
||||
ptr_arg = "deny"
|
||||
ref_option = "deny"
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
wildcard_imports = "deny"
|
||||
|
||||
[workspace.dependencies]
|
||||
schemars = { version = "1.2.3", features = ["preserve_order"] }
|
||||
ferese-shape = { git = "https://github.com/ferese-wm/ferese-shape", tag = "v0.1.0" }
|
||||
bitflags = "2.9.4"
|
||||
calloop = { version = "0.14.4", features = ["signals"] }
|
||||
calloop = { version = "0.14.5", features = ["signals"] }
|
||||
jiff = "0.2.37"
|
||||
libc = "0.2.176"
|
||||
libcosmic = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0", default-features = false, features = ["multi-window", "tokio", "wayland"] }
|
||||
smithay = { version = "0.7.0", default-features = false, features = [
|
||||
iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.3-rc.3" }
|
||||
libcosmic = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.3-rc.3", default-features = false, features = ["multi-window", "tokio", "wayland"] }
|
||||
smithay = { git = "https://github.com/Smithay/smithay", rev = "8a270232f1bfc416c781273c98a3f52a812a0bba", default-features = false, features = [
|
||||
"backend_drm",
|
||||
"backend_gbm",
|
||||
"backend_libinput",
|
||||
@@ -42,7 +64,7 @@ smithay = { version = "0.7.0", default-features = false, features = [
|
||||
] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde = { version = "1.0.228", features = ["derive", "rc"] }
|
||||
serde_json = "1.0.145"
|
||||
tempfile = "3.23.0"
|
||||
xcursor = "0.3.11"
|
||||
@@ -51,3 +73,6 @@ wayland-client = "0.31.15"
|
||||
wayland-scanner = "0.31.11"
|
||||
wayland-server = "0.31.14"
|
||||
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
|
||||
|
||||
[patch."https://github.com/Smithay/smithay"]
|
||||
smithay = { path = "vendor/smithay" }
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ferese contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,37 +1,61 @@
|
||||
<p align="center">
|
||||
<img src="docs/images/ferese-lockup.svg" width="300" alt="Ferese">
|
||||
<img src="assets/branding/ferese.svg" width="160" height="160" alt="Ferese">
|
||||
</p>
|
||||
|
||||
<p align="center"><strong>A complete Wayland desktop built around personalization</strong></p>
|
||||
<p align="center"><strong>A configurable Wayland desktop</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/installation.md">Install</a> ·
|
||||
<a href="docs/configuration.md">Configure</a> ·
|
||||
<a href="docs/screenshots.md">Screenshots</a> ·
|
||||
<a href="docs/README.md">Documentation</a>
|
||||
<a href="https://github.com/ferese-wm/ferese/releases">Download</a> ·
|
||||
<a href="https://github.com/ferese-wm/ferese/wiki/Installation">Install</a> ·
|
||||
<a href="https://github.com/ferese-wm/ferese/wiki/Configuration">Configure</a> ·
|
||||
<a href="https://github.com/ferese-wm/ferese/wiki">Documentation</a>
|
||||
</p>
|
||||
|
||||
Ferese is a window manager and desktop shell built together and configured in one place, for people who want their desktop to be their own.
|
||||
Ferese is a Linux desktop built in Rust with Smithay. It includes a window manager,
|
||||
desktop shell, Settings and a lock screen, with shared configuration and theming.
|
||||
Choose a layout per workspace and configure the desktop through Settings or a KDL file.
|
||||
|
||||

|
||||
**Version 0.1.0** is the first stable release. See the [release notes](docs/releases/v0.1.0.md)
|
||||
for what's included. Packages target Fedora 44 and Arch Linux on x86_64.
|
||||
This README and the wiki describe `main`.
|
||||
|
||||
[View more screenshots](docs/screenshots.md)
|
||||
This local `twilight/rime-service-poc` branch adds an independent native Wayland
|
||||
Rime service and compositor popup fixes. See [Rime prototype validation](docs/development/rime-poc.md)
|
||||
for its architecture, isolated preview, test commands and current limits.
|
||||
|
||||
## Features
|
||||

|
||||
|
||||
- Scrolling columns, tree tiling and floating windows, with an overview of all your workspaces
|
||||
- Keyboard shortcuts and touchpad gestures you can remap
|
||||
- Multiple monitors with fractional scaling
|
||||
- Themes, wallpapers, blur and corners that update live, with Light/Dark/Auto modes and custom KDL themes
|
||||
- Desktop widgets, including a clock and sticky notes
|
||||
- Screenshots with annotation, plus screen sharing and built-in recording
|
||||
- A built-in lock screen
|
||||
- Integrated Settings and Control Center for configuring your desktop
|
||||
[Screenshots](https://github.com/ferese-wm/ferese/wiki/Screenshots)
|
||||
|
||||
## Using Ferese
|
||||
|
||||
- Use scrolling columns, tree tiling, floating windows and
|
||||
[tabbed window groups](https://github.com/ferese-wm/ferese/wiki/Tabbed-Windows).
|
||||
- Arrange [panel items and groups](https://github.com/ferese-wm/ferese/wiki/Panel-Configuration),
|
||||
place the panel at the top or bottom, and choose its style.
|
||||
- Follow command and build progress in the panel with
|
||||
[Live Activities](https://github.com/ferese-wm/ferese/wiki/Live-Activities).
|
||||
- Set up [binding modes](https://github.com/ferese-wm/ferese/wiki/Configuration-Bindings#binding-modes)
|
||||
for resizing, launching apps and other shortcut groups.
|
||||
- Change themes, wallpapers, blur and animation settings across the desktop.
|
||||
Configuration edits apply live. Invalid edits leave the last working configuration active.
|
||||
- Manage displays, Wi-Fi, Bluetooth and audio from Settings and Control Center.
|
||||
Multiple monitors, fractional scaling and monitor mirroring are supported.
|
||||
- Annotate screenshots, share a window, workspace or display, and record a monitor or selected
|
||||
region with audio.
|
||||
|
||||
The [feature roadmap](https://github.com/ferese-wm/ferese/issues/5) tracks implemented features and remaining work.
|
||||
|
||||
## Install
|
||||
|
||||
Start with the [dependencies](docs/installation.md#requirements), then clone Ferese and
|
||||
build it as your normal user:
|
||||
Download the package for your distribution from
|
||||
[Releases](https://github.com/ferese-wm/ferese/releases/tag/v0.1.0), then follow
|
||||
the [package installation instructions](https://github.com/ferese-wm/ferese/wiki/Packages-and-Releases#install-a-release-package).
|
||||
Read the [release notes](docs/releases/v0.1.0.md) before upgrading.
|
||||
|
||||
To build the current development version, install the
|
||||
[build dependencies](https://github.com/ferese-wm/ferese/wiki/Installation#requirements)
|
||||
and run these commands as your normal user:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/ferese-wm/ferese.git
|
||||
@@ -39,55 +63,56 @@ cd ferese
|
||||
./scripts/install.sh
|
||||
```
|
||||
|
||||
Once it’s installed, log out and choose **Ferese** at your login screen, or run
|
||||
`ferese-session` from a TTY or a greeter that accepts a command. The [installation
|
||||
guide](docs/installation.md) also walks you through previews, updates and recovery.
|
||||
Once installed, log out and select **Ferese** at your login screen, or run
|
||||
`ferese-session` from a local TTY. You can also try a
|
||||
[nested preview](https://github.com/ferese-wm/ferese/wiki/Installation#preview-and-logs)
|
||||
inside your existing Wayland desktop.
|
||||
|
||||
## Configure
|
||||
|
||||
Make it yours through **Control Center → Settings** or `~/.config/ferese/config.kdl`,
|
||||
with changes applying as you make them and the last working configuration staying in
|
||||
place if an edit is invalid.
|
||||
Open **Control Center → Settings** or edit `~/.config/ferese/config.kdl`.
|
||||
The [visual direction notes](docs/development/visual-direction.md) document the
|
||||
Graphite dark/light family and the responsive settings layout.
|
||||
|
||||
The [configuration reference](docs/configuration.md) and [example
|
||||
config](packaging/config.kdl) cover everything from appearance and window layouts to
|
||||
displays and startup apps.
|
||||
This fork adds a grouped **Appearance** settings hub. **General → Language**
|
||||
supports the system language, 简体中文 and English and saves the choice as
|
||||
`ui { language "zh-CN"; }` (`auto` and `en` are also supported). It changes
|
||||
Ferese's interface independently of keyboard layouts and input methods.
|
||||
|
||||
## Default shortcuts
|
||||
**All options** exposes the compositor's complete configuration schema, including
|
||||
nested lists, maps and optional sections. Confirm text with Enter and apply the
|
||||
whole draft; validation, conflict detection and backups use the normal Settings
|
||||
save path. Basic settings continue to save immediately.
|
||||
|
||||
Use `Super`, usually the Windows key, to move around your desktop, and open **Settings →
|
||||
Shortcuts** whenever you want to change a shortcut or gesture.
|
||||
**Tray icons** controls panel status icons and StatusNotifierItem application
|
||||
icons. Applications can be hidden individually, including after they exit. Tray
|
||||
preferences live under `status { tray { enabled #true; show-passive #false;
|
||||
icon-size 16; hidden-items "application-id"; }; }`. Existing custom panels can
|
||||
add an **Application tray** item in **Panel & Shell**. The compositor, Shell and
|
||||
Settings must be built and installed together so the schema and editor match.
|
||||
|
||||
The [configuration reference](https://github.com/ferese-wm/ferese/wiki/Configuration)
|
||||
and [example config](packaging/config.kdl) cover appearance, layouts, displays,
|
||||
window rules and startup apps.
|
||||
|
||||
## First shortcuts
|
||||
|
||||
`Super` is usually the Windows key. Change shortcuts and gestures in **Settings → Shortcuts**.
|
||||
|
||||
| Control | Action |
|
||||
| --- | --- |
|
||||
| Super + Enter | Open a terminal |
|
||||
| Super + H / J / K / L | Focus left / down / up / right |
|
||||
| Super + Backspace | Return to the last focused window |
|
||||
| Super + Escape | Toggle between the last two visited workspaces on this monitor |
|
||||
| Alt + Tab / Alt + Shift + Tab | Preview recent windows forward / backward; release Alt to select, Escape to cancel |
|
||||
| Super + F | Maximize the window |
|
||||
| Super + Shift + F | Toggle fullscreen |
|
||||
| Super + R | Cycle column width |
|
||||
| Super + Tab | Open overview |
|
||||
| Super + M | Switch scrolling / tree layout |
|
||||
| Print Screen | Capture the active monitor and open Satty |
|
||||
| Super + Shift + S | Select a screenshot area |
|
||||
| Super + Shift + E | Ask to log out |
|
||||
| Super + left-button drag | Move a floating window |
|
||||
| Super + right-button drag | Resize a floating window |
|
||||
| Three-finger swipe up / down | Next / previous workspace |
|
||||
| Three-finger swipe left / right | Focus the window to the right / left |
|
||||
|
||||
Explore [all shortcuts and mouse actions](docs/shortcuts.md) for more ways to arrange
|
||||
your windows and move between workspaces.
|
||||
See [all shortcuts and gestures](https://github.com/ferese-wm/ferese/wiki/Shortcuts-and-Gestures)
|
||||
for resizing, grouping windows, screenshots and workspace navigation.
|
||||
|
||||
## Documentation
|
||||
## Development and bug reports
|
||||
|
||||
The [documentation index](docs/README.md) brings the desktop guides together, while
|
||||
[development instructions](docs/development.md) cover building, testing and
|
||||
contributing.
|
||||
|
||||
Ferese is written in Rust using Smithay, with development happening [on
|
||||
GitHub](https://github.com/ferese-wm/ferese). If something goes wrong, [open an
|
||||
issue](https://github.com/ferese-wm/ferese/issues) with the steps to reproduce it and
|
||||
any relevant logs.
|
||||
[Development instructions](https://github.com/ferese-wm/ferese/wiki/Development)
|
||||
cover building, tests and hardware checks. To
|
||||
[report a bug](https://github.com/ferese-wm/ferese/issues), include steps to reproduce it,
|
||||
your version or commit, relevant logs, and whether you ran a nested preview or a direct session.
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="160 185 950 950" role="img" aria-label="Ferese">
|
||||
<path d="M0 0 C4.55 4.32 7.91 8.89 8.33 15.33 C8.35 16.55 8.36 17.78 8.38 19 C8.39 19.3 8.39 19.3 8.44 20.83 C8.49 27.21 5.87 31.95 1.71 36.73 C-4.2 42.15 -10.93 44.1 -18.76 44.55 C-50.32 46.61 -75.43 58.22 -98.88 79.19 C-99.21 79.48 -99.21 79.48 -100.9 80.99 C-124.9 103.69 -139.89 139.69 -141.14 172.46 C-141.28 178.36 -141.25 184.26 -141.22 190.16 C-141.21 192.41 -141.22 194.66 -141.22 196.91 C-141.22 201.71 -141.21 206.51 -141.19 211.32 C-141.17 218.26 -141.16 225.2 -141.16 232.15 C-141.15 243.42 -141.13 254.69 -141.1 265.96 C-141.07 276.89 -141.05 287.83 -141.04 298.76 C-141.04 299.44 -141.04 300.12 -141.04 300.81 C-141.03 304.21 -141.03 307.6 -141.03 310.99 C-140.99 339.06 -140.94 367.12 -140.88 395.19 C-140.31 395.28 -139.74 395.37 -139.15 395.47 C-133.11 396.45 -127.07 397.43 -121.03 398.42 C-118.79 398.78 -116.55 399.15 -114.31 399.51 C-60.83 408.2 -60.83 408.2 -54.88 410.19 C-54.71 409.64 -54.54 409.1 -54.37 408.54 C-47.93 388.17 -34.96 370.82 -20.25 355.62 C-18.86 354.17 -17.49 352.71 -16.12 351.24 C-4.73 339.25 8.69 329.23 23.12 321.19 C23.84 320.79 24.55 320.39 25.29 319.98 C50.29 306.18 77.01 296.78 105.88 296.87 C107.93 296.87 109.97 296.85 112.02 296.83 C133.34 296.72 155.21 302.4 171.12 317.19 C177.61 324.1 181.44 332.74 182.12 342.19 C182.18 342.83 182.23 343.47 182.28 344.12 C183.26 366.26 170.19 386.3 156 402.12 C142.58 416.77 125.82 428.36 108.12 437.19 C107.42 437.54 106.71 437.9 105.98 438.26 C73.26 454.35 37.72 460.63 1.56 460.31 C-0.43 460.3 -2.43 460.29 -4.42 460.29 C-9.24 460.26 -14.06 460.23 -18.88 460.19 C-18.58 462.41 -18.28 464.63 -17.98 466.84 C-17.94 467.15 -17.94 467.15 -17.73 468.73 C-15.1 487.74 -2.61 505.82 12.38 517.44 C45.27 541.86 86.62 552.07 127.12 553.19 C127.53 553.2 127.53 553.2 129.6 553.27 C191.26 555.11 250.44 535.68 303.61 505.44 C304.2 505.11 304.79 504.77 305.4 504.43 C306.5 503.81 307.59 503.18 308.69 502.55 C315.54 498.66 321.72 497.01 329.43 498.69 C335.43 500.46 339.97 502.79 343.38 508.16 C346.2 513.74 347.2 519 346.12 525.19 C341.87 538.11 331.44 544.92 320.12 551.19 C319.32 551.64 318.51 552.09 317.67 552.55 C280.55 572.94 239.61 586.84 197.88 593.94 C197.58 593.99 197.58 593.99 196.08 594.25 C176.92 597.48 157.92 598.57 138.5 598.5 C137.4 598.5 136.3 598.5 135.17 598.49 C118.96 598.45 103.11 598.08 87.12 595.19 C86.12 595.01 85.12 594.83 84.08 594.65 C50.1 588.51 15.4 577.01 -11.88 555.19 C-12.46 554.72 -13.05 554.25 -13.66 553.76 C-31.32 539.47 -44.56 522.98 -53.88 502.19 C-54 501.91 -54 501.91 -54.64 500.51 C-60.89 486.4 -63.29 471.5 -63.88 456.19 C-78.94 453.2 -94.1 450.81 -109.25 448.35 C-112.83 447.76 -116.4 447.18 -119.97 446.6 C-126.94 445.46 -133.91 444.32 -140.88 443.19 C-140.88 443.96 -140.88 444.74 -140.89 445.53 C-140.97 464.47 -141.06 483.4 -141.16 502.34 C-141.21 511.49 -141.26 520.65 -141.3 529.81 C-141.33 537.8 -141.37 545.79 -141.42 553.78 C-141.44 558 -141.46 562.22 -141.48 566.45 C-141.54 583.79 -141.65 600.96 -143.88 618.19 C-144.02 619.33 -144.16 620.48 -144.31 621.65 C-147.21 643.73 -153.97 665.28 -163.88 685.19 C-164.05 685.55 -164.05 685.55 -164.96 687.38 C-173.84 705.26 -185.28 720.58 -198.88 735.19 C-199.49 735.85 -200.1 736.52 -200.73 737.21 C-226.52 764.21 -267.21 783.28 -304.69 784.35 C-306.01 784.37 -307.33 784.38 -308.64 784.39 C-309.36 784.4 -310.07 784.4 -310.81 784.41 C-313.1 784.43 -315.39 784.44 -317.69 784.44 C-318.08 784.44 -318.08 784.44 -320.06 784.44 C-331.93 784.42 -343.28 783.89 -354.88 781.19 C-355.24 781.11 -355.24 781.11 -357.1 780.72 C-376.29 776.61 -395.44 768.97 -411.88 758.19 C-412.44 757.82 -413.01 757.45 -413.6 757.06 C-422.26 751.33 -429.96 744.83 -437.41 737.6 C-438.74 736.32 -440.09 735.06 -441.45 733.8 C-471.2 705.54 -489.04 662.81 -490.16 621.95 C-491.32 566.83 -469.26 516.41 -431.34 476.72 C-430.86 476.21 -430.37 475.71 -429.88 475.19 C-429.36 474.65 -428.84 474.11 -428.3 473.55 C-421.71 466.72 -415 460.26 -407.48 454.44 C-405.62 452.99 -403.78 451.53 -401.94 450.06 C-393.58 443.48 -385.11 437.52 -375.84 432.3 C-373.76 431.12 -371.7 429.93 -369.64 428.73 C-333.29 407.84 -293.36 396.2 -251.88 391.19 C-251.14 391.1 -250.4 391.01 -249.64 390.92 C-243.13 390.19 -236.72 390.06 -230.18 390.09 C-229.11 390.09 -228.03 390.09 -226.93 390.09 C-223.53 390.1 -220.14 390.11 -216.75 390.12 C-214.43 390.13 -212.12 390.13 -209.8 390.14 C-204.16 390.15 -198.52 390.17 -192.88 390.19 C-192.88 389.63 -192.88 389.63 -192.88 386.8 C-192.92 360.23 -192.96 333.65 -192.98 307.08 C-192.98 303.8 -192.98 300.52 -192.98 297.24 C-192.99 296.91 -192.99 296.91 -192.99 295.26 C-193 284.72 -193.01 274.19 -193.03 263.66 C-193.05 252.82 -193.06 241.99 -193.06 231.16 C-193.07 224.49 -193.07 217.83 -193.09 211.16 C-193.1 206.02 -193.1 200.88 -193.1 195.74 C-193.1 193.65 -193.11 191.56 -193.11 189.47 C-193.17 172.31 -192.41 155.4 -188.19 138.69 C-187.93 137.64 -187.67 136.59 -187.4 135.5 C-180.48 108.47 -167.73 82.68 -149.88 61.19 C-149.38 60.59 -148.89 59.99 -148.38 59.37 C-118.56 23.29 -76.88 -0.56 -30.08 -5.29 C-19.46 -6.27 -8Line truncated
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="160 185 950 950" role="img" aria-label="Ferese">
|
||||
<defs><linearGradient id="blue0" gradientUnits="userSpaceOnUse" x1="250" y1="260" x2="1040" y2="980" gradientTransform="translate(-706.875 -245.8125)"><stop offset="0" stop-color="#69A8FF" /><stop offset="0.5" stop-color="#3B82F6" /><stop offset="1" stop-color="#245CE5" /></linearGradient></defs><path d="M0 0 C4.55 4.32 7.91 8.89 8.33 15.33 C8.35 16.55 8.36 17.78 8.38 19 C8.39 19.3 8.39 19.3 8.44 20.83 C8.49 27.21 5.87 31.95 1.71 36.73 C-4.2 42.15 -10.93 44.1 -18.76 44.55 C-50.32 46.61 -75.43 58.22 -98.88 79.19 C-99.21 79.48 -99.21 79.48 -100.9 80.99 C-124.9 103.69 -139.89 139.69 -141.14 172.46 C-141.28 178.36 -141.25 184.26 -141.22 190.16 C-141.21 192.41 -141.22 194.66 -141.22 196.91 C-141.22 201.71 -141.21 206.51 -141.19 211.32 C-141.17 218.26 -141.16 225.2 -141.16 232.15 C-141.15 243.42 -141.13 254.69 -141.1 265.96 C-141.07 276.89 -141.05 287.83 -141.04 298.76 C-141.04 299.44 -141.04 300.12 -141.04 300.81 C-141.03 304.21 -141.03 307.6 -141.03 310.99 C-140.99 339.06 -140.94 367.12 -140.88 395.19 C-140.31 395.28 -139.74 395.37 -139.15 395.47 C-133.11 396.45 -127.07 397.43 -121.03 398.42 C-118.79 398.78 -116.55 399.15 -114.31 399.51 C-60.83 408.2 -60.83 408.2 -54.88 410.19 C-54.71 409.64 -54.54 409.1 -54.37 408.54 C-47.93 388.17 -34.96 370.82 -20.25 355.62 C-18.86 354.17 -17.49 352.71 -16.12 351.24 C-4.73 339.25 8.69 329.23 23.12 321.19 C23.84 320.79 24.55 320.39 25.29 319.98 C50.29 306.18 77.01 296.78 105.88 296.87 C107.93 296.87 109.97 296.85 112.02 296.83 C133.34 296.72 155.21 302.4 171.12 317.19 C177.61 324.1 181.44 332.74 182.12 342.19 C182.18 342.83 182.23 343.47 182.28 344.12 C183.26 366.26 170.19 386.3 156 402.12 C142.58 416.77 125.82 428.36 108.12 437.19 C107.42 437.54 106.71 437.9 105.98 438.26 C73.26 454.35 37.72 460.63 1.56 460.31 C-0.43 460.3 -2.43 460.29 -4.42 460.29 C-9.24 460.26 -14.06 460.23 -18.88 460.19 C-18.58 462.41 -18.28 464.63 -17.98 466.84 C-17.94 467.15 -17.94 467.15 -17.73 468.73 C-15.1 487.74 -2.61 505.82 12.38 517.44 C45.27 541.86 86.62 552.07 127.12 553.19 C127.53 553.2 127.53 553.2 129.6 553.27 C191.26 555.11 250.44 535.68 303.61 505.44 C304.2 505.11 304.79 504.77 305.4 504.43 C306.5 503.81 307.59 503.18 308.69 502.55 C315.54 498.66 321.72 497.01 329.43 498.69 C335.43 500.46 339.97 502.79 343.38 508.16 C346.2 513.74 347.2 519 346.12 525.19 C341.87 538.11 331.44 544.92 320.12 551.19 C319.32 551.64 318.51 552.09 317.67 552.55 C280.55 572.94 239.61 586.84 197.88 593.94 C197.58 593.99 197.58 593.99 196.08 594.25 C176.92 597.48 157.92 598.57 138.5 598.5 C137.4 598.5 136.3 598.5 135.17 598.49 C118.96 598.45 103.11 598.08 87.12 595.19 C86.12 595.01 85.12 594.83 84.08 594.65 C50.1 588.51 15.4 577.01 -11.88 555.19 C-12.46 554.72 -13.05 554.25 -13.66 553.76 C-31.32 539.47 -44.56 522.98 -53.88 502.19 C-54 501.91 -54 501.91 -54.64 500.51 C-60.89 486.4 -63.29 471.5 -63.88 456.19 C-78.94 453.2 -94.1 450.81 -109.25 448.35 C-112.83 447.76 -116.4 447.18 -119.97 446.6 C-126.94 445.46 -133.91 444.32 -140.88 443.19 C-140.88 443.96 -140.88 444.74 -140.89 445.53 C-140.97 464.47 -141.06 483.4 -141.16 502.34 C-141.21 511.49 -141.26 520.65 -141.3 529.81 C-141.33 537.8 -141.37 545.79 -141.42 553.78 C-141.44 558 -141.46 562.22 -141.48 566.45 C-141.54 583.79 -141.65 600.96 -143.88 618.19 C-144.02 619.33 -144.16 620.48 -144.31 621.65 C-147.21 643.73 -153.97 665.28 -163.88 685.19 C-164.05 685.55 -164.05 685.55 -164.96 687.38 C-173.84 705.26 -185.28 720.58 -198.88 735.19 C-199.49 735.85 -200.1 736.52 -200.73 737.21 C-226.52 764.21 -267.21 783.28 -304.69 784.35 C-306.01 784.37 -307.33 784.38 -308.64 784.39 C-309.36 784.4 -310.07 784.4 -310.81 784.41 C-313.1 784.43 -315.39 784.44 -317.69 784.44 C-318.08 784.44 -318.08 784.44 -320.06 784.44 C-331.93 784.42 -343.28 783.89 -354.88 781.19 C-355.24 781.11 -355.24 781.11 -357.1 780.72 C-376.29 776.61 -395.44 768.97 -411.88 758.19 C-412.44 757.82 -413.01 757.45 -413.6 757.06 C-422.26 751.33 -429.96 744.83 -437.41 737.6 C-438.74 736.32 -440.09 735.06 -441.45 733.8 C-471.2 705.54 -489.04 662.81 -490.16 621.95 C-491.32 566.83 -469.26 516.41 -431.34 476.72 C-430.86 476.21 -430.37 475.71 -429.88 475.19 C-429.36 474.65 -428.84 474.11 -428.3 473.55 C-421.71 466.72 -415 460.26 -407.48 454.44 C-405.62 452.99 -403.78 451.53 -401.94 450.06 C-393.58 443.48 -385.11 437.52 -375.84 432.3 C-373.76 431.12 -371.7 429.93 -369.64 428.73 C-333.29 407.84 -293.36 396.2 -251.88 391.19 C-251.14 391.1 -250.4 391.01 -249.64 390.92 C-243.13 390.19 -236.72 390.06 -230.18 390.09 C-229.11 390.09 -228.03 390.09 -226.93 390.09 C-223.53 390.1 -220.14 390.11 -216.75 390.12 C-214.43 390.13 -212.12 390.13 -209.8 390.14 C-204.16 390.15 -198.52 390.17 -192.88 390.19 C-192.88 389.63 -192.88 389.63 -192.88 386.8 C-192.92 360.23 -192.96 333.65 -192.98 307.08 C-192.98 303.8 -192.98 300.52 -192.98 297.24 C-192.99 296.91 -192.99 296.91 -192.99 295.26 C-193 284.72 -193.01 274.19 -193.03 263.66 C-193.05 252.82 -193.06 241.99 -193.06 231.16 C-193.07 224.49 -193.07 217.83 -193.09 211.16 C-193.1 206.02 -193.1 200.Line truncated
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
@@ -1,111 +0,0 @@
|
||||
Copyright (c) 2009--2019, The Cantarell Authors
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
Scripts contain parts of the source code of glyphsLib:
|
||||
|
||||
# Copyright 2015 Google Inc. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
After Width: | Height: | Size: 6.3 MiB |
|
After Width: | Height: | Size: 6.2 MiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 1.8 MiB |
@@ -1,62 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo4MzkwZjY3Yi1lMWMzLTRkZjItYjc5Ni1kMThmOTcwMzJiOTgAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaHMKM0mGirl6EAsa915f3d8AAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDplYTUzOTY3Ni03MTE4LTQzMGItYTU3Ni02MDk2Zjk0ZmY0NTdscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNomXih8e+70OF/YLHlVgL9LgAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggnypnxLNZxd05KEbslyHWdZn0du7pvfZdyFM3FXgUiimkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaEYNHg3OE8TdlNzzrWNuGkkAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCBmyCV0xk4L/Lqf9X1S9qWNTl5TCz9r70H5YR7X/R91+mRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiaZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjgzOTBmNjdiLWUxYzMtNGRmMi1iNzk2LWQxOGY5NzAzMmI5OC9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOmY4MGM0NTA5LTE1NmEtNDQxYi04ZDU4LTNjMzE5YTRiOWU4ZHJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCfKmfEs1nF3TkoRuyXIdZ1mfR27um99l3IUzcVeBSKKaJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggBkna+FVWA7ttg4e5fN3SGCgCzCQlDA9sXSLaXIILGWiiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggaFremDqsMi+9hC8EXdAqcvUmalJPQpuJro7fci7a9hF0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALine truncated
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="42%" r="75%">
|
||||
<stop offset="0%" stop-color="#12151c"/>
|
||||
<stop offset="55%" stop-color="#0a0c11"/>
|
||||
<stop offset="100%" stop-color="#050609"/>
|
||||
</radialGradient>
|
||||
|
||||
<linearGradient id="glow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6ea3ff"/>
|
||||
<stop offset="100%" stop-color="#3d7be6"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="glass" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.06"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.015"/>
|
||||
</linearGradient>
|
||||
|
||||
<filter id="soft" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="18"/>
|
||||
</filter>
|
||||
|
||||
<filter id="softer" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="45"/>
|
||||
</filter>
|
||||
|
||||
<filter id="markGlow" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="14" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- base -->
|
||||
<rect width="1920" height="1080" fill="url(#bg)"/>
|
||||
|
||||
<!-- distant blurred glow behind mark -->
|
||||
<circle cx="960" cy="540" r="420" fill="url(#glow)" opacity="0.10" filter="url(#softer)"/>
|
||||
|
||||
<!-- faint background tiling grid, evoking Ferese's window layout -->
|
||||
<g opacity="0.35">
|
||||
<rect x="90" y="90" width="430" height="300" rx="18" fill="url(#glass)" stroke="#ffffff" stroke-opacity="0.05" filter="url(#soft)"/>
|
||||
<rect x="90" y="420" width="430" height="220" rx="18" fill="url(#glass)" stroke="#ffffff" stroke-opacity="0.05" filter="url(#soft)"/>
|
||||
<rect x="1400" y="120" width="420" height="230" rx="18" fill="url(#glass)" stroke="#ffffff" stroke-opacity="0.05" filter="url(#soft)"/>
|
||||
<rect x="1400" y="380" width="420" height="330" rx="18" fill="url(#glass)" stroke="#ffffff" stroke-opacity="0.05" filter="url(#soft)"/>
|
||||
<rect x="120" y="760" width="360" height="220" rx="18" fill="url(#glass)" stroke="#ffffff" stroke-opacity="0.04" filter="url(#soft)"/>
|
||||
<rect x="1440" y="800" width="360" height="180" rx="18" fill="url(#glass)" stroke="#ffffff" stroke-opacity="0.04" filter="url(#soft)"/>
|
||||
</g>
|
||||
|
||||
<!-- centered Ferese mark, scaled from the 200x200 source -->
|
||||
<g transform="translate(960,540) scale(1.9) translate(-100,-100)" filter="url(#markGlow)">
|
||||
<rect x="20" y="20" width="50" height="160" rx="8" fill="#3d7be6"/>
|
||||
<rect x="80" y="20" width="100" height="55" rx="8" fill="#3d7be6"/>
|
||||
<rect x="80" y="95" width="70" height="45" rx="8" fill="#3d7be6"/>
|
||||
</g>
|
||||
|
||||
<!-- subtle vignette -->
|
||||
<rect width="1920" height="1080" fill="url(#bg)" opacity="0" />
|
||||
<rect x="0" y="0" width="1920" height="1080" fill="black" opacity="0.12" style="mix-blend-mode:multiply"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,170 @@
|
||||
"""Render the matching Ferese wallpapers directly at UHD resolution.
|
||||
|
||||
Run with Blender 4.5 or later:
|
||||
blender --background --python assets/wallpapers/render.py
|
||||
|
||||
Pass -- --preview to render 960 x 540 previews instead.
|
||||
"""
|
||||
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
PREVIEW = "--preview" in sys.argv
|
||||
DESTINATION = Path("/tmp/ferese-wallpaper-render") if PREVIEW else ROOT
|
||||
|
||||
|
||||
def material(name, color, metallic=0.0, roughness=0.35):
|
||||
result = bpy.data.materials.new(name)
|
||||
result.use_nodes = True
|
||||
shader = result.node_tree.nodes.get("Principled BSDF")
|
||||
shader.inputs["Base Color"].default_value = (*color, 1.0)
|
||||
shader.inputs["Metallic"].default_value = metallic
|
||||
shader.inputs["Roughness"].default_value = roughness
|
||||
shader.inputs["Coat Weight"].default_value = 0.3
|
||||
shader.inputs["Coat Roughness"].default_value = 0.2
|
||||
return result
|
||||
|
||||
|
||||
def ribbon(name, phase, offset, surface):
|
||||
vertices, faces = [], []
|
||||
length_steps, width_steps = 360, 64
|
||||
for i in range(length_steps):
|
||||
t = i / length_steps
|
||||
angle = 2.0 * math.pi * t
|
||||
x = 2.0 + 5.4 * math.cos(angle)
|
||||
y = 3.5 * math.sin(angle)
|
||||
z = 0.8 * math.sin(2.0 * angle) + offset
|
||||
tangent = Vector((-5.4 * math.sin(angle), 3.5 * math.cos(angle), 0.0))
|
||||
normal = Vector((-tangent.y, tangent.x, 0.0)).normalized()
|
||||
width = 1.75 + 0.25 * math.cos(angle + phase)
|
||||
twist = angle + phase
|
||||
for j in range(width_steps + 1):
|
||||
u = 2.0 * j / width_steps - 1.0
|
||||
distance = width * u * math.cos(twist)
|
||||
curl = 0.25 * width * u * u
|
||||
vertices.append(
|
||||
(
|
||||
x + normal.x * distance,
|
||||
y + normal.y * distance,
|
||||
z + width * u * math.sin(twist) + curl,
|
||||
)
|
||||
)
|
||||
for i in range(length_steps):
|
||||
for j in range(width_steps):
|
||||
a = i * (width_steps + 1) + j
|
||||
b = ((i + 1) % length_steps) * (width_steps + 1) + j
|
||||
faces.append((a, a + 1, b + 1, b))
|
||||
mesh = bpy.data.meshes.new(name)
|
||||
mesh.from_pydata(vertices, [], faces)
|
||||
mesh.update()
|
||||
obj = bpy.data.objects.new(name, mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
obj.data.materials.append(surface)
|
||||
for polygon in mesh.polygons:
|
||||
polygon.use_smooth = True
|
||||
solid = obj.modifiers.new("Satin edge", "SOLIDIFY")
|
||||
solid.thickness = 0.028
|
||||
bevel = obj.modifiers.new("Soft edge", "BEVEL")
|
||||
bevel.width = 0.025
|
||||
bevel.segments = 3
|
||||
return obj
|
||||
|
||||
|
||||
def area(name, position, color, power, size, target=(1.0, 0.0, 0.0)):
|
||||
light = bpy.data.lights.new(name, "AREA")
|
||||
light.energy = power
|
||||
light.color = color
|
||||
light.shape = "RECTANGLE"
|
||||
light.size = size
|
||||
light.size_y = size * 2.0
|
||||
obj = bpy.data.objects.new(name, light)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
obj.location = position
|
||||
obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler()
|
||||
|
||||
|
||||
def render(appearance):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = "CYCLES"
|
||||
scene.cycles.samples = 24 if PREVIEW else 64
|
||||
scene.cycles.use_denoising = True
|
||||
scene.cycles.adaptive_threshold = 0.03
|
||||
scene.cycles.max_bounces = 6
|
||||
scene.render.resolution_x = 960 if PREVIEW else 3840
|
||||
scene.render.resolution_y = 540 if PREVIEW else 2160
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = "PNG"
|
||||
scene.render.image_settings.color_mode = "RGB"
|
||||
scene.render.image_settings.color_depth = "8"
|
||||
scene.render.film_transparent = False
|
||||
scene.view_settings.view_transform = "AgX"
|
||||
scene.view_settings.look = "AgX - Medium High Contrast"
|
||||
|
||||
dark = appearance == "dark"
|
||||
world = bpy.data.worlds.new("Studio")
|
||||
world.use_nodes = True
|
||||
world.node_tree.nodes["Background"].inputs["Color"].default_value = (
|
||||
(0.11, 0.16, 0.27, 1.0) if dark else (0.75, 0.82, 0.95, 1.0)
|
||||
)
|
||||
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.35 if dark else 0.55
|
||||
scene.world = world
|
||||
|
||||
pearl = material("Pearl satin", (0.12, 0.43, 0.66) if dark else (0.56, 0.75, 0.95), 0.45, 0.28)
|
||||
blue = material("Blue satin", (0.014, 0.085, 0.30) if dark else (0.24, 0.46, 0.74), 0.55, 0.3)
|
||||
backdrop = bpy.data.materials.new("Quiet gradient")
|
||||
backdrop.use_nodes = True
|
||||
nodes, links = backdrop.node_tree.nodes, backdrop.node_tree.links
|
||||
nodes.clear()
|
||||
geometry = nodes.new("ShaderNodeNewGeometry")
|
||||
distance = nodes.new("ShaderNodeVectorMath")
|
||||
distance.operation = "DISTANCE"
|
||||
distance.inputs[1].default_value = (5.0, 2.0, -4.0)
|
||||
links.new(geometry.outputs["Position"], distance.inputs[0])
|
||||
radius = nodes.new("ShaderNodeMath")
|
||||
radius.operation = "DIVIDE"
|
||||
radius.inputs[1].default_value = 18.0
|
||||
links.new(distance.outputs["Value"], radius.inputs[0])
|
||||
ramp = nodes.new("ShaderNodeValToRGB")
|
||||
ramp.color_ramp.interpolation = "EASE"
|
||||
ramp.color_ramp.elements[0].color = (0.022, 0.052, 0.11, 1.0) if dark else (1.5, 1.7, 2.0, 1.0)
|
||||
ramp.color_ramp.elements[1].color = (0.002, 0.006, 0.016, 1.0) if dark else (0.9, 1.15, 1.5, 1.0)
|
||||
links.new(radius.outputs[0], ramp.inputs[0])
|
||||
emission = nodes.new("ShaderNodeEmission")
|
||||
links.new(ramp.outputs[0], emission.inputs["Color"])
|
||||
output = nodes.new("ShaderNodeOutputMaterial")
|
||||
links.new(emission.outputs[0], output.inputs["Surface"])
|
||||
|
||||
fold = ribbon("Satin fold", -0.6, 0.7, pearl)
|
||||
fold.data.materials.append(blue)
|
||||
fold.modifiers["Satin edge"].material_offset = 1
|
||||
bpy.ops.mesh.primitive_plane_add(size=200, location=(0.0, 0.0, -4.0))
|
||||
bpy.context.object.data.materials.append(backdrop)
|
||||
|
||||
area("Broad softbox", (-4.0, 3.0, 12.0), (0.81, 0.93, 1.0), 2400, 8.0)
|
||||
area("Upper rim", (7.0, 6.0, 8.0), (0.52, 0.82, 1.0), 1900, 6.0)
|
||||
area("Blue reflection", (1.0, -7.0, 5.0), (0.25, 0.46, 1.0) if dark else (0.75, 0.87, 1.0), 1100, 7.0)
|
||||
|
||||
camera = bpy.data.cameras.new("Wallpaper camera")
|
||||
camera.type = "ORTHO"
|
||||
camera.ortho_scale = 21.0
|
||||
obj = bpy.data.objects.new("Wallpaper camera", camera)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
obj.location = (-1.5, -7.0, 22.0)
|
||||
target = Vector((-1.5, 0.0, 0.0))
|
||||
obj.rotation_euler = (target - obj.location).to_track_quat("-Z", "Y").to_euler()
|
||||
scene.camera = obj
|
||||
DESTINATION.mkdir(parents=True, exist_ok=True)
|
||||
scene.render.filepath = str(DESTINATION / f"ferese-wallpaper-{appearance}.png")
|
||||
bpy.ops.render.render(write_still=True)
|
||||
Path(scene.render.filepath).chmod(0o644)
|
||||
|
||||
|
||||
for appearance in ("dark", "light"):
|
||||
render(appearance)
|
||||
@@ -5,7 +5,14 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
schemars.workspace = true
|
||||
ferese-activities = { path = "../crates/ferese-activities" }
|
||||
ferese-panel = { path = "../crates/ferese-panel" }
|
||||
ferese-shape.workspace = true
|
||||
ferese-config = { path = "../crates/ferese-config" }
|
||||
ab_glyph = "0.2.32"
|
||||
fontdb = "0.23.0"
|
||||
@@ -15,17 +22,26 @@ ferese-core = { path = "../crates/ferese-core" }
|
||||
ferese-ipc = { path = "../crates/ferese-ipc" }
|
||||
ferese-layout = { path = "../crates/ferese-layout" }
|
||||
ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] }
|
||||
futures-lite = "2.6.1"
|
||||
async-channel = "2.5.0"
|
||||
libc.workspace = true
|
||||
jiff.workspace = true
|
||||
notify = "8.2.0"
|
||||
zbus = "5.19.0"
|
||||
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
memmap2 = "0.9.11"
|
||||
smithay.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
xcursor.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[features]
|
||||
# Opt-in counters/histograms with a per-client exit summary.
|
||||
resize-metrics = []
|
||||
# One-shot KMS recovery checks; never enabled by the installer.
|
||||
drm-fault-injection = []
|
||||
@@ -0,0 +1,894 @@
|
||||
//! Session broker: verified process identities, one-shot policy deadlines and
|
||||
//! bounded long-poll subscribers. Process inspection runs on IPC workers.
|
||||
pub(crate) mod deadline;
|
||||
mod pomodoro;
|
||||
mod publication;
|
||||
use ferese_activities::{Data, Id, Owner, Source, State, Store, Template, Value};
|
||||
use ferese_ipc::Response;
|
||||
use ferese_ipc::activities::{Publish, Snapshot};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Rules {
|
||||
pub enabled: bool,
|
||||
pub matcher: ferese_config::activities::Matcher,
|
||||
}
|
||||
pub(crate) type SharedRules = Arc<RwLock<Arc<Rules>>>;
|
||||
|
||||
pub(crate) struct Engine {
|
||||
pub store: Store,
|
||||
pomodoro: Option<pomodoro::Timer>,
|
||||
pomodoro_settings: ferese_activities::pomodoro::Settings,
|
||||
alerts: Option<pomodoro::Alerts>,
|
||||
pub rules: SharedRules,
|
||||
pub monitor: Option<crate::ipc::activity_owner::Monitor>,
|
||||
process_enabled: bool,
|
||||
process_sender: Option<smithay::reexports::calloop::channel::SyncSender<crate::ipc::IpcEvent>>,
|
||||
process_watcher: Option<crate::activities_process::Watcher>,
|
||||
pub timer: Option<deadline::Deadline>,
|
||||
revision: u64,
|
||||
cached_snapshot: Option<Arc<Snapshot>>,
|
||||
publisher: Option<publication::Publisher>,
|
||||
watchers: BTreeMap<u64, (u64, SyncSender<Response>)>,
|
||||
locked: bool,
|
||||
rates: BTreeMap<Owner, (u64, u16)>,
|
||||
windows: BTreeMap<Id, u64>,
|
||||
results: BTreeMap<Id, ferese_config::activities::ResultText>,
|
||||
}
|
||||
impl Default for Engine {
|
||||
fn default() -> Self {
|
||||
let settings = ferese_config::activities::Settings::default();
|
||||
Self {
|
||||
pomodoro: None,
|
||||
pomodoro_settings: Default::default(),
|
||||
alerts: None,
|
||||
store: Store::new(
|
||||
ferese_ipc::activities::now_ms() ^ u64::from(std::process::id()) << 32,
|
||||
),
|
||||
rules: Arc::new(RwLock::new(Arc::new(Rules {
|
||||
enabled: false,
|
||||
matcher: ferese_config::activities::Matcher::try_new(&settings)
|
||||
.expect("built-in rules"),
|
||||
}))),
|
||||
monitor: None,
|
||||
process_enabled: false,
|
||||
process_sender: None,
|
||||
process_watcher: None,
|
||||
timer: None,
|
||||
revision: 1,
|
||||
cached_snapshot: None,
|
||||
publisher: publication::Publisher::new()
|
||||
.map_err(|error| tracing::warn!(%error, "activity snapshot worker unavailable"))
|
||||
.ok(),
|
||||
watchers: BTreeMap::new(),
|
||||
locked: false,
|
||||
rates: BTreeMap::new(),
|
||||
results: BTreeMap::new(),
|
||||
windows: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Engine {
|
||||
pub fn detect_process(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
matched: ferese_config::activities::Matched,
|
||||
output: Option<String>,
|
||||
fd: &std::os::fd::OwnedFd,
|
||||
) {
|
||||
if crate::ipc::activity_owner::exited(fd) {
|
||||
return;
|
||||
}
|
||||
// A dispatched detection is not an admission acknowledgement. Retry
|
||||
// starts after rejection, and refresh existing records without restarting
|
||||
// their eligibility delay or recency boost.
|
||||
if self.resolve(owner, "process", None).is_ok() {
|
||||
let _ = self.publish(
|
||||
owner,
|
||||
Publish::Update {
|
||||
local: "process".into(),
|
||||
handle: None,
|
||||
value: None,
|
||||
lease_ms: Some(5_000),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
let _ = self.publish(
|
||||
owner,
|
||||
Publish::Hook {
|
||||
local: "process".into(),
|
||||
command: String::new(),
|
||||
cwd: String::new(),
|
||||
},
|
||||
Some(matched),
|
||||
output,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configure(
|
||||
&mut self,
|
||||
settings: &ferese_config::activities::Settings,
|
||||
matcher: ferese_config::activities::Matcher,
|
||||
) {
|
||||
self.store.limits = settings.limits();
|
||||
self.pomodoro_settings = settings.pomodoro.clone();
|
||||
self.process_enabled = settings.sources.process_watch;
|
||||
*self.rules.write().expect("activity rules") = Arc::new(Rules {
|
||||
enabled: settings.sources.shell_hook,
|
||||
matcher,
|
||||
});
|
||||
self.refresh_process_source();
|
||||
self.changed();
|
||||
}
|
||||
pub fn attach_process_source(
|
||||
&mut self,
|
||||
sender: smithay::reexports::calloop::channel::SyncSender<crate::ipc::IpcEvent>,
|
||||
) {
|
||||
self.process_sender = Some(sender);
|
||||
self.refresh_process_source();
|
||||
}
|
||||
fn refresh_process_source(&mut self) {
|
||||
if !self.process_enabled {
|
||||
self.process_watcher = None;
|
||||
} else if self.process_watcher.is_none()
|
||||
&& let Some(sender) = &self.process_sender
|
||||
{
|
||||
match crate::activities_process::Watcher::start(self.rules.clone(), sender.clone()) {
|
||||
Ok(watcher) => self.process_watcher = Some(watcher),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "could not start optional activity process source")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn snapshot(&mut self) -> Arc<Snapshot> {
|
||||
if let Some(snapshot) = &self.cached_snapshot {
|
||||
return snapshot.clone();
|
||||
}
|
||||
let snapshot = Arc::new(Snapshot {
|
||||
version: ferese_ipc::activities::VERSION,
|
||||
revision: self.revision,
|
||||
now_ms: ferese_ipc::activities::now_ms(),
|
||||
limits: self.store.limits,
|
||||
activities: self
|
||||
.store
|
||||
.entries()
|
||||
.cloned()
|
||||
.chain(self.pomodoro.iter().map(|timer| timer.activity()))
|
||||
.collect(),
|
||||
pomodoro: self
|
||||
.pomodoro
|
||||
.as_ref()
|
||||
.map(|timer| ferese_ipc::activities::Pomodoro {
|
||||
id: timer.id.clone(),
|
||||
timer: timer.session.clone(),
|
||||
}),
|
||||
windows: self
|
||||
.windows
|
||||
.iter()
|
||||
.map(
|
||||
|(activity, window)| ferese_ipc::activities::ActivityWindow {
|
||||
activity: activity.clone(),
|
||||
window: *window,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
});
|
||||
self.cached_snapshot = Some(snapshot.clone());
|
||||
snapshot
|
||||
}
|
||||
pub fn remove_connection(&mut self, owner: u64) {
|
||||
self.watchers.remove(&owner);
|
||||
}
|
||||
pub fn set_locked(&mut self, locked: bool) {
|
||||
self.locked = locked;
|
||||
if locked {
|
||||
if let Some(publisher) = &self.publisher {
|
||||
publisher.revoke();
|
||||
}
|
||||
publication::locked(std::mem::take(&mut self.watchers).into_values().collect());
|
||||
}
|
||||
}
|
||||
fn deliver(&mut self, recipients: Vec<(u64, SyncSender<Response>)>) {
|
||||
if self.locked {
|
||||
publication::locked(recipients);
|
||||
return;
|
||||
}
|
||||
if recipients.is_empty() {
|
||||
return;
|
||||
}
|
||||
let snapshot = self.snapshot();
|
||||
if let Some(publisher) = &self.publisher {
|
||||
publisher.send(snapshot, recipients);
|
||||
} else {
|
||||
publication::unavailable(recipients);
|
||||
}
|
||||
}
|
||||
pub fn get(&mut self, request: u64, sender: SyncSender<Response>) {
|
||||
self.deliver(vec![(request, sender)]);
|
||||
}
|
||||
pub fn watch(
|
||||
&mut self,
|
||||
connection: u64,
|
||||
request: u64,
|
||||
since: u64,
|
||||
sender: SyncSender<Response>,
|
||||
) {
|
||||
if self.locked || since != self.revision {
|
||||
self.get(request, sender);
|
||||
} else if self.watchers.len() < 32 {
|
||||
self.watchers.insert(connection, (request, sender));
|
||||
} else {
|
||||
let _ = sender.try_send(Response::error(
|
||||
request,
|
||||
"subscriber_limit",
|
||||
"Activity subscriber limit",
|
||||
));
|
||||
}
|
||||
}
|
||||
pub fn owner_exit(&mut self, owner: Owner) {
|
||||
self.store.owner_exit(owner);
|
||||
self.rates.remove(&owner);
|
||||
self.changed();
|
||||
}
|
||||
pub fn changed(&mut self) {
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
self.cached_snapshot = None;
|
||||
self.results
|
||||
.retain(|id, _| self.store.entries().any(|entry| &entry.id == id));
|
||||
self.windows
|
||||
.retain(|id, _| self.store.entries().any(|entry| &entry.id == id));
|
||||
let recipients = std::mem::take(&mut self.watchers).into_values().collect();
|
||||
self.deliver(recipients);
|
||||
self.schedule();
|
||||
}
|
||||
pub fn tick(&mut self) {
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
self.store.advance(now);
|
||||
self.advance_pomodoro(now);
|
||||
self.changed();
|
||||
}
|
||||
pub fn schedule(&self) {
|
||||
if let Some(monitor) = &self.monitor {
|
||||
monitor.retain(
|
||||
&self
|
||||
.store
|
||||
.entries()
|
||||
.filter(|entry| entry.state == State::Running)
|
||||
.map(|entry| entry.id.owner)
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
if let Some(timer) = &self.timer {
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
if let Err(error) = timer.update(
|
||||
self.store
|
||||
.next_deadline(now)
|
||||
.into_iter()
|
||||
.chain(
|
||||
self.pomodoro
|
||||
.as_ref()
|
||||
.and_then(|timer| timer.session.deadline),
|
||||
)
|
||||
.min(),
|
||||
) {
|
||||
tracing::error!(%error, "Activity deadline could not be armed");
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn publish_verified(
|
||||
&mut self,
|
||||
verified: crate::ipc::activity_owner::VerifiedOwner,
|
||||
publish: Publish,
|
||||
matched: Option<ferese_config::activities::Matched>,
|
||||
output: Option<String>,
|
||||
window: Option<u64>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
// Admission and publication run in one compositor dispatch. A queued
|
||||
// request owns its pidfd, but is not yet subject to monitor pruning.
|
||||
let owner = self
|
||||
.monitor
|
||||
.as_ref()
|
||||
.ok_or("Activity owner monitor unavailable")?
|
||||
.register(verified)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let result = self
|
||||
.publish_from(owner, publish, matched, output, window)
|
||||
.map_err(str::to_owned);
|
||||
self.schedule();
|
||||
result
|
||||
}
|
||||
|
||||
pub fn publish(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
publish: Publish,
|
||||
matched: Option<ferese_config::activities::Matched>,
|
||||
output: Option<String>,
|
||||
) -> Result<serde_json::Value, &'static str> {
|
||||
self.publish_from(owner, publish, matched, output, None)
|
||||
}
|
||||
|
||||
fn publish_from(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
publish: Publish,
|
||||
matched: Option<ferese_config::activities::Matched>,
|
||||
output: Option<String>,
|
||||
window: Option<u64>,
|
||||
) -> Result<serde_json::Value, &'static str> {
|
||||
let started = match &publish {
|
||||
Publish::Start { local, .. }
|
||||
| Publish::Run { local, .. }
|
||||
| Publish::Hook { local, .. } => Some(local.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
if matches!(publish, Publish::Hook { .. }) && matched.is_none() {
|
||||
return Ok(json!({"matched":false}));
|
||||
}
|
||||
self.rates
|
||||
.retain(|_, (at, _)| now.saturating_sub(*at) < 60_000);
|
||||
if self.rates.len() >= 128 && !self.rates.contains_key(&owner) {
|
||||
return Err("owner publication quota exceeded");
|
||||
}
|
||||
let rate = self.rates.entry(owner).or_insert((now, 0));
|
||||
if now.saturating_sub(rate.0) >= 1_000 {
|
||||
*rate = (now, 0);
|
||||
}
|
||||
// Each connection waits for acknowledgement; excess replaceable updates
|
||||
// are explicitly rejected so terminal events retain capacity.
|
||||
if (matches!(publish, Publish::Update { .. }) && rate.1 >= 30)
|
||||
|| (matches!(
|
||||
publish,
|
||||
Publish::Start { .. } | Publish::Hook { .. } | Publish::Run { .. }
|
||||
) && rate.1 >= 10)
|
||||
{
|
||||
return Err("publication rate exceeded");
|
||||
}
|
||||
rate.1 = rate.1.saturating_add(1);
|
||||
// Expiration is a change even when the following publication is rejected.
|
||||
// Notify subscribers before a store mutation can silently sweep leases.
|
||||
if self.store.advance(now) {
|
||||
self.changed();
|
||||
}
|
||||
let result = match publish {
|
||||
Publish::Start {
|
||||
local,
|
||||
mut data,
|
||||
lease_ms,
|
||||
} => {
|
||||
if data.output.is_none() {
|
||||
data.output = output;
|
||||
}
|
||||
let id = self
|
||||
.store
|
||||
.start(owner, local, data, now, 0, lease_ms, true, true, None)?;
|
||||
json!({"handle":id})
|
||||
}
|
||||
Publish::Run { local, title, .. } => {
|
||||
if let Some(matched) = matched {
|
||||
let data = Data {
|
||||
source: if local == "process" {
|
||||
Source::Process
|
||||
} else {
|
||||
Source::Command
|
||||
},
|
||||
output,
|
||||
priority: matched.rule.priority,
|
||||
icon: matched.rule.icon.clone(),
|
||||
title: title.unwrap_or(matched.title),
|
||||
value: Value::Indeterminate,
|
||||
template: Template::Progress,
|
||||
};
|
||||
let id = self.store.start(
|
||||
owner,
|
||||
local,
|
||||
data,
|
||||
now,
|
||||
matched.rule.min_duration_ms,
|
||||
None,
|
||||
true,
|
||||
matched.rule.flash_on_fail,
|
||||
matched.group,
|
||||
)?;
|
||||
self.results.insert(id.clone(), matched.rule.result);
|
||||
json!({"handle":id,"progress":matched.rule.progress})
|
||||
} else {
|
||||
let data = Data {
|
||||
source: Source::Command,
|
||||
output,
|
||||
priority: 100,
|
||||
icon: "ferese".into(),
|
||||
title: title.unwrap_or_else(|| "Command".into()),
|
||||
value: Value::Indeterminate,
|
||||
template: Template::Progress,
|
||||
};
|
||||
let id = self
|
||||
.store
|
||||
.start(owner, local, data, now, 0, None, true, true, None)?;
|
||||
json!({"handle":id,"progress":"indeterminate"})
|
||||
}
|
||||
}
|
||||
Publish::Hook { local, .. } => {
|
||||
let Some(matched) = matched else {
|
||||
return Ok(json!({"matched":false}));
|
||||
};
|
||||
let data = Data {
|
||||
source: if local == "process" {
|
||||
Source::Process
|
||||
} else {
|
||||
Source::Command
|
||||
},
|
||||
output,
|
||||
priority: matched.rule.priority,
|
||||
icon: matched.rule.icon.clone(),
|
||||
title: matched.title,
|
||||
value: Value::Indeterminate,
|
||||
template: Template::Progress,
|
||||
};
|
||||
let lease = (local == "process").then_some(5_000);
|
||||
let id = self.store.start(
|
||||
owner,
|
||||
local,
|
||||
data,
|
||||
now,
|
||||
matched.rule.min_duration_ms,
|
||||
lease,
|
||||
true,
|
||||
matched.rule.flash_on_fail,
|
||||
matched.group,
|
||||
)?;
|
||||
self.results.insert(id.clone(), matched.rule.result);
|
||||
json!({"matched":true,"handle":id,"progress":matched.rule.progress})
|
||||
}
|
||||
Publish::Update {
|
||||
local,
|
||||
handle,
|
||||
value,
|
||||
lease_ms,
|
||||
} => {
|
||||
let id = self.resolve(owner, &local, handle)?;
|
||||
self.store.update(owner, &id, value, lease_ms, now)?;
|
||||
json!({})
|
||||
}
|
||||
Publish::End {
|
||||
local,
|
||||
handle,
|
||||
result,
|
||||
message,
|
||||
} => {
|
||||
let id = self.resolve(owner, &local, handle)?;
|
||||
let message = message.or_else(|| {
|
||||
self.results.get(&id).map(|text| {
|
||||
if result == State::Done {
|
||||
text.ok.clone()
|
||||
} else {
|
||||
text.fail.clone()
|
||||
}
|
||||
})
|
||||
});
|
||||
self.store.finish(owner, &id, result, message, now)?;
|
||||
json!({})
|
||||
}
|
||||
Publish::Cancel { local, handle } => {
|
||||
let id = self.resolve(owner, &local, handle)?;
|
||||
self.store.finish(owner, &id, State::Cancelled, None, now)?;
|
||||
json!({})
|
||||
}
|
||||
};
|
||||
if let Some(local) = started
|
||||
&& let Some(id) = self.store.resolve(owner, &local)
|
||||
&& let Some(window) = window
|
||||
{
|
||||
self.windows.insert(id.clone(), window);
|
||||
}
|
||||
self.changed();
|
||||
Ok(result)
|
||||
}
|
||||
fn resolve(&self, owner: Owner, local: &str, handle: Option<Id>) -> Result<Id, &'static str> {
|
||||
match handle {
|
||||
Some(id) if id.local == local => Ok(id),
|
||||
Some(_) => Err("handle does not match local ID"),
|
||||
None => self
|
||||
.store
|
||||
.resolve(owner, local)
|
||||
.cloned()
|
||||
.ok_or("unknown owner-scoped activity"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Ferese {
|
||||
pub(crate) fn activity_window(
|
||||
&self,
|
||||
owner: &crate::ipc::activity_owner::VerifiedOwner,
|
||||
) -> Option<u64> {
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
let mut ancestors = owner.ancestor_pids().peekable();
|
||||
ancestors.peek()?;
|
||||
let windows: Vec<_> = self
|
||||
.windows
|
||||
.records()
|
||||
.filter_map(|(id, record)| {
|
||||
let client = record.surface.as_ref()?.client()?;
|
||||
let pid =
|
||||
u32::try_from(client.get_credentials(&self.display_handle).ok()?.pid).ok()?;
|
||||
Some((pid, id.0))
|
||||
})
|
||||
.collect();
|
||||
origin_window(ancestors, &windows)
|
||||
}
|
||||
}
|
||||
|
||||
fn origin_window(ancestors: impl Iterator<Item = u32>, windows: &[(u32, u64)]) -> Option<u64> {
|
||||
for pid in ancestors {
|
||||
let mut matches = windows.iter().filter(|(client, _)| *client == pid);
|
||||
if let Some((_, window)) = matches.next() {
|
||||
// A terminal server may own several windows. Never guess from focus.
|
||||
return matches.next().is_none().then_some(*window);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn owner() -> Owner {
|
||||
Owner { pid: 12, birth: 3 }
|
||||
}
|
||||
fn data() -> Data {
|
||||
Data {
|
||||
source: Source::Publisher,
|
||||
output: None,
|
||||
priority: 255,
|
||||
icon: "hammer".into(),
|
||||
title: "Build".into(),
|
||||
value: Value::Indeterminate,
|
||||
template: Template::Progress,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn session_lock_cancels_pending_and_rejects_new_activity_reads() {
|
||||
let mut engine = Engine::default();
|
||||
let revision = engine.snapshot().revision;
|
||||
let (send, receive) = std::sync::mpsc::sync_channel(1);
|
||||
engine.watch(1, 42, revision, send);
|
||||
engine.set_locked(true);
|
||||
let reply = receive.recv().unwrap();
|
||||
assert_eq!(reply.id, 42);
|
||||
assert_eq!(reply.error.unwrap().code, "session_locked");
|
||||
assert!(reply.result.is_none());
|
||||
engine.changed();
|
||||
for since in [revision, engine.revision] {
|
||||
let (send, receive) = std::sync::mpsc::sync_channel(1);
|
||||
engine.watch(2, 43, since, send);
|
||||
assert_eq!(
|
||||
receive.recv().unwrap().error.unwrap().code,
|
||||
"session_locked"
|
||||
);
|
||||
}
|
||||
let (send, receive) = std::sync::mpsc::sync_channel(1);
|
||||
engine.get(44, send);
|
||||
assert_eq!(
|
||||
receive.recv().unwrap().error.unwrap().code,
|
||||
"session_locked"
|
||||
);
|
||||
engine.set_locked(false);
|
||||
let (send, receive) = std::sync::mpsc::sync_channel(1);
|
||||
engine.watch(3, 45, revision, send);
|
||||
assert!(
|
||||
receive
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap()
|
||||
.result
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_updates_do_not_leave_a_snapshot_from_before_lease_expiry() {
|
||||
let mut engine = Engine::default();
|
||||
let id = engine
|
||||
.store
|
||||
.start(
|
||||
owner(),
|
||||
"expired".into(),
|
||||
data(),
|
||||
ferese_ipc::activities::now_ms().saturating_sub(5_000),
|
||||
0,
|
||||
Some(1_000),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let before = engine.snapshot();
|
||||
assert_eq!(before.activities.len(), 1);
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
engine.watch(1, 11, before.revision, sender);
|
||||
assert!(
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::Update {
|
||||
local: "expired".into(),
|
||||
handle: Some(id),
|
||||
value: None,
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(engine.snapshot().activities.is_empty());
|
||||
let response = receiver.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let snapshot: Snapshot = serde_json::from_value(response.result.unwrap()).unwrap();
|
||||
assert!(snapshot.activities.is_empty());
|
||||
assert!(snapshot.revision > before.revision);
|
||||
assert_eq!(before.activities.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_are_lazy_shared_and_immutable_across_publications() {
|
||||
let mut engine = Engine::default();
|
||||
engine.changed();
|
||||
assert!(
|
||||
engine.cached_snapshot.is_none(),
|
||||
"no subscriber needs a snapshot"
|
||||
);
|
||||
let before = engine.snapshot();
|
||||
assert!(Arc::ptr_eq(&before, &engine.snapshot()));
|
||||
let (first, first_reply) = std::sync::mpsc::sync_channel(1);
|
||||
let (second, second_reply) = std::sync::mpsc::sync_channel(1);
|
||||
engine.watch(1, 11, before.revision, first);
|
||||
engine.watch(2, 22, before.revision, second);
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::Start {
|
||||
local: "build".into(),
|
||||
data: data(),
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let after = engine.snapshot();
|
||||
assert!(!Arc::ptr_eq(&before, &after));
|
||||
assert!(before.activities.is_empty());
|
||||
assert_eq!(after.activities.len(), 1);
|
||||
for (id, reply) in [(11, first_reply), (22, second_reply)] {
|
||||
let reply = reply.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
assert_eq!(reply.id, id);
|
||||
let snapshot: Snapshot = serde_json::from_value(reply.result.unwrap()).unwrap();
|
||||
assert_eq!(snapshot.revision, after.revision);
|
||||
assert_eq!(snapshot.activities[0].id, after.activities[0].id);
|
||||
}
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
engine.watch(3, 33, before.revision, sender);
|
||||
assert_eq!(
|
||||
receiver.recv_timeout(Duration::from_secs(2)).unwrap().id,
|
||||
33
|
||||
);
|
||||
assert!(Arc::ptr_eq(&after, &engine.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_that_exits_before_detection_dispatch_is_not_published() {
|
||||
let mut child = std::process::Command::new("sleep")
|
||||
.arg("30")
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let (owner, fd, _) = crate::ipc::activity_owner::inspect(child.id()).unwrap();
|
||||
let matched = ferese_config::activities::Matcher::try_new(&Default::default())
|
||||
.unwrap()
|
||||
.matches("cargo test", "/tmp", &[])
|
||||
.unwrap();
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
let mut engine = Engine::default();
|
||||
engine.detect_process(owner, matched, None, &fd);
|
||||
assert_eq!(engine.store.entries().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_process_detection_is_retried_without_restarting_admitted_records() {
|
||||
let mut engine = Engine::default();
|
||||
let (owner, fd, _) = crate::ipc::activity_owner::inspect(std::process::id()).unwrap();
|
||||
let settings = ferese_config::activities::Settings::default();
|
||||
let matched = ferese_config::activities::Matcher::try_new(&settings)
|
||||
.unwrap()
|
||||
.matches("cargo test", "/tmp/test", &[])
|
||||
.unwrap();
|
||||
engine.store.limits.max_total = 0;
|
||||
engine.detect_process(owner, matched.clone(), None, &fd);
|
||||
assert_eq!(engine.store.entries().count(), 0);
|
||||
engine.store.limits.max_total = 128;
|
||||
engine.detect_process(owner, matched.clone(), None, &fd);
|
||||
let admitted = engine.store.entries().next().unwrap().clone();
|
||||
engine.detect_process(owner, matched, None, &fd);
|
||||
let refreshed = engine.store.entries().next().unwrap();
|
||||
assert_eq!(refreshed.id, admitted.id);
|
||||
assert_eq!(refreshed.eligible_at, admitted.eligible_at);
|
||||
assert_eq!(engine.store.entries().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_matching_uses_nearest_unique_ancestor_and_never_guesses() {
|
||||
let ancestry = [400, 300, 200, 100];
|
||||
assert_eq!(
|
||||
origin_window(ancestry.into_iter(), &[(200, 7), (100, 8)]),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(
|
||||
origin_window(ancestry.into_iter(), &[(200, 7), (200, 9), (100, 8)]),
|
||||
None
|
||||
);
|
||||
assert_eq!(origin_window(ancestry.into_iter(), &[(900, 7)]), None);
|
||||
assert_eq!(origin_window([].into_iter(), &[(200, 7)]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_window_survives_updates_and_results_then_expires() {
|
||||
let mut engine = Engine::default();
|
||||
engine.store.limits.start_delay_ms = 0;
|
||||
engine
|
||||
.publish_from(
|
||||
owner(),
|
||||
Publish::Run {
|
||||
local: "build".into(),
|
||||
title: Some("Build".into()),
|
||||
command: String::new(),
|
||||
cwd: String::new(),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
Some(42),
|
||||
)
|
||||
.unwrap();
|
||||
let id = engine.store.entries().next().unwrap().id.clone();
|
||||
assert_eq!(engine.snapshot().windows[0].window, 42);
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::Update {
|
||||
local: "build".into(),
|
||||
handle: Some(id.clone()),
|
||||
value: Some(Value::Progress(0.5)),
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(engine.snapshot().windows[0].window, 42);
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::End {
|
||||
local: "build".into(),
|
||||
handle: Some(id.clone()),
|
||||
result: State::Done,
|
||||
message: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
engine.owner_exit(owner());
|
||||
assert_eq!(engine.snapshot().windows[0].activity, id);
|
||||
engine
|
||||
.store
|
||||
.advance(ferese_ipc::activities::now_ms() + 2_000);
|
||||
engine.changed();
|
||||
assert!(engine.snapshot().windows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_shot_disconnect_does_not_own_lifetime_and_rate_limit_preserves_completion() {
|
||||
let mut engine = Engine::default();
|
||||
let result = engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::Start {
|
||||
local: "build".into(),
|
||||
data: data(),
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
Some("first".into()),
|
||||
)
|
||||
.unwrap();
|
||||
let id: Id = serde_json::from_value(result["handle"].clone()).unwrap();
|
||||
engine.remove_connection(1);
|
||||
assert_eq!(engine.store.entries().count(), 1);
|
||||
for _ in 0..40 {
|
||||
let _ = engine.publish(
|
||||
owner(),
|
||||
Publish::Update {
|
||||
local: "build".into(),
|
||||
handle: Some(id.clone()),
|
||||
value: Some(Value::Progress(0.5)),
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
Some("other".into()),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
engine
|
||||
.store
|
||||
.entries()
|
||||
.next()
|
||||
.unwrap()
|
||||
.data
|
||||
.output
|
||||
.as_deref(),
|
||||
Some("first")
|
||||
);
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::End {
|
||||
local: "build".into(),
|
||||
handle: Some(id),
|
||||
result: State::Done,
|
||||
message: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
engine.store.entries().count(),
|
||||
0,
|
||||
"completion before delay never flashes"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn rule_is_pinned_across_atomic_reload_and_no_raw_metadata_is_published() {
|
||||
let settings = ferese_config::activities::Settings::default();
|
||||
let matched = ferese_config::activities::Matcher::try_new(&settings)
|
||||
.unwrap()
|
||||
.matches("cargo test --secret", "/secret/cwd", &[])
|
||||
.unwrap();
|
||||
let mut engine = Engine::default();
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::Run {
|
||||
local: "build".into(),
|
||||
title: None,
|
||||
command: String::new(),
|
||||
cwd: String::new(),
|
||||
},
|
||||
Some(matched),
|
||||
Some("first".into()),
|
||||
)
|
||||
.unwrap();
|
||||
let before = engine.store.entries().next().unwrap().clone();
|
||||
let mut replacement = settings.clone();
|
||||
replacement.start_delay_ms = 5_000;
|
||||
replacement.rules[0].title = "Replacement".into();
|
||||
engine.configure(
|
||||
&replacement,
|
||||
ferese_config::activities::Matcher::try_new(&replacement).unwrap(),
|
||||
);
|
||||
let after = engine.store.entries().next().unwrap();
|
||||
assert_eq!(after.data.title, "cargo test");
|
||||
assert_eq!(after.eligible_at, before.eligible_at);
|
||||
let serialized = serde_json::to_string(engine.snapshot().as_ref()).unwrap();
|
||||
assert!(!serialized.contains("secret"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Activity deadlines share CLOCK_BOOTTIME with the published countdowns. A
|
||||
//! monotonic calloop timer would otherwise keep waiting for time spent asleep.
|
||||
use smithay::reexports::calloop::generic::Generic;
|
||||
use smithay::reexports::calloop::{Interest, LoopHandle, Mode, PostAction, RegistrationToken};
|
||||
use std::io;
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||||
|
||||
pub(crate) struct Deadline {
|
||||
fd: OwnedFd,
|
||||
armed: std::cell::Cell<Option<u64>>,
|
||||
handle: LoopHandle<'static, crate::Ferese>,
|
||||
token: RegistrationToken,
|
||||
}
|
||||
impl Deadline {
|
||||
pub fn new(
|
||||
handle: LoopHandle<'static, crate::Ferese>,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
// SAFETY: timerfd_create returns a new owned descriptor or -1.
|
||||
let fd = unsafe {
|
||||
libc::timerfd_create(libc::CLOCK_BOOTTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error().into());
|
||||
}
|
||||
// SAFETY: timerfd_create succeeded above; this fresh descriptor transfers to one OwnedFd.
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
|
||||
let token = handle.insert_source(
|
||||
Generic::new(fd.try_clone()?, Interest::READ, Mode::Level),
|
||||
|_, fd, state| {
|
||||
let mut expirations = 0_u64;
|
||||
// SAFETY: expirations is a writable eight-byte timerfd counter.
|
||||
let result =
|
||||
unsafe { libc::read(fd.as_raw_fd(), (&raw mut expirations).cast(), 8) };
|
||||
if result < 0 {
|
||||
let error = io::Error::last_os_error();
|
||||
if error.kind() != io::ErrorKind::WouldBlock {
|
||||
return Err(error);
|
||||
}
|
||||
} else {
|
||||
state.activities.tick();
|
||||
}
|
||||
Ok(PostAction::Continue)
|
||||
},
|
||||
)?;
|
||||
Ok(Self {
|
||||
fd,
|
||||
armed: std::cell::Cell::new(None),
|
||||
handle,
|
||||
token,
|
||||
})
|
||||
}
|
||||
pub fn update(&self, deadline: Option<u64>) -> io::Result<()> {
|
||||
if self.armed.get() == deadline {
|
||||
return Ok(());
|
||||
}
|
||||
let ms = deadline.unwrap_or(0);
|
||||
let spec = libc::itimerspec {
|
||||
it_interval: libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
},
|
||||
it_value: libc::timespec {
|
||||
tv_sec: (ms / 1_000) as _,
|
||||
tv_nsec: ((ms % 1_000) * 1_000_000) as _,
|
||||
},
|
||||
};
|
||||
// SAFETY: spec is initialized; null old_value is permitted.
|
||||
if unsafe {
|
||||
libc::timerfd_settime(
|
||||
self.fd.as_raw_fd(),
|
||||
libc::TFD_TIMER_ABSTIME,
|
||||
&spec,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
} < 0
|
||||
{
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
self.armed.set(deadline);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Deadline {
|
||||
fn drop(&mut self) {
|
||||
self.handle.remove(self.token);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn deadline_uses_boottime_and_disarming_leaves_no_periodic_wakeups() {
|
||||
let events = smithay::reexports::calloop::EventLoop::<crate::Ferese>::try_new().unwrap();
|
||||
let deadline = Deadline::new(events.handle()).unwrap();
|
||||
let info =
|
||||
std::fs::read_to_string(format!("/proc/self/fdinfo/{}", deadline.fd.as_raw_fd()))
|
||||
.unwrap();
|
||||
assert!(
|
||||
info.lines()
|
||||
.any(|line| line.split_whitespace().collect::<Vec<_>>() == ["clockid:", "7"]),
|
||||
"{info}"
|
||||
);
|
||||
deadline
|
||||
.update(Some(ferese_ipc::activities::now_ms() + 20))
|
||||
.unwrap();
|
||||
let mut pollfd = libc::pollfd {
|
||||
fd: deadline.fd.as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
// SAFETY: pollfd is a valid array of one descriptor.
|
||||
assert_eq!(unsafe { libc::poll(&raw mut pollfd, 1, 500) }, 1);
|
||||
let mut counter = 0_u64;
|
||||
assert_eq!(
|
||||
// SAFETY: counter is writable for the requested eight bytes and deadline owns the
|
||||
// timer descriptor.
|
||||
unsafe { libc::read(deadline.fd.as_raw_fd(), (&raw mut counter).cast(), 8) },
|
||||
8
|
||||
);
|
||||
assert_eq!(counter, 1);
|
||||
deadline.update(None).unwrap();
|
||||
// SAFETY: pollfd is a live writable pollfd and the entry count is one.
|
||||
assert_eq!(unsafe { libc::poll(&raw mut pollfd, 1, 0) }, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
use super::{Data, Duration, Engine, Id, Source, State, Template, Value};
|
||||
#[cfg(test)]
|
||||
use ferese_activities::Owner;
|
||||
|
||||
use ferese_activities::pomodoro::{Session, Status};
|
||||
use ferese_ipc::activities::{PomodoroAction, PomodoroRequest};
|
||||
|
||||
pub(super) struct Timer {
|
||||
pub id: Id,
|
||||
pub session: Session,
|
||||
output: Option<String>,
|
||||
created_at: u64,
|
||||
}
|
||||
impl Timer {
|
||||
pub fn activity(&self) -> ferese_activities::Activity {
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
let value = if let Some(deadline) = self.session.deadline {
|
||||
Value::Countdown {
|
||||
remaining_ms: deadline.saturating_sub(now),
|
||||
sampled_at: now,
|
||||
}
|
||||
} else {
|
||||
Value::PausedCountdown {
|
||||
remaining_ms: self.session.remaining_ms,
|
||||
}
|
||||
};
|
||||
ferese_activities::Activity {
|
||||
id: self.id.clone(),
|
||||
data: Data {
|
||||
source: Source::Timer,
|
||||
output: self.output.clone(),
|
||||
priority: 100,
|
||||
icon: "timer".into(),
|
||||
title: format!(
|
||||
"Pomodoro · {}{}",
|
||||
self.session.phase.label(),
|
||||
match self.session.status {
|
||||
Status::Paused => " · Paused",
|
||||
Status::Ready => " · Ready",
|
||||
Status::Finished => " · Finished",
|
||||
_ => "",
|
||||
}
|
||||
),
|
||||
value,
|
||||
template: Template::Timer,
|
||||
},
|
||||
state: State::Running,
|
||||
created_at: self.created_at,
|
||||
eligible_at: self.created_at,
|
||||
refreshed_at: self.created_at,
|
||||
lease_deadline: None,
|
||||
result_deadline: None,
|
||||
result_message: None,
|
||||
flash_on_fail: false,
|
||||
result_flash_ms: 0,
|
||||
group: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Engine {
|
||||
pub fn pomodoro_control(
|
||||
&mut self,
|
||||
request: PomodoroRequest,
|
||||
output: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
if request.version != ferese_ipc::activities::VERSION {
|
||||
return Err("Unsupported activity schema".into());
|
||||
}
|
||||
if let Some(id) = &request.session
|
||||
&& self.pomodoro.as_ref().is_none_or(|timer| &timer.id != id)
|
||||
{
|
||||
return Err("Pomodoro session changed; refresh its controls".into());
|
||||
}
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
if !matches!(request.action, PomodoroAction::Stop | PomodoroAction::Reset)
|
||||
&& self.advance_pomodoro(now)
|
||||
{
|
||||
self.changed();
|
||||
}
|
||||
match request.action {
|
||||
PomodoroAction::Status => {}
|
||||
PomodoroAction::Start | PomodoroAction::Reset => {
|
||||
if request.action == PomodoroAction::Start
|
||||
&& self
|
||||
.pomodoro
|
||||
.as_ref()
|
||||
.is_some_and(|timer| timer.session.status != Status::Finished)
|
||||
{
|
||||
return Err("A Pomodoro session already exists; resume or reset it".into());
|
||||
}
|
||||
let session = Session::start(self.pomodoro_settings.clone(), now)?;
|
||||
// Verify the long-lived host, never the short-lived CLI, as owner.
|
||||
let (owner, _pidfd, _) = crate::ipc::activity_owner::inspect(std::process::id())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let id = Id {
|
||||
owner,
|
||||
local: "pomodoro".into(),
|
||||
generation: self.revision,
|
||||
session: self.store.session(),
|
||||
};
|
||||
self.pomodoro = Some(Timer {
|
||||
id,
|
||||
session,
|
||||
output,
|
||||
created_at: now,
|
||||
});
|
||||
}
|
||||
PomodoroAction::Stop => {
|
||||
self.pomodoro = None;
|
||||
}
|
||||
action => {
|
||||
let timer = self
|
||||
.pomodoro
|
||||
.as_mut()
|
||||
.ok_or("No Pomodoro session; start one first")?;
|
||||
match action {
|
||||
PomodoroAction::Pause => timer.session.pause(now)?,
|
||||
PomodoroAction::Resume => timer.session.resume(now)?,
|
||||
PomodoroAction::Next => timer.session.next(now)?,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
if request.action != PomodoroAction::Status {
|
||||
self.changed();
|
||||
}
|
||||
let snapshot = self.snapshot();
|
||||
serde_json::to_value(&snapshot.pomodoro).map_err(|error| error.to_string())
|
||||
}
|
||||
pub(super) fn advance_pomodoro(&mut self, now: u64) -> bool {
|
||||
let Some(timer) = &mut self.pomodoro else {
|
||||
return false;
|
||||
};
|
||||
let Some(completion) = timer.session.advance(now) else {
|
||||
return false;
|
||||
};
|
||||
if timer.session.settings.notifications {
|
||||
let title = format!("Pomodoro: {} complete", completion.phase.label());
|
||||
let body = completion.next.map_or_else(
|
||||
|| "Your Pomodoro session is finished.".into(),
|
||||
|phase| {
|
||||
format!(
|
||||
"{} {}.",
|
||||
phase.label(),
|
||||
if timer.session.status == Status::Running {
|
||||
"has started"
|
||||
} else {
|
||||
"is ready"
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
let alerts = self.alerts.get_or_insert_with(Alerts::new);
|
||||
if alerts.sender.try_send((title, body)).is_err() {
|
||||
tracing::warn!("Pomodoro alert queue unavailable");
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
pub fn pomodoro_remove_output(&mut self, output: &str, fallback: Option<&str>) {
|
||||
if let Some(timer) = &mut self.pomodoro
|
||||
&& timer.output.as_deref() == Some(output)
|
||||
{
|
||||
timer.output = fallback.map(str::to_owned);
|
||||
}
|
||||
}
|
||||
pub fn pomodoro_attach_output(&mut self, output: &str) -> bool {
|
||||
if let Some(timer) = &mut self.pomodoro
|
||||
&& timer.output.is_none()
|
||||
{
|
||||
timer.output = Some(output.into());
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Notification delivery runs off the compositor thread and is attempted once.
|
||||
/// Reconnection never replays an alert; the notification server applies DND and
|
||||
/// lock-screen privacy, just as it does for other applications.
|
||||
pub(super) struct Alerts {
|
||||
sender: std::sync::mpsc::SyncSender<(String, String)>,
|
||||
}
|
||||
impl Alerts {
|
||||
fn new() -> Self {
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel::<(String, String)>(16);
|
||||
std::thread::spawn(move || {
|
||||
while let Ok((title, body)) = receiver.recv() {
|
||||
let result = (|| -> zbus::Result<()> {
|
||||
let connection = zbus::blocking::connection::Builder::session()?
|
||||
.method_timeout(Duration::from_secs(2))
|
||||
.build()?;
|
||||
let mut hints =
|
||||
std::collections::HashMap::<&str, zbus::zvariant::Value<'_>>::new();
|
||||
hints.insert("suppress-sound", zbus::zvariant::Value::Bool(true));
|
||||
let reply = connection.call_method(
|
||||
Some("org.freedesktop.Notifications"),
|
||||
"/org/freedesktop/Notifications",
|
||||
Some("org.freedesktop.Notifications"),
|
||||
"Notify",
|
||||
&(
|
||||
"Ferese",
|
||||
0_u32,
|
||||
"",
|
||||
title,
|
||||
body,
|
||||
Vec::<String>::new(),
|
||||
hints,
|
||||
-1_i32,
|
||||
),
|
||||
)?;
|
||||
let _: u32 = reply.body().deserialize()?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(%error,"Pomodoro completion alert could not be delivered");
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { sender }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn control(engine: &mut Engine, action: PomodoroAction) -> Result<serde_json::Value, String> {
|
||||
engine.pomodoro_control(
|
||||
PomodoroRequest {
|
||||
version: ferese_ipc::activities::VERSION,
|
||||
action,
|
||||
session: None,
|
||||
},
|
||||
Some("test".into()),
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn completion_uses_the_standard_notification_service_without_sound() {
|
||||
const TEST: &str = "activities::pomodoro::tests::completion_uses_the_standard_notification_service_without_sound";
|
||||
if !crate::startup_tests::private_runtime(TEST) {
|
||||
return;
|
||||
}
|
||||
use std::io::BufRead;
|
||||
struct Bus(std::process::Child);
|
||||
impl Drop for Bus {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
let mut bus = Bus(std::process::Command::new("dbus-daemon")
|
||||
.args(["--session", "--nofork", "--print-address=1"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap());
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(bus.0.stdout.take().unwrap())
|
||||
.read_line(&mut address)
|
||||
.unwrap();
|
||||
// SAFETY: private_runtime runs only this test in a separate process;
|
||||
// set the bus before starting the notification service or alert worker.
|
||||
unsafe {
|
||||
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", address.trim());
|
||||
}
|
||||
struct Notifications(std::sync::mpsc::Sender<(String, String, bool)>);
|
||||
#[zbus::interface(name = "org.freedesktop.Notifications")]
|
||||
impl Notifications {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn notify(
|
||||
&self,
|
||||
_app_name: &str,
|
||||
_replaces_id: u32,
|
||||
_app_icon: &str,
|
||||
summary: &str,
|
||||
body: &str,
|
||||
_actions: Vec<String>,
|
||||
hints: std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
_expire_timeout: i32,
|
||||
) -> u32 {
|
||||
let silent = hints
|
||||
.get("suppress-sound")
|
||||
.and_then(|value| bool::try_from(value).ok())
|
||||
.unwrap_or(false);
|
||||
self.0.send((summary.into(), body.into(), silent)).unwrap();
|
||||
1
|
||||
}
|
||||
}
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let _service = zbus::blocking::connection::Builder::address(address.trim())
|
||||
.unwrap()
|
||||
.serve_at("/org/freedesktop/Notifications", Notifications(sender))
|
||||
.unwrap()
|
||||
.name("org.freedesktop.Notifications")
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
let mut engine = Engine::default();
|
||||
control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
let deadline = engine.pomodoro.as_ref().unwrap().session.deadline.unwrap();
|
||||
engine.advance_pomodoro(deadline);
|
||||
let (title, body, silent) = receiver.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
assert_eq!(title, "Pomodoro: Work complete");
|
||||
assert_eq!(body, "Short break is ready.");
|
||||
assert!(silent);
|
||||
engine.advance_pomodoro(deadline + 1);
|
||||
assert!(receiver.try_recv().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn host_owns_the_single_session_and_stale_popup_controls_are_rejected() {
|
||||
let mut engine = Engine::default();
|
||||
let first = control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
let first: ferese_ipc::activities::Pomodoro = serde_json::from_value(first).unwrap();
|
||||
assert_eq!(first.id.owner.pid, std::process::id());
|
||||
assert!(control(&mut engine, PomodoroAction::Start).is_err());
|
||||
engine.remove_connection(10);
|
||||
engine.owner_exit(Owner {
|
||||
pid: 999_999,
|
||||
birth: 0,
|
||||
});
|
||||
assert_eq!(engine.snapshot().pomodoro.as_ref().unwrap().id, first.id);
|
||||
assert_eq!(engine.snapshot().activities.len(), 1);
|
||||
control(&mut engine, PomodoroAction::Reset).unwrap();
|
||||
assert_ne!(engine.snapshot().pomodoro.as_ref().unwrap().id, first.id);
|
||||
assert!(
|
||||
engine
|
||||
.pomodoro_control(
|
||||
PomodoroRequest {
|
||||
version: ferese_ipc::activities::VERSION,
|
||||
action: PomodoroAction::Stop,
|
||||
session: Some(first.id)
|
||||
},
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
control(&mut engine, PomodoroAction::Stop).unwrap();
|
||||
assert!(engine.snapshot().pomodoro.is_none());
|
||||
assert!(engine.snapshot().activities.is_empty());
|
||||
assert!(Engine::default().snapshot().pomodoro.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn completions_are_delivered_once_and_cancellation_does_not_alert() {
|
||||
let mut engine = Engine::default();
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel(16);
|
||||
engine.alerts = Some(Alerts { sender });
|
||||
control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
let deadline = engine.pomodoro.as_ref().unwrap().session.deadline.unwrap();
|
||||
assert!(engine.advance_pomodoro(deadline));
|
||||
assert!(!engine.advance_pomodoro(deadline + 1));
|
||||
let (title, body) = receiver.try_recv().unwrap();
|
||||
assert_eq!(title, "Pomodoro: Work complete");
|
||||
assert_eq!(body, "Short break is ready.");
|
||||
assert!(receiver.try_recv().is_err());
|
||||
control(&mut engine, PomodoroAction::Reset).unwrap();
|
||||
engine.pomodoro.as_mut().unwrap().session.deadline = Some(1);
|
||||
control(&mut engine, PomodoroAction::Stop).unwrap();
|
||||
assert!(receiver.try_recv().is_err());
|
||||
control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
engine.pomodoro.as_mut().unwrap().session.deadline = Some(1);
|
||||
control(&mut engine, PomodoroAction::Reset).unwrap();
|
||||
assert!(receiver.try_recv().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn paused_snapshot_is_constant_and_output_loss_routes_the_timer() {
|
||||
let mut engine = Engine::default();
|
||||
control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
control(&mut engine, PomodoroAction::Pause).unwrap();
|
||||
let snapshot = engine.snapshot();
|
||||
let activity = &snapshot.activities[0];
|
||||
assert!(activity.data.title.contains("Paused"));
|
||||
assert_eq!(
|
||||
activity.data.value.remaining_ms(0),
|
||||
activity.data.value.remaining_ms(u64::MAX)
|
||||
);
|
||||
engine.pomodoro_remove_output("test", None);
|
||||
assert!(engine.pomodoro_attach_output("new"));
|
||||
engine.changed();
|
||||
assert_eq!(
|
||||
engine.snapshot().activities[0].data.output.as_deref(),
|
||||
Some("new")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn disabled_alerts_and_schema_errors_have_no_side_effects() {
|
||||
let mut engine = Engine::default();
|
||||
engine.pomodoro_settings.notifications = false;
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
engine.alerts = Some(Alerts { sender });
|
||||
control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
assert!(
|
||||
engine
|
||||
.pomodoro_control(
|
||||
PomodoroRequest {
|
||||
version: 99,
|
||||
action: PomodoroAction::Stop,
|
||||
session: None
|
||||
},
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let deadline = engine.pomodoro.as_ref().unwrap().session.deadline.unwrap();
|
||||
engine.advance_pomodoro(deadline);
|
||||
assert!(receiver.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Bounded serialization and delivery outside the compositor event loop.
|
||||
use ferese_ipc::Response;
|
||||
use ferese_ipc::activities::Snapshot;
|
||||
use std::sync::mpsc::{self, SyncSender};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
type Recipient = (u64, SyncSender<Response>);
|
||||
struct Job {
|
||||
epoch: u64,
|
||||
snapshot: Arc<Snapshot>,
|
||||
recipients: Vec<Recipient>,
|
||||
}
|
||||
pub(super) struct Publisher(SyncSender<Job>, Arc<RwLock<u64>>);
|
||||
impl Publisher {
|
||||
pub fn new() -> std::io::Result<Self> {
|
||||
let (send, receive) = mpsc::sync_channel::<Job>(8);
|
||||
let epoch = Arc::new(RwLock::new(0));
|
||||
let worker_epoch = epoch.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("ferese-activity-snapshots".into())
|
||||
.spawn(move || {
|
||||
let mut cached: Option<(Arc<Snapshot>, serde_json::Value)> = None;
|
||||
while let Ok(job) = receive.recv() {
|
||||
if !cached
|
||||
.as_ref()
|
||||
.is_some_and(|(snapshot, _)| Arc::ptr_eq(snapshot, &job.snapshot))
|
||||
{
|
||||
cached = Some((
|
||||
job.snapshot.clone(),
|
||||
serde_json::json!(job.snapshot.as_ref()),
|
||||
));
|
||||
}
|
||||
let value = &cached
|
||||
.as_ref()
|
||||
.expect("activity serialization populated the cache for this revision")
|
||||
.1;
|
||||
// Lock acquisition revokes queued/in-flight serialization.
|
||||
// Hold the guard through delivery to order sends before revocation.
|
||||
deliver(job.epoch, &worker_epoch, job.recipients, value);
|
||||
}
|
||||
})?;
|
||||
Ok(Self(send, epoch))
|
||||
}
|
||||
|
||||
pub fn revoke(&self) {
|
||||
let mut epoch = self.1.write().expect("activity publication epoch");
|
||||
*epoch = epoch.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn send(&self, snapshot: Arc<Snapshot>, recipients: Vec<Recipient>) {
|
||||
let epoch = *self.1.read().expect("activity publication epoch");
|
||||
if let Err(error) = self.0.try_send(Job {
|
||||
epoch,
|
||||
snapshot,
|
||||
recipients,
|
||||
}) {
|
||||
let (mpsc::TrySendError::Full(job) | mpsc::TrySendError::Disconnected(job)) = error;
|
||||
unavailable(job.recipients);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn deliver(
|
||||
epoch: u64,
|
||||
current: &RwLock<u64>,
|
||||
recipients: Vec<Recipient>,
|
||||
value: &serde_json::Value,
|
||||
) {
|
||||
let current = current.read().expect("activity publication epoch");
|
||||
if epoch != *current {
|
||||
locked(recipients);
|
||||
return;
|
||||
}
|
||||
for (id, sender) in recipients {
|
||||
// A slow/disconnected client cannot block another subscriber.
|
||||
let _ = sender.try_send(Response::success(id, value.clone()));
|
||||
}
|
||||
}
|
||||
pub(super) fn locked(recipients: Vec<Recipient>) {
|
||||
for (id, sender) in recipients {
|
||||
let _ = sender.try_send(Response::error(id, "session_locked", "Session is locked"));
|
||||
}
|
||||
}
|
||||
pub(super) fn unavailable(recipients: Vec<Recipient>) {
|
||||
for (id, sender) in recipients {
|
||||
let _ = sender.try_send(Response::error(
|
||||
id,
|
||||
"activity_busy",
|
||||
"Activity snapshot delivery unavailable; retry",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn lock_revokes_serialized_jobs_even_after_unlock() {
|
||||
let (send, _receive) = mpsc::sync_channel(1);
|
||||
let publisher = Publisher(send, Arc::new(RwLock::new(0)));
|
||||
let job_epoch = *publisher.1.read().unwrap();
|
||||
publisher.revoke();
|
||||
let (reply, receive) = mpsc::sync_channel(1);
|
||||
deliver(
|
||||
job_epoch,
|
||||
&publisher.1,
|
||||
vec![(9, reply)],
|
||||
&serde_json::json!({"title": "private"}),
|
||||
);
|
||||
let response = receive.recv().unwrap();
|
||||
assert_eq!(response.id, 9);
|
||||
assert!(response.result.is_none());
|
||||
assert_eq!(response.error.unwrap().code, "session_locked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturated_queue_rejects_without_blocking_and_slow_clients_do_not_block_others() {
|
||||
let (send, _receive) = mpsc::sync_channel(0);
|
||||
let publisher = Publisher(send, Arc::new(RwLock::new(0)));
|
||||
let (reply, receive) = mpsc::sync_channel(1);
|
||||
publisher.send(Arc::new(Snapshot::default()), vec![(7, reply)]);
|
||||
let response = receive.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
assert_eq!(response.id, 7);
|
||||
assert_eq!(response.error.unwrap().code, "activity_busy");
|
||||
|
||||
let publisher = Publisher::new().unwrap();
|
||||
let (slow, _slow_receive) = mpsc::sync_channel(1);
|
||||
slow.send(Response::success(1, serde_json::json!({})))
|
||||
.unwrap();
|
||||
let (fast, fast_receive) = mpsc::sync_channel(1);
|
||||
publisher.send(Arc::new(Snapshot::default()), vec![(2, slow), (3, fast)]);
|
||||
assert_eq!(
|
||||
fast_receive
|
||||
.recv_timeout(Duration::from_secs(1))
|
||||
.unwrap()
|
||||
.id,
|
||||
3
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Explicitly enabled fallback. Bounded /proc sampling and regex work stay on
|
||||
//! one worker; disappearance emits owner exit, never an inferred exit status.
|
||||
use crate::ipc::IpcEvent;
|
||||
use ferese_activities::Owner;
|
||||
use smithay::reexports::calloop::channel;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) struct Watcher(Arc<AtomicBool>);
|
||||
impl Drop for Watcher {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
impl Watcher {
|
||||
pub fn start(
|
||||
rules: crate::activities::SharedRules,
|
||||
sender: channel::SyncSender<IpcEvent>,
|
||||
) -> std::io::Result<Self> {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let worker_stop = stop.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("ferese-activity-processes".into())
|
||||
.spawn(move || {
|
||||
let mut observed = BTreeMap::<
|
||||
Owner,
|
||||
(
|
||||
Arc<std::os::fd::OwnedFd>,
|
||||
ferese_config::activities::Matched,
|
||||
),
|
||||
>::new();
|
||||
while !worker_stop.load(Ordering::Acquire) {
|
||||
let rules = rules.read().expect("activity rules").clone();
|
||||
let dead: Vec<_> = observed
|
||||
.iter()
|
||||
.filter_map(|(owner, (fd, _))| {
|
||||
crate::ipc::activity_owner::exited(fd).then_some(*owner)
|
||||
})
|
||||
.collect();
|
||||
for owner in dead {
|
||||
observed.remove(&owner);
|
||||
if sender.send(IpcEvent::ActivityOwnerExited(owner)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Reconcile every detection: admission may have failed or its lease expired.
|
||||
for (owner, (fd, matched)) in &observed {
|
||||
let _ = sender.try_send(IpcEvent::ActivityDetected(
|
||||
*owner,
|
||||
Box::new(matched.clone()),
|
||||
fd.clone(),
|
||||
));
|
||||
}
|
||||
if let Ok(processes) = std::fs::read_dir("/proc") {
|
||||
for process in processes.take(4_096).flatten() {
|
||||
if worker_stop.load(Ordering::Acquire) || observed.len() >= 128 {
|
||||
break;
|
||||
}
|
||||
let Some(pid) = process
|
||||
.file_name()
|
||||
.to_str()
|
||||
.and_then(|name| name.parse::<u32>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok((owner, fd, parents)) = crate::ipc::activity_owner::inspect(pid)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if observed.contains_key(&owner)
|
||||
|| parents.iter().any(|parent| parent == "feresectl")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(process.path().join("cmdline"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
if file.take(4_097).read_to_end(&mut bytes).is_err()
|
||||
|| bytes.len() > 4_096
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(command) = String::from_utf8(bytes) else {
|
||||
continue;
|
||||
};
|
||||
let command = command.trim_end_matches('\0').replace('\0', " ");
|
||||
let Ok(cwd) = std::fs::read_link(process.path().join("cwd")) else {
|
||||
continue;
|
||||
};
|
||||
let cwd = cwd.to_string_lossy();
|
||||
let Some(matched) = rules.matcher.matches(&command, &cwd, &parents)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let fd = Arc::new(fd);
|
||||
// Keep the inspected process identity alive through dispatch.
|
||||
// No raw command, cwd or environment crosses the dispatcher.
|
||||
if sender
|
||||
.try_send(IpcEvent::ActivityDetected(
|
||||
owner,
|
||||
Box::new(matched.clone()),
|
||||
fd.clone(),
|
||||
))
|
||||
.is_ok()
|
||||
{
|
||||
observed.insert(owner, (fd, matched));
|
||||
}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
})?;
|
||||
Ok(Self(stop))
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,381 @@
|
||||
use smithay::backend::allocator::Fourcc;
|
||||
use smithay::backend::renderer::damage::{Error as DamageError, OutputDamageTracker};
|
||||
use smithay::backend::renderer::element::RenderElement;
|
||||
use smithay::backend::renderer::gles::{GlesError, GlesRenderer, GlesTarget, GlesTexture};
|
||||
use smithay::backend::renderer::{Bind, Offscreen, Texture};
|
||||
use smithay::output::Output;
|
||||
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
|
||||
use std::fmt::Display;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::render::{redraw_output, sampled_output_elements};
|
||||
use crate::render::{capture_output_elements, redraw_output};
|
||||
use crate::state::FrameScene;
|
||||
|
||||
pub(super) fn capture_output(
|
||||
const CACHE_IDLE: Duration = Duration::from_secs(2);
|
||||
|
||||
pub(crate) mod changes;
|
||||
|
||||
pub(crate) struct CaptureTarget {
|
||||
texture: GlesTexture,
|
||||
visible: Option<smithay::backend::renderer::element::RenderElementStates>,
|
||||
damage: OutputDamageTracker,
|
||||
output: Output,
|
||||
revision: u64,
|
||||
id: smithay::backend::renderer::element::Id,
|
||||
commit: smithay::backend::renderer::utils::CommitCounter,
|
||||
stamp: Option<changes::Stamp>,
|
||||
preparations: u64,
|
||||
reuses: u64,
|
||||
compositions: u64,
|
||||
trace: bool,
|
||||
reported: Instant,
|
||||
}
|
||||
|
||||
impl CaptureTarget {
|
||||
pub(crate) fn surface_visible(
|
||||
&self,
|
||||
surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
|
||||
) -> bool {
|
||||
self.visible
|
||||
.as_ref()
|
||||
.and_then(|states| states.element_render_state(surface))
|
||||
.is_some_and(crate::display_presentation::is_visible)
|
||||
}
|
||||
|
||||
pub(crate) fn image(&self) -> (GlesTexture, u64) {
|
||||
(self.texture.clone(), self.revision)
|
||||
}
|
||||
|
||||
pub(crate) fn new(texture: GlesTexture, output: &Output) -> Self {
|
||||
Self {
|
||||
texture,
|
||||
visible: None,
|
||||
damage: OutputDamageTracker::from_output(output),
|
||||
output: output.clone(),
|
||||
revision: 0,
|
||||
id: smithay::backend::renderer::element::Id::new(),
|
||||
commit: Default::default(),
|
||||
stamp: None,
|
||||
preparations: 0,
|
||||
reuses: 0,
|
||||
compositions: 0,
|
||||
trace: std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some(),
|
||||
reported: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn touch_cache(state: &mut Ferese) {
|
||||
if state.capture_last_used.replace(Instant::now()).is_some() {
|
||||
return;
|
||||
}
|
||||
if let Err(error) =
|
||||
state
|
||||
.loop_handle
|
||||
.insert_source(Timer::from_duration(CACHE_IDLE), |_, _, state| {
|
||||
let Some(last_used) = state.capture_last_used else {
|
||||
return TimeoutAction::Drop;
|
||||
};
|
||||
let idle = last_used.elapsed();
|
||||
if idle < CACHE_IDLE {
|
||||
return TimeoutAction::ToDuration(CACHE_IDLE - idle);
|
||||
}
|
||||
if let Some(backend) = state.nested_backend.clone()
|
||||
&& let Ok(mut backend) = backend.try_borrow_mut()
|
||||
{
|
||||
crate::render::clear_stream_capture_targets(backend.renderer());
|
||||
}
|
||||
if let Some(backend) = state.direct_backend.as_mut() {
|
||||
backend.clear_stream_capture_targets();
|
||||
}
|
||||
if !state.pending_screencopies.is_empty() || state.image_capture.has_sessions() {
|
||||
// A damage-waiting stream must retain the comparison image.
|
||||
return TimeoutAction::ToDuration(CACHE_IDLE);
|
||||
}
|
||||
// A stream can pause or fail without another repaint. Release its
|
||||
// offscreen targets and filtered-scene caches from the event loop.
|
||||
state.capture_last_used = None;
|
||||
state.capture_render = Default::default();
|
||||
state.nested_capture_texture = Default::default();
|
||||
if let Some(backend) = state.direct_backend.as_mut() {
|
||||
for device in backend.devices.values_mut() {
|
||||
for output in device.outputs.values_mut() {
|
||||
output.capture_texture = Default::default();
|
||||
output.mirror_capture_texture = Default::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
TimeoutAction::Drop
|
||||
})
|
||||
{
|
||||
state.capture_last_used = None;
|
||||
tracing::warn!(%error, "could not schedule capture cache cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn capture_output(
|
||||
state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
texture: &mut Option<GlesTexture>,
|
||||
textures: &mut [Option<CaptureTarget>; 2],
|
||||
output: &Output,
|
||||
scene: &FrameScene,
|
||||
) -> Result<(), GlesError> {
|
||||
) {
|
||||
for include_cursor in [true, false] {
|
||||
if !state.has_pending_screencopy(output, include_cursor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sample the same scene into an independent target: the displayed
|
||||
// primary buffer may omit both a scanned-out client and hardware cursor.
|
||||
let elements = sampled_output_elements(state, renderer, output, include_cursor, scene);
|
||||
let target = render_capture(renderer, texture, output, &elements)?;
|
||||
state.process_screencopies(renderer, &target, output, include_cursor);
|
||||
let texture = &mut textures[usize::from(include_cursor)];
|
||||
if !prepare_capture(
|
||||
state,
|
||||
renderer,
|
||||
texture,
|
||||
output,
|
||||
output,
|
||||
include_cursor,
|
||||
scene,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let texture = texture.as_mut().expect("capture target allocated");
|
||||
let Some((target, revision)) = capture_request(state, output, include_cursor, || {
|
||||
renderer
|
||||
.bind(&mut texture.texture)
|
||||
.map(|target| (target, texture.revision))
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
state.process_screencopies_with_revision(
|
||||
renderer,
|
||||
&target,
|
||||
output,
|
||||
include_cursor,
|
||||
Some(revision),
|
||||
);
|
||||
drop(target);
|
||||
state.process_output_streams(renderer, &texture.texture, output, include_cursor, revision);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_capture<'a, E: RenderElement<GlesRenderer>>(
|
||||
pub(super) fn capture_mirror(
|
||||
state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
source: &Output,
|
||||
scene: &FrameScene,
|
||||
output: &mut super::DirectOutput,
|
||||
) {
|
||||
for include_cursor in [true, false] {
|
||||
if !state.has_pending_screencopy(&output.output, include_cursor) {
|
||||
continue;
|
||||
}
|
||||
// Never sample mirror_texture: it contains the unfiltered display.
|
||||
let canvas = output
|
||||
.mirror_canvas
|
||||
.as_ref()
|
||||
.expect("mirror display composed");
|
||||
super::mirror::configure_canvas(canvas, source);
|
||||
let source_target = &mut output.mirror_capture_texture[usize::from(include_cursor)];
|
||||
if !prepare_capture(
|
||||
state,
|
||||
renderer,
|
||||
source_target,
|
||||
source,
|
||||
canvas,
|
||||
include_cursor,
|
||||
scene,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let source_target = source_target
|
||||
.as_ref()
|
||||
.expect("mirror capture target allocated");
|
||||
let element = super::mirror::fitted_texture(
|
||||
source_target.texture.clone(),
|
||||
&output.output,
|
||||
source_target.id.clone(),
|
||||
source_target.commit,
|
||||
);
|
||||
let Some((target, revision)) =
|
||||
capture_request(state, &output.output, include_cursor, || {
|
||||
render_capture_with_revision(
|
||||
renderer,
|
||||
&mut output.capture_texture[usize::from(include_cursor)],
|
||||
&output.output,
|
||||
&[element],
|
||||
)
|
||||
})
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
state.process_screencopies_with_revision(
|
||||
renderer,
|
||||
&target,
|
||||
&output.output,
|
||||
include_cursor,
|
||||
Some(revision),
|
||||
);
|
||||
drop(target);
|
||||
state.process_output_streams(
|
||||
renderer,
|
||||
&output.capture_texture[usize::from(include_cursor)]
|
||||
.as_ref()
|
||||
.expect("capture target allocated")
|
||||
.texture
|
||||
.clone(),
|
||||
&output.output,
|
||||
include_cursor,
|
||||
revision,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Display damage cannot prove a filtered scene is unchanged: an opaque private
|
||||
/// window may hide updates that must remain visible to capture underneath it.
|
||||
/// Retain a separate source revision and only prepare the filtered scene on a
|
||||
/// real mutation. Delivery buffers still receive a complete image.
|
||||
fn prepare_capture(
|
||||
state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
target: &mut Option<CaptureTarget>,
|
||||
source: &Output,
|
||||
destination: &Output,
|
||||
include_cursor: bool,
|
||||
scene: &FrameScene,
|
||||
) -> bool {
|
||||
touch_cache(state);
|
||||
let stamp = changes::Stamp::new(state, source, include_cursor);
|
||||
let reusable = target.as_ref().is_some_and(|target| {
|
||||
target.stamp.as_ref() == Some(&stamp)
|
||||
&& target.output == *destination
|
||||
&& destination
|
||||
.current_mode()
|
||||
.is_some_and(|mode| target.texture.size() == (mode.size.w, mode.size.h).into())
|
||||
});
|
||||
if reusable {
|
||||
target.as_mut().expect("cached capture target").reuses += 1;
|
||||
} else {
|
||||
// Keep the capture image separate from the display swapchain and planes.
|
||||
let elements = capture_output_elements(state, renderer, source, include_cursor, scene);
|
||||
let Some(framebuffer) = capture_request(state, destination, include_cursor, || {
|
||||
render_capture_with_revision(renderer, target, destination, &elements)
|
||||
}) else {
|
||||
// A failed render can have changed the retained pixels already.
|
||||
if let Some(target) = target.as_mut() {
|
||||
target.stamp = None;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
drop(framebuffer);
|
||||
let target = target.as_mut().expect("capture target allocated");
|
||||
target.stamp = Some(stamp);
|
||||
target.preparations += 1;
|
||||
}
|
||||
let target = target.as_mut().expect("capture target allocated");
|
||||
if target.trace && target.reported.elapsed() >= Duration::from_secs(5) {
|
||||
tracing::info!(target: "ferese::render", output = %source.name(), include_cursor,
|
||||
preparations = target.preparations, scene_reuses = target.reuses,
|
||||
compositions = target.compositions, "capture scene outcomes");
|
||||
target.reported = Instant::now();
|
||||
target.preparations = 0;
|
||||
target.reuses = 0;
|
||||
target.compositions = 0;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn capture_request<T, E: Display>(
|
||||
state: &mut Ferese,
|
||||
output: &Output,
|
||||
include_cursor: bool,
|
||||
render: impl FnOnce() -> Result<T, E>,
|
||||
) -> Option<T> {
|
||||
match render() {
|
||||
Ok(target) => Some(target),
|
||||
Err(error) => {
|
||||
tracing::warn!(?output, include_cursor, %error, "screencopy render failed");
|
||||
state.fail_screencopies(output, include_cursor);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn after_queue<T, E>(queued: Result<T, E>, capture: impl FnOnce()) -> Result<T, E> {
|
||||
let queued = queued?;
|
||||
capture();
|
||||
Ok(queued)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn render_capture<'a, E: RenderElement<GlesRenderer>>(
|
||||
renderer: &mut GlesRenderer,
|
||||
target: &'a mut Option<CaptureTarget>,
|
||||
output: &Output,
|
||||
elements: &[E],
|
||||
) -> Result<GlesTarget<'a>, DamageError<GlesError>> {
|
||||
render_capture_with_revision(renderer, target, output, elements).map(|(target, _)| target)
|
||||
}
|
||||
|
||||
pub(crate) fn render_capture_with_revision<'a, E: RenderElement<GlesRenderer>>(
|
||||
renderer: &mut GlesRenderer,
|
||||
target: &'a mut Option<CaptureTarget>,
|
||||
output: &Output,
|
||||
elements: &[E],
|
||||
) -> Result<(GlesTarget<'a>, u64), DamageError<GlesError>> {
|
||||
let size = output.current_mode().expect("output has a mode").size;
|
||||
if target
|
||||
.as_ref()
|
||||
.is_none_or(|target| target.texture.size() != (size.w, size.h).into())
|
||||
{
|
||||
*target = Some(CaptureTarget::new(
|
||||
Offscreen::<GlesTexture>::create_buffer(
|
||||
renderer,
|
||||
Fourcc::Abgr8888,
|
||||
(size.w, size.h).into(),
|
||||
)
|
||||
.map_err(DamageError::Rendering)?,
|
||||
output,
|
||||
));
|
||||
}
|
||||
let target = target.as_mut().expect("capture target allocated");
|
||||
if target.output != *output {
|
||||
target.damage = OutputDamageTracker::from_output(output);
|
||||
target.output = output.clone();
|
||||
}
|
||||
// This texture retains the previous capture, independently of display
|
||||
// scanout and damage. Age one lets unchanged pixels survive; a new target
|
||||
// has no history and is repainted in full. Rebind after every readback.
|
||||
let mut framebuffer = renderer
|
||||
.bind(&mut target.texture)
|
||||
.map_err(DamageError::Rendering)?;
|
||||
let result = target.damage.render_output(
|
||||
renderer,
|
||||
&mut framebuffer,
|
||||
1,
|
||||
elements,
|
||||
[0.035, 0.04, 0.055, 1.0],
|
||||
)?;
|
||||
if result
|
||||
.damage
|
||||
.as_ref()
|
||||
.is_some_and(|damage| !damage.is_empty())
|
||||
{
|
||||
static NEXT_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
target.commit.increment();
|
||||
target.compositions += 1;
|
||||
target.revision = NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
if output
|
||||
.user_data()
|
||||
.get::<crate::handlers::workspace_capture::CaptureOutputId>()
|
||||
.is_some()
|
||||
{
|
||||
target.visible = Some(result.states);
|
||||
}
|
||||
Ok((framebuffer, target.revision))
|
||||
}
|
||||
|
||||
pub(super) fn render_texture<'a, E: RenderElement<GlesRenderer>>(
|
||||
renderer: &mut GlesRenderer,
|
||||
texture: &'a mut Option<GlesTexture>,
|
||||
output: &Output,
|
||||
@@ -69,14 +414,363 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an EGL rendering device"]
|
||||
fn capture_recomposes_cursor_without_touching_the_display_target() {
|
||||
let device = EGLDevice::enumerate().unwrap().last().expect("an EGL device");
|
||||
fn unchanged_capture_skips_preparation_without_losing_source_updates() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"backends::direct::capture::tests::unchanged_capture_skips_preparation_without_losing_source_updates",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
|
||||
let mut config = crate::config::Config::default().runtime_config().unwrap();
|
||||
config.wallpaper.path = None;
|
||||
config.animations_enabled = false;
|
||||
let mut state = Ferese::new(
|
||||
&mut events,
|
||||
smithay::reexports::wayland_server::Display::new().unwrap(),
|
||||
config,
|
||||
)
|
||||
.unwrap();
|
||||
let output = Output::new(
|
||||
"capture-cache".into(),
|
||||
PhysicalProperties {
|
||||
serial_number: String::new(),
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
output.change_current_state(
|
||||
Some(Mode {
|
||||
size: (320, 240).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
Some(Transform::Normal),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
state.space.map_output(&output, (0, 0));
|
||||
state.register_output(&output, "capture-cache".into());
|
||||
let (window, _client) = crate::state::test_window(&mut state, &mut events, 0xffff0000);
|
||||
state.advance_animations(Instant::now());
|
||||
let device = EGLDevice::enumerate().unwrap().last().expect("EGL device");
|
||||
// SAFETY: the device and display outlive this exclusively owned context.
|
||||
let display = unsafe { EGLDisplay::new(device).unwrap() };
|
||||
let context = EGLContext::new(&display).unwrap();
|
||||
// SAFETY: this test thread exclusively owns the fresh context.
|
||||
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
|
||||
let mut target = None;
|
||||
let prepare = |state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
target: &mut Option<CaptureTarget>| {
|
||||
let scene = state.sample_frame(&output, Duration::ZERO);
|
||||
assert!(prepare_capture(
|
||||
state, renderer, target, &output, &output, false, &scene
|
||||
));
|
||||
};
|
||||
let pixels = |renderer: &mut GlesRenderer, target: &mut Option<CaptureTarget>| {
|
||||
let target = target.as_mut().unwrap();
|
||||
let framebuffer = renderer.bind(&mut target.texture).unwrap();
|
||||
let mapping = renderer
|
||||
.copy_framebuffer(
|
||||
&framebuffer,
|
||||
Rectangle::from_size((320, 240).into()),
|
||||
Fourcc::Abgr8888,
|
||||
)
|
||||
.unwrap();
|
||||
drop(framebuffer);
|
||||
renderer.map_texture(&mapping).unwrap().to_vec()
|
||||
};
|
||||
prepare(&mut state, &mut renderer, &mut target);
|
||||
// Keep assertions independent of the opt-in periodic reporting clock.
|
||||
target.as_mut().unwrap().trace = false;
|
||||
let original = pixels(&mut renderer, &mut target);
|
||||
for _ in 0..100 {
|
||||
super::super::schedule_capture(&mut state, std::slice::from_ref(&output));
|
||||
prepare(&mut state, &mut renderer, &mut target);
|
||||
}
|
||||
assert_eq!(target.as_ref().unwrap().preparations, 1);
|
||||
assert_eq!(target.as_ref().unwrap().reuses, 100);
|
||||
assert_eq!(pixels(&mut renderer, &mut target), original);
|
||||
let toplevel = window.toplevel().unwrap();
|
||||
smithay::wayland::compositor::with_states(toplevel.wl_surface(), |states| {
|
||||
states
|
||||
.data_map
|
||||
.get::<smithay::wayland::shell::xdg::XdgToplevelSurfaceData>()
|
||||
.unwrap()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.title = Some("Metadata without new pixels".into());
|
||||
});
|
||||
<Ferese as smithay::wayland::shell::xdg::XdgShellHandler>::title_changed(
|
||||
&mut state,
|
||||
toplevel.clone(),
|
||||
);
|
||||
prepare(&mut state, &mut renderer, &mut target);
|
||||
assert_eq!(
|
||||
target.as_ref().unwrap().preparations,
|
||||
1,
|
||||
"a title change must reuse the captured pixels"
|
||||
);
|
||||
assert_eq!(pixels(&mut renderer, &mut target), original);
|
||||
super::super::render_cursor(&mut state);
|
||||
prepare(&mut state, &mut renderer, &mut target);
|
||||
assert_eq!(target.as_ref().unwrap().preparations, 1);
|
||||
state.capture_epoch += 1;
|
||||
prepare(&mut state, &mut renderer, &mut target);
|
||||
assert_eq!(target.as_ref().unwrap().preparations, 2);
|
||||
let (_cover, _cover_client) =
|
||||
crate::state::test_window(&mut state, &mut events, 0xff00ff00);
|
||||
state.advance_animations(Instant::now());
|
||||
prepare(&mut state, &mut renderer, &mut target);
|
||||
assert_eq!(target.as_ref().unwrap().preparations, 3);
|
||||
assert_ne!(pixels(&mut renderer, &mut target), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an EGL rendering device"]
|
||||
fn incremental_capture_matches_full_redraw_after_scene_and_output_changes() {
|
||||
let device = EGLDevice::enumerate().unwrap().last().expect("EGL device");
|
||||
// SAFETY: the enumerated device remains alive through its EGL display.
|
||||
let display = unsafe { EGLDisplay::new(device).unwrap() };
|
||||
let context = EGLContext::new(&display).unwrap();
|
||||
// SAFETY: this thread exclusively owns the renderer and its fresh context.
|
||||
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
|
||||
let output = |name: &str| {
|
||||
Output::new(
|
||||
name.into(),
|
||||
PhysicalProperties {
|
||||
serial_number: String::new(),
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
)
|
||||
};
|
||||
let outputs = [output("first"), output("second")];
|
||||
let mut capture = None;
|
||||
let mut reference = None;
|
||||
let background = SolidColorRenderElement::new(
|
||||
Id::new(),
|
||||
Rectangle::from_size((96, 96).into()),
|
||||
CommitCounter::default(),
|
||||
Color32F::new(0., 0., 1., 1.),
|
||||
Kind::Unspecified,
|
||||
);
|
||||
let item_id = Id::new();
|
||||
for output in outputs.iter().chain(std::iter::once(&outputs[0])) {
|
||||
for (width, height) in [(64, 48), (48, 64)] {
|
||||
for scale in [1., 1.25, 1.5] {
|
||||
for transform in [
|
||||
Transform::Normal,
|
||||
Transform::_90,
|
||||
Transform::_180,
|
||||
Transform::Flipped,
|
||||
] {
|
||||
output.change_current_state(
|
||||
Some(Mode {
|
||||
size: (width, height).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
Some(transform),
|
||||
Some(smithay::output::Scale::Fractional(scale)),
|
||||
None,
|
||||
);
|
||||
let mut commit = CommitCounter::default();
|
||||
// Include moves, color/alpha changes, removal, cursor
|
||||
// inclusion/exclusion and an entirely empty scene.
|
||||
for step in [6, 0, 1, 2, 3, 4, 5, 6] {
|
||||
if step == 2 {
|
||||
commit.increment();
|
||||
}
|
||||
let mut elements = Vec::new();
|
||||
if matches!(step, 0..=2 | 4) {
|
||||
elements.push(SolidColorRenderElement::new(
|
||||
item_id.clone(),
|
||||
Rectangle::new(
|
||||
(if step == 0 { 4 } else { 20 }, 4).into(),
|
||||
(12, 12).into(),
|
||||
),
|
||||
commit,
|
||||
if step < 2 {
|
||||
Color32F::new(0., 1., 0., 1.)
|
||||
} else {
|
||||
Color32F::new(0.5, 0., 0., 0.5)
|
||||
},
|
||||
Kind::Cursor,
|
||||
));
|
||||
}
|
||||
if step != 6 {
|
||||
elements.push(background.clone());
|
||||
}
|
||||
drop(
|
||||
render_capture(&mut renderer, &mut capture, output, &elements)
|
||||
.unwrap(),
|
||||
);
|
||||
// The repeated scene must preserve the same pixels when
|
||||
// damage tracking skips all drawing on the next capture.
|
||||
let actual =
|
||||
render_capture(&mut renderer, &mut capture, output, &elements)
|
||||
.unwrap();
|
||||
let mapping = renderer
|
||||
.copy_framebuffer(
|
||||
&actual,
|
||||
Rectangle::from_size((width, height).into()),
|
||||
Fourcc::Abgr8888,
|
||||
)
|
||||
.unwrap();
|
||||
let actual_pixels = renderer.map_texture(&mapping).unwrap().to_vec();
|
||||
drop(actual);
|
||||
let expected =
|
||||
render_texture(&mut renderer, &mut reference, output, &elements)
|
||||
.unwrap();
|
||||
let mapping = renderer
|
||||
.copy_framebuffer(
|
||||
&expected,
|
||||
Rectangle::from_size((width, height).into()),
|
||||
Fourcc::Abgr8888,
|
||||
)
|
||||
.unwrap();
|
||||
let expected_pixels = renderer.map_texture(&mapping).unwrap();
|
||||
assert_eq!(
|
||||
actual_pixels,
|
||||
expected_pixels,
|
||||
"{} {width}x{height} scale={scale} {transform:?} step={step}",
|
||||
output.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual 4K capture benchmark; requires a hardware EGL device"]
|
||||
fn profile_output_capture() {
|
||||
let device = EGLDevice::enumerate()
|
||||
.unwrap()
|
||||
.find(|device| device.render_device_path().is_ok())
|
||||
.expect("a hardware EGL rendering device");
|
||||
eprintln!("capture_benchmark device={:?}", device.render_device_path());
|
||||
// SAFETY: the enumerated device remains alive through its EGL display.
|
||||
let display = unsafe { EGLDisplay::new(device).unwrap() };
|
||||
let context = EGLContext::new(&display).unwrap();
|
||||
// SAFETY: this thread exclusively owns the renderer and its fresh context.
|
||||
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
|
||||
let output = Output::new(
|
||||
"capture-benchmark".into(),
|
||||
PhysicalProperties {
|
||||
serial_number: String::new(),
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
output.change_current_state(
|
||||
Some(Mode {
|
||||
size: (3840, 2160).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
Some(Transform::Normal),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut texture = None;
|
||||
let background_id = Id::new();
|
||||
let cursor_id = Id::new();
|
||||
let mut commit = CommitCounter::default();
|
||||
let panes: Vec<_> = (0..20)
|
||||
.map(|i| {
|
||||
SolidColorRenderElement::new(
|
||||
Id::new(),
|
||||
Rectangle::new((i * 150, i * 75).into(), (800, 600).into()),
|
||||
CommitCounter::default(),
|
||||
Color32F::new(0.1, 0.2, 0.3, 0.5),
|
||||
Kind::Unspecified,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for readback in [false, true] {
|
||||
let size = if readback { (3840, 2160) } else { (1, 1) };
|
||||
let mut pixels = vec![0u8; size.0 as usize * size.1 as usize * 4];
|
||||
for workload in ["static", "cursor", "full"] {
|
||||
for run in 0..=3 {
|
||||
let started = Instant::now();
|
||||
commit.increment();
|
||||
for tick in 0..60 {
|
||||
let color = if workload == "full" {
|
||||
commit.increment();
|
||||
tick as f32 / 60.
|
||||
} else {
|
||||
0.1
|
||||
};
|
||||
let mut elements = vec![SolidColorRenderElement::new(
|
||||
cursor_id.clone(),
|
||||
Rectangle::new(
|
||||
(if workload == "cursor" { tick * 30 } else { 0 }, 0).into(),
|
||||
(24, 24).into(),
|
||||
),
|
||||
CommitCounter::default(),
|
||||
Color32F::new(1., 1., 1., 1.),
|
||||
Kind::Cursor,
|
||||
)];
|
||||
elements.extend_from_slice(&panes);
|
||||
elements.push(SolidColorRenderElement::new(
|
||||
background_id.clone(),
|
||||
Rectangle::from_size((3840, 2160).into()),
|
||||
commit,
|
||||
Color32F::new(color, 0.1, 0.2, 1.),
|
||||
Kind::Unspecified,
|
||||
));
|
||||
let target =
|
||||
render_capture(&mut renderer, &mut texture, &output, &elements)
|
||||
.unwrap();
|
||||
// Mapping waits for GPU completion in both workloads. The full
|
||||
// readback also includes the copy into the client's SHM buffer.
|
||||
let mapping = renderer
|
||||
.copy_framebuffer(
|
||||
&target,
|
||||
Rectangle::from_size(size.into()),
|
||||
Fourcc::Argb8888,
|
||||
)
|
||||
.unwrap();
|
||||
let source = renderer.map_texture(&mapping).unwrap();
|
||||
let bytes = pixels.len();
|
||||
pixels.copy_from_slice(&source[..bytes]);
|
||||
std::hint::black_box(&pixels);
|
||||
}
|
||||
if run != 0 {
|
||||
eprintln!(
|
||||
"output_capture_benchmark readback={readback} workload={workload} run={run} frames=60 elapsed_us={}",
|
||||
started.elapsed().as_micros()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an EGL rendering device"]
|
||||
fn capture_recomposes_cursor_without_touching_the_display_target() {
|
||||
let device = EGLDevice::enumerate()
|
||||
.unwrap()
|
||||
.last()
|
||||
.expect("an EGL device");
|
||||
// SAFETY: The native display comes from a live enumerated EGLDevice and its lifetime is
|
||||
// retained by the EGL objects created here.
|
||||
let display = unsafe { EGLDisplay::new(device).unwrap() };
|
||||
let context = EGLContext::new(&display).unwrap();
|
||||
// SAFETY: The fresh EGL context is exclusively owned by this renderer; this test issues
|
||||
// GL work only through its renderer methods.
|
||||
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
|
||||
let output = Output::new(
|
||||
"capture".into(),
|
||||
PhysicalProperties {
|
||||
serial_number: String::new(),
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
@@ -109,7 +803,7 @@ mod tests {
|
||||
let mut displayed = None;
|
||||
let mut capture = None;
|
||||
{
|
||||
let _ = render_capture(
|
||||
let _ = render_texture(
|
||||
&mut renderer,
|
||||
&mut displayed,
|
||||
&output,
|
||||
@@ -124,18 +818,32 @@ mod tests {
|
||||
] {
|
||||
let target = render_capture(&mut renderer, &mut capture, &output, &elements).unwrap();
|
||||
let mapping = renderer
|
||||
.copy_framebuffer(&target, Rectangle::from_size((32, 24).into()), Fourcc::Abgr8888)
|
||||
.copy_framebuffer(
|
||||
&target,
|
||||
Rectangle::from_size((32, 24).into()),
|
||||
Fourcc::Abgr8888,
|
||||
)
|
||||
.unwrap();
|
||||
let pixels = renderer.map_texture(&mapping).unwrap();
|
||||
assert_eq!(&pixels[..4], &expected);
|
||||
assert_eq!(&pixels[(12 * 32 + 16) * 4..(12 * 32 + 16) * 4 + 4], &[255, 0, 0, 255]);
|
||||
assert_eq!(
|
||||
&pixels[(12 * 32 + 16) * 4..(12 * 32 + 16) * 4 + 4],
|
||||
&[255, 0, 0, 255]
|
||||
);
|
||||
drop(target);
|
||||
|
||||
let target = renderer.bind(displayed.as_mut().unwrap()).unwrap();
|
||||
let mapping = renderer
|
||||
.copy_framebuffer(&target, Rectangle::from_size((32, 24).into()), Fourcc::Abgr8888)
|
||||
.copy_framebuffer(
|
||||
&target,
|
||||
Rectangle::from_size((32, 24).into()),
|
||||
Fourcc::Abgr8888,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(&renderer.map_texture(&mapping).unwrap()[..4], &[255, 0, 0, 255]);
|
||||
assert_eq!(
|
||||
&renderer.map_texture(&mapping).unwrap()[..4],
|
||||
&[255, 0, 0, 255]
|
||||
);
|
||||
}
|
||||
|
||||
output.change_current_state(
|
||||
@@ -147,7 +855,110 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let _ = render_capture::<SolidColorRenderElement>(&mut renderer, &mut capture, &output, &[]).unwrap();
|
||||
assert_eq!(capture.as_ref().unwrap().size(), (48, 16).into());
|
||||
let _ =
|
||||
render_capture::<SolidColorRenderElement>(&mut renderer, &mut capture, &output, &[])
|
||||
.unwrap();
|
||||
assert_eq!(capture.as_ref().unwrap().texture.size(), (48, 16).into());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod failure_tests {
|
||||
use std::cell::Cell;
|
||||
|
||||
use calloop::channel::channel;
|
||||
use smithay::output::Output;
|
||||
use smithay::utils::{Buffer, Rectangle};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn capture_allocation_failure_fails_one_request_after_display_submission() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"backends::direct::capture::failure_tests::capture_allocation_failure_fails_one_request_after_display_submission",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut events = calloop::EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
let output = Output::new(
|
||||
"capture-failure".into(),
|
||||
smithay::output::PhysicalProperties {
|
||||
serial_number: String::new(),
|
||||
size: (0, 0).into(),
|
||||
subpixel: smithay::output::Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
let (sender, receiver) = channel();
|
||||
state
|
||||
.pending_screencopies
|
||||
.push(crate::handlers::screencopy::PendingScreencopy::owned(
|
||||
41,
|
||||
0,
|
||||
sender,
|
||||
output.clone(),
|
||||
Rectangle::<i32, Buffer>::from_size((16, 16).into()),
|
||||
));
|
||||
let other_output = Output::new(
|
||||
"other-capture".into(),
|
||||
smithay::output::PhysicalProperties {
|
||||
serial_number: String::new(),
|
||||
size: (0, 0).into(),
|
||||
subpixel: smithay::output::Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
let (other_sender, other_receiver) = channel();
|
||||
state
|
||||
.pending_screencopies
|
||||
.push(crate::handlers::screencopy::PendingScreencopy::owned(
|
||||
42,
|
||||
0,
|
||||
other_sender,
|
||||
other_output.clone(),
|
||||
Rectangle::<i32, Buffer>::from_size((16, 16).into()),
|
||||
));
|
||||
|
||||
let submitted = Cell::new(false);
|
||||
let queued = {
|
||||
submitted.set(true);
|
||||
Ok::<(), ()>(())
|
||||
};
|
||||
after_queue(queued, || {
|
||||
assert!(
|
||||
submitted.get(),
|
||||
"capture must run after the display queue succeeds"
|
||||
);
|
||||
assert!(
|
||||
capture_request(&mut state, &output, false, || Err::<(), _>(
|
||||
"injected allocation failure"
|
||||
))
|
||||
.is_none()
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
submitted.get(),
|
||||
"capture failure must not undo queued display work"
|
||||
);
|
||||
assert_eq!(state.pending_screencopies.len(), 1);
|
||||
assert_eq!(state.pending_screencopies[0].output, other_output);
|
||||
let outcome = receiver.try_recv().unwrap();
|
||||
assert_eq!(outcome.request, 41);
|
||||
assert_eq!(outcome.part, 0);
|
||||
assert!(outcome.result.is_err());
|
||||
assert!(
|
||||
receiver.try_recv().is_err(),
|
||||
"request should fail exactly once"
|
||||
);
|
||||
assert!(
|
||||
other_receiver.try_recv().is_err(),
|
||||
"other output's request must stay pending"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Capture invalidation is independent of display damage and consumer requests.
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use smithay::output::Output;
|
||||
use smithay::utils::{Logical, Physical, Rectangle, Size, Transform};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Changes {
|
||||
content: AtomicU64,
|
||||
cursor: AtomicU64,
|
||||
}
|
||||
|
||||
pub(crate) fn content_changed(output: &Output) {
|
||||
if let Some(changes) = output.user_data().get::<Changes>() {
|
||||
changes.content.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cursor_changed(output: &Output) {
|
||||
if let Some(changes) = output.user_data().get::<Changes>() {
|
||||
changes.cursor.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn tracked(output: &Output) -> bool {
|
||||
output.user_data().get::<Changes>().is_some()
|
||||
}
|
||||
|
||||
/// Also compare output configuration: mode changes can precede a redraw request.
|
||||
/// Keeping the source identity prevents a remapped mirror from reusing its old view.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) struct Stamp {
|
||||
output: Output,
|
||||
content: u64,
|
||||
cursor: Option<u64>,
|
||||
epoch: u64,
|
||||
cursor_protected: bool,
|
||||
size: Option<Size<i32, Physical>>,
|
||||
scale: f64,
|
||||
transform: Transform,
|
||||
geometry: Option<Rectangle<i32, Logical>>,
|
||||
}
|
||||
|
||||
impl Stamp {
|
||||
pub fn new(state: &crate::Ferese, output: &Output, include_cursor: bool) -> Self {
|
||||
let changes = output
|
||||
.user_data()
|
||||
.get_or_insert_threadsafe(Changes::default);
|
||||
Self {
|
||||
output: output.clone(),
|
||||
content: changes.content.load(Ordering::Relaxed),
|
||||
cursor: include_cursor.then(|| changes.cursor.load(Ordering::Relaxed)),
|
||||
epoch: state.capture_epoch,
|
||||
cursor_protected: include_cursor && state.capture_cursor_protected(),
|
||||
size: output.current_mode().map(|mode| mode.size),
|
||||
scale: output.current_scale().fractional_scale(),
|
||||
transform: output.current_transform(),
|
||||
geometry: state.space.output_geometry(output),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use smithay::output::{Mode, PhysicalProperties, Scale, Subpixel};
|
||||
|
||||
fn output() -> Output {
|
||||
let output = Output::new(
|
||||
"capture-test".into(),
|
||||
PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
serial_number: String::new(),
|
||||
},
|
||||
);
|
||||
output.change_current_state(
|
||||
Some(Mode {
|
||||
size: (640, 480).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
Some(Transform::Normal),
|
||||
Some(Scale::Fractional(1.25)),
|
||||
Some((0, 0).into()),
|
||||
);
|
||||
output
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumer_demand_does_not_invalidate_but_scene_redraws_do() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"backends::direct::capture::changes::tests::consumer_demand_does_not_invalidate_but_scene_redraws_do",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
let first = output();
|
||||
let second = output();
|
||||
state.space.map_output(&first, (0, 0));
|
||||
state.space.map_output(&second, (640, 0));
|
||||
let before = Stamp::new(&state, &first, false);
|
||||
let other = Stamp::new(&state, &second, false);
|
||||
crate::backends::direct::schedule_capture(&mut state, std::slice::from_ref(&first));
|
||||
assert_eq!(before, Stamp::new(&state, &first, false));
|
||||
crate::backends::direct::render_on(&mut state, std::slice::from_ref(&first));
|
||||
assert_ne!(before, Stamp::new(&state, &first, false));
|
||||
assert_eq!(other, Stamp::new(&state, &second, false));
|
||||
let before = Stamp::new(&state, &first, false);
|
||||
state.defer_output_redraw(first.clone());
|
||||
assert_ne!(before, Stamp::new(&state, &first, false));
|
||||
crate::backends::direct::render_all(&mut state);
|
||||
assert_ne!(other, Stamp::new(&state, &second, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_privacy_and_output_configuration_change_the_appropriate_view() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"backends::direct::capture::changes::tests::cursor_privacy_and_output_configuration_change_the_appropriate_view",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
let output = output();
|
||||
state.space.map_output(&output, (0, 0));
|
||||
let hidden = Stamp::new(&state, &output, false);
|
||||
let embedded = Stamp::new(&state, &output, true);
|
||||
crate::backends::direct::render_cursor(&mut state);
|
||||
assert_eq!(hidden, Stamp::new(&state, &output, false));
|
||||
assert_ne!(embedded, Stamp::new(&state, &output, true));
|
||||
state.capture_epoch += 1;
|
||||
assert_ne!(hidden, Stamp::new(&state, &output, false));
|
||||
let before = Stamp::new(&state, &output, false);
|
||||
output.change_current_state(None, Some(Transform::_180), None, None);
|
||||
assert_ne!(before, Stamp::new(&state, &output, false));
|
||||
let before = Stamp::new(&state, &output, false);
|
||||
output.change_current_state(None, None, Some(Scale::Fractional(1.5)), None);
|
||||
assert_ne!(before, Stamp::new(&state, &output, false));
|
||||
let before = Stamp::new(&state, &output, false);
|
||||
state.space.map_output(&output, (100, 0));
|
||||
assert_ne!(before, Stamp::new(&state, &output, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commits_and_the_final_animation_frame_invalidate_capture() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"backends::direct::capture::changes::tests::commits_and_the_final_animation_frame_invalidate_capture",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
let output = output();
|
||||
state.space.map_output(&output, (0, 0));
|
||||
state.register_output(&output, "capture-animation".into());
|
||||
let (_window, mut client) = crate::state::test_window(&mut state, &mut events, 0xffff0000);
|
||||
let before = Stamp::new(&state, &output, false);
|
||||
// Even a callback-only commit goes through the mutation path. Damage
|
||||
// tracking can subsequently decide that the retained pixels still match.
|
||||
crate::state::test_client_request(&mut client, 2, 6, &[], None);
|
||||
crate::state::dispatch_test_client(&mut events, &mut state);
|
||||
assert_ne!(before, Stamp::new(&state, &output, false));
|
||||
let mut now = std::time::Instant::now();
|
||||
state.advance_animations(now);
|
||||
assert!(state.output_has_animations(&output));
|
||||
let mut settled = false;
|
||||
for _ in 0..500 {
|
||||
let before = Stamp::new(&state, &output, false);
|
||||
now += std::time::Duration::from_millis(16);
|
||||
state.advance_animations(now);
|
||||
assert_ne!(before, Stamp::new(&state, &output, false));
|
||||
if !state.output_has_animations(&output) {
|
||||
settled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
settled,
|
||||
"the opening/focus animation must reach its final frame"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use smithay::backend::egl::context::ContextPriority;
|
||||
use smithay::backend::egl::{EGLContext, EGLDisplay, Error};
|
||||
|
||||
pub(super) fn try_new(display: &EGLDisplay) -> Result<EGLContext, Error> {
|
||||
// Smithay omits the hint without EGL_IMG_context_priority and logs the
|
||||
// queried priority when available. A successful request need not grant High.
|
||||
try_with_high_priority(|priority| match priority {
|
||||
Some(priority) => EGLContext::new_with_priority(display, priority),
|
||||
None => EGLContext::new(display),
|
||||
})
|
||||
}
|
||||
|
||||
fn try_with_high_priority<T, E: Display>(
|
||||
mut create: impl FnMut(Option<ContextPriority>) -> Result<T, E>,
|
||||
) -> Result<T, E> {
|
||||
create(Some(ContextPriority::High)).or_else(|error| {
|
||||
tracing::warn!(
|
||||
%error,
|
||||
"EGL context creation with a high-priority hint failed; retrying without the hint"
|
||||
);
|
||||
create(None)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn requests_high_without_recreating_a_successful_context() {
|
||||
let mut requests = Vec::new();
|
||||
let context = try_with_high_priority(|priority| {
|
||||
requests.push(priority);
|
||||
Ok::<_, &str>("context")
|
||||
});
|
||||
|
||||
assert_eq!(context, Ok("context"));
|
||||
assert_eq!(requests, [Some(ContextPriority::High)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_without_a_hint_when_priority_creation_fails() {
|
||||
let mut requests = Vec::new();
|
||||
let context = try_with_high_priority(|priority| {
|
||||
requests.push(priority);
|
||||
match priority {
|
||||
Some(_) => Err("priority rejected"),
|
||||
None => Ok("fallback context"),
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(context, Ok("fallback context"));
|
||||
assert_eq!(requests, [Some(ContextPriority::High), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagates_fallback_failure_without_retrying_again() {
|
||||
let mut requests = Vec::new();
|
||||
let context = try_with_high_priority(|priority| {
|
||||
requests.push(priority);
|
||||
match priority {
|
||||
Some(_) => Err::<(), _>("priority rejected"),
|
||||
None => Err("default creation failed"),
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(context, Err("default creation failed"));
|
||||
assert_eq!(requests, [Some(ContextPriority::High), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an EGL device (software Mesa is supported)"]
|
||||
fn creates_a_usable_egl_context() {
|
||||
use smithay::backend::allocator::Fourcc;
|
||||
use smithay::backend::egl::EGLDevice;
|
||||
use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture};
|
||||
use smithay::backend::renderer::{Bind, Color32F, ExportMem, Frame, Offscreen, Renderer};
|
||||
use smithay::utils::{Physical, Rectangle, Transform};
|
||||
|
||||
let software_only = std::env::var("LIBGL_ALWAYS_SOFTWARE").as_deref() == Ok("1");
|
||||
let mut tested = 0;
|
||||
for device in EGLDevice::enumerate().unwrap() {
|
||||
let software = device
|
||||
.extensions()
|
||||
.iter()
|
||||
.any(|name| name == "EGL_MESA_device_software");
|
||||
if software_only && !software {
|
||||
continue;
|
||||
}
|
||||
eprintln!(
|
||||
"EGL context test: software={software}, render_node={:?}",
|
||||
device.render_device_path().ok()
|
||||
);
|
||||
// SAFETY: the enumerated EGL device remains owned by the display.
|
||||
let display = unsafe { EGLDisplay::new(device).unwrap() };
|
||||
let context = try_new(&display).unwrap();
|
||||
// SAFETY: this fresh context is not current on another thread.
|
||||
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
|
||||
let mut texture: GlesTexture = renderer
|
||||
.create_buffer(Fourcc::Abgr8888, (8, 8).into())
|
||||
.unwrap();
|
||||
let rect = Rectangle::<i32, Physical>::from_size((8, 8).into());
|
||||
{
|
||||
let mut target = renderer.bind(&mut texture).unwrap();
|
||||
{
|
||||
let mut frame = renderer
|
||||
.render(&mut target, (8, 8).into(), Transform::Normal)
|
||||
.unwrap();
|
||||
frame.clear(Color32F::new(1., 0., 0., 1.), &[rect]).unwrap();
|
||||
frame.finish().unwrap().wait().unwrap();
|
||||
}
|
||||
let mapping = renderer
|
||||
.copy_framebuffer(
|
||||
&target,
|
||||
Rectangle::from_size((8, 8).into()),
|
||||
Fourcc::Abgr8888,
|
||||
)
|
||||
.unwrap();
|
||||
let pixels = renderer.map_texture(&mapping).unwrap();
|
||||
assert_eq!(pixels.len(), 8 * 8 * 4);
|
||||
assert!(
|
||||
pixels
|
||||
.chunks_exact(4)
|
||||
.all(|pixel| pixel == [255, 0, 0, 255])
|
||||
);
|
||||
}
|
||||
renderer.egl_context().unbind().unwrap();
|
||||
tested += 1;
|
||||
}
|
||||
assert!(tested > 0, "no matching EGL device was exercised");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
use smithay::reexports::drm::control::crtc;
|
||||
|
||||
pub(super) struct Candidates {
|
||||
pub applied: Option<crtc::Handle>,
|
||||
pub kernel: Option<crtc::Handle>,
|
||||
pub compatible: Vec<crtc::Handle>,
|
||||
}
|
||||
|
||||
/// Maximum-cardinality, minimum-cost bipartite matching. Cardinality wins over
|
||||
/// preferences: augment until no path remains, even if its cost is positive.
|
||||
/// For that cardinality, retain the most valid applied assignments, then the
|
||||
/// most kernel assignments. Stable connector/compatible-CRTC order breaks ties.
|
||||
/// One applied edge outweighs every possible kernel edge, since applied ownership
|
||||
/// is authoritative and encoder routing can be stale after disabling an output.
|
||||
pub(super) fn assign(requests: &[Candidates]) -> Vec<Option<crtc::Handle>> {
|
||||
let mut crtcs = Vec::new();
|
||||
for request in requests {
|
||||
for crtc in &request.compatible {
|
||||
if !crtcs.contains(crtc) {
|
||||
crtcs.push(*crtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
let source = requests.len() + crtcs.len();
|
||||
let sink = source + 1;
|
||||
let mut edges = Vec::new();
|
||||
let mut assignments = Vec::new();
|
||||
let applied_weight = requests.len() as i64 + 1;
|
||||
for (index, request) in requests.iter().enumerate() {
|
||||
add_edge(&mut edges, source, index, 0);
|
||||
for (crtc_index, crtc) in crtcs.iter().enumerate() {
|
||||
if request.compatible.contains(crtc) {
|
||||
let cost = -applied_weight * i64::from(request.applied == Some(*crtc))
|
||||
- i64::from(request.kernel == Some(*crtc));
|
||||
assignments.push((index, *crtc, edges.len()));
|
||||
add_edge(&mut edges, index, requests.len() + crtc_index, cost);
|
||||
}
|
||||
}
|
||||
}
|
||||
for crtc_index in 0..crtcs.len() {
|
||||
add_edge(&mut edges, requests.len() + crtc_index, sink, 0);
|
||||
}
|
||||
while augment(&mut edges, source, sink) {}
|
||||
let mut assigned = vec![None; requests.len()];
|
||||
for (index, crtc, edge) in assignments {
|
||||
if !edges[edge].available {
|
||||
assigned[index] = Some(crtc);
|
||||
}
|
||||
}
|
||||
assigned
|
||||
}
|
||||
|
||||
struct Edge {
|
||||
from: usize,
|
||||
to: usize,
|
||||
cost: i64,
|
||||
available: bool,
|
||||
}
|
||||
|
||||
fn add_edge(edges: &mut Vec<Edge>, from: usize, to: usize, cost: i64) {
|
||||
edges.push(Edge {
|
||||
from,
|
||||
to,
|
||||
cost,
|
||||
available: true,
|
||||
});
|
||||
edges.push(Edge {
|
||||
from: to,
|
||||
to: from,
|
||||
cost: -cost,
|
||||
available: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Find the cheapest residual augmenting path. Reverse edges allow earlier
|
||||
/// assignments to move. Bellman-Ford handles negative preference costs; starting
|
||||
/// from an empty matching and using shortest paths leaves no negative cost cycle.
|
||||
/// Failed searches leave the matching untouched. Strict improvements and stable
|
||||
/// edge traversal make equal-cost choices deterministic without hash iteration.
|
||||
fn augment(edges: &mut [Edge], source: usize, sink: usize) -> bool {
|
||||
let mut distances = vec![None; sink + 1];
|
||||
let mut previous = vec![None; sink + 1];
|
||||
distances[source] = Some(0);
|
||||
for _ in 0..sink {
|
||||
let mut changed = false;
|
||||
for (index, edge) in edges.iter().enumerate() {
|
||||
if edge.available
|
||||
&& let Some(distance) = distances[edge.from]
|
||||
&& distances[edge.to].is_none_or(|current| distance + edge.cost < current)
|
||||
{
|
||||
distances[edge.to] = Some(distance + edge.cost);
|
||||
previous[edge.to] = Some(index);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if previous[sink].is_none() {
|
||||
return false;
|
||||
}
|
||||
let mut vertex = sink;
|
||||
while vertex != source {
|
||||
let index = previous[vertex].expect("reachable augmenting path");
|
||||
edges[index].available = false;
|
||||
edges[index ^ 1].available = true;
|
||||
vertex = edges[index].from;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn crtc(value: u32) -> crtc::Handle {
|
||||
std::num::NonZeroU32::new(value).unwrap().into()
|
||||
}
|
||||
|
||||
fn candidates(applied: Option<u32>, kernel: Option<u32>, compatible: &[u32]) -> Candidates {
|
||||
Candidates {
|
||||
applied: applied.map(crtc),
|
||||
kernel: kernel.map(crtc),
|
||||
compatible: compatible.iter().copied().map(crtc).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_connector_order_finds_a_complete_matching() {
|
||||
let graph = [&[1, 2][..], &[1, 3][..], &[1, 3][..]];
|
||||
for order in [
|
||||
[0, 1, 2],
|
||||
[0, 2, 1],
|
||||
[1, 0, 2],
|
||||
[1, 2, 0],
|
||||
[2, 0, 1],
|
||||
[2, 1, 0],
|
||||
] {
|
||||
let requests = order.map(|index| candidates(None, None, graph[index]));
|
||||
let result = assign(&requests);
|
||||
assert_eq!(result.iter().flatten().count(), 3, "order {order:?}");
|
||||
assert_eq!(
|
||||
assign(&requests),
|
||||
result,
|
||||
"deterministic for the same inventory"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_preferences_use_connector_order() {
|
||||
let result = assign(&[
|
||||
candidates(Some(1), None, &[1, 2]),
|
||||
candidates(Some(1), None, &[1, 2]),
|
||||
]);
|
||||
assert_eq!(result, vec![Some(crtc(1)), Some(crtc(2))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserving_two_applied_outputs_outweighs_preserving_the_first_one() {
|
||||
assert_eq!(
|
||||
assign(&[
|
||||
candidates(Some(1), None, &[1, 4]),
|
||||
candidates(Some(2), None, &[2, 4]),
|
||||
candidates(Some(3), None, &[2, 3]),
|
||||
candidates(None, None, &[1, 3]),
|
||||
]),
|
||||
vec![Some(crtc(4)), Some(crtc(2)), Some(crtc(3)), Some(crtc(1))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_cardinality_and_preference_objective_match_exhaustive_oracle() {
|
||||
fn score(
|
||||
requests: &[Candidates],
|
||||
assignment: &[Option<crtc::Handle>],
|
||||
) -> (usize, usize, usize) {
|
||||
let retained = [true, false].map(|applied| {
|
||||
requests
|
||||
.iter()
|
||||
.zip(assignment)
|
||||
.filter(|(request, actual)| {
|
||||
let preferred = if applied {
|
||||
request.applied
|
||||
} else {
|
||||
request.kernel
|
||||
};
|
||||
preferred.is_some() && preferred == **actual
|
||||
})
|
||||
.count()
|
||||
});
|
||||
(
|
||||
assignment.iter().flatten().count(),
|
||||
retained[0],
|
||||
retained[1],
|
||||
)
|
||||
}
|
||||
|
||||
fn enumerate(
|
||||
requests: &[Candidates],
|
||||
prefix: &mut Vec<Option<crtc::Handle>>,
|
||||
best: &mut (usize, usize, usize),
|
||||
) {
|
||||
if prefix.len() == requests.len() {
|
||||
*best = std::cmp::max(*best, score(requests, prefix));
|
||||
return;
|
||||
}
|
||||
let index = prefix.len();
|
||||
prefix.push(None);
|
||||
enumerate(requests, prefix, best);
|
||||
prefix.pop();
|
||||
for crtc in &requests[index].compatible {
|
||||
if !prefix.contains(&Some(*crtc)) {
|
||||
prefix.push(Some(*crtc));
|
||||
enumerate(requests, prefix, best);
|
||||
prefix.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
// All 512 three-by-three graphs, including empty and impossible ones.
|
||||
// Vary both preference classes, including invalid/conflicting preferences.
|
||||
for graph in 0..512 {
|
||||
for seed in 0..8 {
|
||||
let requests = (0..3)
|
||||
.map(|index| {
|
||||
let compatible = (1..=3)
|
||||
.filter(|crtc| graph & (1 << (index * 3 + crtc - 1)) != 0)
|
||||
.collect::<Vec<_>>();
|
||||
let applied = ((seed + index) % 4 != 0).then_some((seed + index) % 4);
|
||||
let kernel =
|
||||
((seed * 3 + index) % 4 != 0).then_some((seed * 3 + index) % 4);
|
||||
candidates(applied, kernel, &compatible)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut best = (0, 0, 0);
|
||||
enumerate(&requests, &mut Vec::new(), &mut best);
|
||||
let actual = assign(&requests);
|
||||
let used = actual.iter().flatten().collect::<HashSet<_>>();
|
||||
assert_eq!(used.len(), actual.iter().flatten().count());
|
||||
assert!(requests.iter().zip(&actual).all(|(request, crtc)| {
|
||||
crtc.is_none_or(|crtc| request.compatible.contains(&crtc))
|
||||
}));
|
||||
assert_eq!(
|
||||
score(&requests, &actual),
|
||||
best,
|
||||
"graph {graph}, preferences {seed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_flexible_connectors_need_an_augmenting_path() {
|
||||
let result = assign(&[
|
||||
candidates(None, None, &[1, 2]),
|
||||
candidates(None, None, &[1, 3]),
|
||||
candidates(None, None, &[1, 3]),
|
||||
]);
|
||||
assert_eq!(result.iter().flatten().count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completeness_can_require_moving_two_applied_outputs() {
|
||||
let result = assign(&[
|
||||
candidates(Some(1), Some(1), &[1, 2]),
|
||||
candidates(Some(2), Some(2), &[2, 3]),
|
||||
candidates(None, None, &[1]),
|
||||
]);
|
||||
assert_eq!(result, vec![Some(crtc(2)), Some(crtc(3)), Some(crtc(1))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_to_two_outputs_keeps_external_crtc_regardless_of_connector_order() {
|
||||
// Extend and mirror both require two independent scanout assignments.
|
||||
// The internal panel appears first in the DRM connector inventory.
|
||||
let internal = candidates(None, None, &[151, 270]);
|
||||
let external = candidates(Some(151), Some(151), &[151, 270]);
|
||||
assert_eq!(
|
||||
assign(&[internal, external]),
|
||||
vec![Some(crtc(270)), Some(crtc(151))]
|
||||
);
|
||||
let internal = candidates(None, None, &[151, 270]);
|
||||
let external = candidates(Some(151), Some(151), &[151, 270]);
|
||||
assert_eq!(
|
||||
assign(&[external, internal]),
|
||||
vec![Some(crtc(151)), Some(crtc(270))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_disabled_encoder_cannot_steal_applied_external_assignment() {
|
||||
assert_eq!(
|
||||
assign(&[
|
||||
candidates(None, Some(151), &[151, 270]),
|
||||
candidates(Some(151), Some(151), &[151, 270]),
|
||||
]),
|
||||
vec![Some(crtc(270)), Some(crtc(151))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_assignments_are_preserved_when_no_outputs_have_been_applied() {
|
||||
assert_eq!(
|
||||
assign(&[
|
||||
candidates(None, None, &[151, 270]),
|
||||
candidates(None, Some(151), &[151, 270]),
|
||||
]),
|
||||
vec![Some(crtc(270)), Some(crtc(151))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returning_to_one_output_and_reconnecting_preserves_surviving_assignment() {
|
||||
assert_eq!(
|
||||
assign(&[candidates(Some(270), Some(270), &[151, 270])]),
|
||||
vec![Some(crtc(270))]
|
||||
);
|
||||
assert_eq!(
|
||||
assign(&[
|
||||
candidates(None, None, &[151, 270]),
|
||||
candidates(Some(270), Some(270), &[151, 270]),
|
||||
]),
|
||||
vec![Some(crtc(151)), Some(crtc(270))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incompatible_previous_assignment_is_not_reserved() {
|
||||
assert_eq!(
|
||||
assign(&[candidates(Some(151), Some(151), &[270])]),
|
||||
vec![Some(crtc(270))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_connector_gets_its_only_free_crtc() {
|
||||
assert_eq!(
|
||||
assign(&[
|
||||
candidates(None, None, &[151, 270]),
|
||||
candidates(None, None, &[151])
|
||||
]),
|
||||
vec![Some(crtc(270)), Some(crtc(151))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_crtcs_do_not_displace_surviving_output_or_assign_twice() {
|
||||
assert_eq!(
|
||||
assign(&[
|
||||
candidates(None, None, &[151]),
|
||||
candidates(Some(151), Some(151), &[151])
|
||||
]),
|
||||
vec![None, Some(crtc(151))]
|
||||
);
|
||||
assert!(assign(&[]).is_empty());
|
||||
assert_eq!(assign(&[candidates(None, None, &[])]), vec![None]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Opt-in accounting of observable DRM results, independent of scanout policy.
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum PrimaryOutcome {
|
||||
DirectScanout,
|
||||
Composed,
|
||||
ReusedSwapchain,
|
||||
}
|
||||
|
||||
impl PrimaryOutcome {
|
||||
pub fn classify(direct: bool, composition_damage: bool) -> Self {
|
||||
if direct {
|
||||
Self::DirectScanout
|
||||
} else if composition_damage {
|
||||
Self::Composed
|
||||
} else {
|
||||
Self::ReusedSwapchain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Eq, PartialEq)]
|
||||
struct Counters {
|
||||
render_attempts: u64,
|
||||
primary_allowed: u64,
|
||||
blocked_locked: u64,
|
||||
blocked_animation: u64,
|
||||
blocked_not_fullscreen: u64,
|
||||
render_failures: u64,
|
||||
sync_failures: u64,
|
||||
no_damage_results: u64,
|
||||
queue_failures: u64,
|
||||
submissions: u64,
|
||||
scanout_submissions: u64,
|
||||
composed_submissions: u64,
|
||||
reused_swapchain_submissions: u64,
|
||||
allowed_composition_submissions: u64,
|
||||
cursor_plane_submissions: u64,
|
||||
no_cursor_plane_submissions: u64,
|
||||
retired_frames: u64,
|
||||
retirement_failures: u64,
|
||||
capture_batches: u64,
|
||||
capture_time: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Diagnostics {
|
||||
enabled: bool,
|
||||
output: String,
|
||||
started: Instant,
|
||||
counters: Counters,
|
||||
}
|
||||
|
||||
impl Diagnostics {
|
||||
pub fn from_environment(output: String) -> Self {
|
||||
Self::new(
|
||||
output,
|
||||
std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some(),
|
||||
)
|
||||
}
|
||||
|
||||
fn new(output: String, enabled: bool) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
output,
|
||||
started: Instant::now(),
|
||||
counters: Counters::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn attempt(&mut self, fullscreen: bool, animating: bool, locked: bool) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
self.counters.render_attempts += 1;
|
||||
// Exclusive diagnostic precedence only; the policy itself is unchanged.
|
||||
if locked {
|
||||
self.counters.blocked_locked += 1;
|
||||
} else if animating {
|
||||
self.counters.blocked_animation += 1;
|
||||
} else if !fullscreen {
|
||||
self.counters.blocked_not_fullscreen += 1;
|
||||
} else {
|
||||
self.counters.primary_allowed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_failed(&mut self) {
|
||||
if self.enabled {
|
||||
self.counters.render_failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync_failed(&mut self) {
|
||||
if self.enabled {
|
||||
self.counters.sync_failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn no_damage(&mut self) {
|
||||
if self.enabled {
|
||||
self.counters.no_damage_results += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn queue_result<T, E>(
|
||||
&mut self,
|
||||
result: &Result<T, E>,
|
||||
primary: PrimaryOutcome,
|
||||
cursor_plane: bool,
|
||||
primary_allowed: bool,
|
||||
) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
if result.is_err() {
|
||||
self.counters.queue_failures += 1;
|
||||
return;
|
||||
}
|
||||
self.counters.submissions += 1;
|
||||
match primary {
|
||||
PrimaryOutcome::DirectScanout => self.counters.scanout_submissions += 1,
|
||||
PrimaryOutcome::Composed => self.counters.composed_submissions += 1,
|
||||
PrimaryOutcome::ReusedSwapchain => self.counters.reused_swapchain_submissions += 1,
|
||||
}
|
||||
if primary_allowed && primary != PrimaryOutcome::DirectScanout {
|
||||
// Smithay selected a swapchain despite permission. This API does not
|
||||
// expose whether a candidate was tested, or why it was rejected.
|
||||
self.counters.allowed_composition_submissions += 1;
|
||||
}
|
||||
if cursor_plane {
|
||||
self.counters.cursor_plane_submissions += 1;
|
||||
} else {
|
||||
self.counters.no_cursor_plane_submissions += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retirement(&mut self, completed: bool, failed: bool) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
self.counters.retired_frames += u64::from(completed && !failed);
|
||||
self.counters.retirement_failures += u64::from(failed);
|
||||
}
|
||||
|
||||
pub fn capture_start(&self, pending: impl FnOnce() -> bool) -> Option<Instant> {
|
||||
(self.enabled && pending()).then(Instant::now)
|
||||
}
|
||||
|
||||
pub fn capture_finished(&mut self, start: Option<Instant>) {
|
||||
if let Some(start) = start {
|
||||
self.record_capture(start.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
fn record_capture(&mut self, elapsed: Duration) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
self.counters.capture_batches += 1;
|
||||
self.counters.capture_time += elapsed;
|
||||
}
|
||||
|
||||
pub fn report_if_due(&mut self) {
|
||||
if !self.enabled || self.started.elapsed() < Duration::from_secs(5) {
|
||||
return;
|
||||
}
|
||||
let c = &self.counters;
|
||||
tracing::info!(target: "ferese::render", output = %self.output,
|
||||
interval_ms = self.started.elapsed().as_millis(),
|
||||
render_attempts = c.render_attempts, primary_allowed = c.primary_allowed,
|
||||
blocked_locked = c.blocked_locked, blocked_animation = c.blocked_animation,
|
||||
blocked_not_fullscreen = c.blocked_not_fullscreen,
|
||||
render_failures = c.render_failures, sync_failures = c.sync_failures,
|
||||
no_damage_results = c.no_damage_results, queue_failures = c.queue_failures,
|
||||
submissions = c.submissions, scanout_submissions = c.scanout_submissions,
|
||||
composed_submissions = c.composed_submissions,
|
||||
reused_swapchain_submissions = c.reused_swapchain_submissions,
|
||||
allowed_composition_submissions = c.allowed_composition_submissions,
|
||||
cursor_plane_submissions = c.cursor_plane_submissions,
|
||||
no_cursor_plane_submissions = c.no_cursor_plane_submissions,
|
||||
retired_frames = c.retired_frames, retirement_failures = c.retirement_failures,
|
||||
capture_batches = c.capture_batches, capture_us = c.capture_time.as_micros(),
|
||||
"DRM frame outcomes");
|
||||
self.counters = Counters::default();
|
||||
self.started = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn primary_classification_does_not_infer_scanout_from_zero_damage() {
|
||||
assert_eq!(
|
||||
PrimaryOutcome::classify(true, false),
|
||||
PrimaryOutcome::DirectScanout
|
||||
);
|
||||
assert_eq!(
|
||||
PrimaryOutcome::classify(false, true),
|
||||
PrimaryOutcome::Composed
|
||||
);
|
||||
assert_eq!(
|
||||
PrimaryOutcome::classify(false, false),
|
||||
PrimaryOutcome::ReusedSwapchain
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_reasons_partition_attempts_with_documented_precedence() {
|
||||
let mut d = Diagnostics::new("test".into(), true);
|
||||
for fullscreen in [false, true] {
|
||||
for animating in [false, true] {
|
||||
for locked in [false, true] {
|
||||
let allowed_before = d.counters.primary_allowed;
|
||||
d.attempt(fullscreen, animating, locked);
|
||||
assert_eq!(
|
||||
d.counters.primary_allowed > allowed_before,
|
||||
super::super::planes::frame_flags(fullscreen, animating, locked)
|
||||
.contains(smithay::backend::drm::compositor::FrameFlags::ALLOW_PRIMARY_PLANE_SCANOUT),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(d.counters.render_attempts, 8);
|
||||
assert_eq!(d.counters.primary_allowed, 1);
|
||||
assert_eq!(d.counters.blocked_locked, 4);
|
||||
assert_eq!(d.counters.blocked_animation, 2);
|
||||
assert_eq!(d.counters.blocked_not_fullscreen, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_successful_queues_count_outcomes_and_retirement_is_separate() {
|
||||
let mut d = Diagnostics::new("test".into(), true);
|
||||
let queued = Err::<(), _>("queue failed");
|
||||
d.queue_result(&queued, PrimaryOutcome::DirectScanout, true, true);
|
||||
assert!(
|
||||
super::super::capture::after_queue(queued, || panic!("failed queues must not capture"))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(d.counters.submissions, 0);
|
||||
assert_eq!(d.counters.scanout_submissions, 0);
|
||||
assert_eq!(d.counters.cursor_plane_submissions, 0);
|
||||
assert_eq!(d.counters.queue_failures, 1);
|
||||
for (primary, cursor, allowed) in [
|
||||
(PrimaryOutcome::DirectScanout, true, true),
|
||||
(PrimaryOutcome::Composed, false, true),
|
||||
(PrimaryOutcome::ReusedSwapchain, true, false),
|
||||
] {
|
||||
d.queue_result(&Ok::<(), ()>(()), primary, cursor, allowed);
|
||||
}
|
||||
assert_eq!(d.counters.submissions, 3);
|
||||
assert_eq!(d.counters.scanout_submissions, 1);
|
||||
assert_eq!(d.counters.composed_submissions, 1);
|
||||
assert_eq!(d.counters.reused_swapchain_submissions, 1);
|
||||
assert_eq!(d.counters.allowed_composition_submissions, 1);
|
||||
assert_eq!(d.counters.cursor_plane_submissions, 2);
|
||||
assert_eq!(d.counters.no_cursor_plane_submissions, 1);
|
||||
assert_eq!(d.counters.retired_frames, 0);
|
||||
d.retirement(false, false);
|
||||
d.retirement(false, true);
|
||||
assert_eq!(d.counters.retired_frames, 0);
|
||||
d.retirement(true, false);
|
||||
assert_eq!(d.counters.retired_frames, 1);
|
||||
assert_eq!(d.counters.retirement_failures, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_damage_and_failures_never_count_as_submissions() {
|
||||
let mut d = Diagnostics::new("test".into(), true);
|
||||
d.no_damage();
|
||||
d.render_failed();
|
||||
d.sync_failed();
|
||||
d.record_capture(Duration::from_micros(123));
|
||||
assert_eq!(d.counters.no_damage_results, 1);
|
||||
assert_eq!(d.counters.render_failures, 1);
|
||||
assert_eq!(d.counters.sync_failures, 1);
|
||||
assert_eq!(d.counters.submissions, 0);
|
||||
assert_eq!(d.counters.capture_time, Duration::from_micros(123));
|
||||
assert_eq!(d.counters.capture_batches, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_accounting_is_inert_and_outputs_are_independent() {
|
||||
let mut d = Diagnostics::new("disabled".into(), false);
|
||||
d.attempt(true, false, false);
|
||||
d.render_failed();
|
||||
d.sync_failed();
|
||||
d.no_damage();
|
||||
d.queue_result(&Ok::<(), ()>(()), PrimaryOutcome::DirectScanout, true, true);
|
||||
d.retirement(true, false);
|
||||
d.record_capture(Duration::from_secs(1));
|
||||
assert_eq!(d.counters, Counters::default());
|
||||
assert!(
|
||||
d.capture_start(|| panic!("disabled diagnostics must not inspect capture requests"))
|
||||
.is_none()
|
||||
);
|
||||
let mut other = Diagnostics::new("other".into(), true);
|
||||
other.no_damage();
|
||||
assert!(other.capture_start(|| false).is_none());
|
||||
assert_eq!(d.counters.no_damage_results, 0);
|
||||
assert_eq!(other.counters.no_damage_results, 1);
|
||||
other.started = Instant::now() - Duration::from_secs(6);
|
||||
other.report_if_due();
|
||||
assert_eq!(other.counters, Counters::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
//! Conservative output feedback for the existing primary-plane policy.
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use smithay::backend::allocator::Format;
|
||||
use smithay::backend::allocator::format::FormatSet;
|
||||
use smithay::backend::renderer::element::{
|
||||
Element, Id, RenderElement, RenderElementPresentationState, RenderElementStates, RenderingReason,
|
||||
};
|
||||
use smithay::backend::renderer::gles::GlesRenderer;
|
||||
use smithay::backend::renderer::utils::with_renderer_surface_state;
|
||||
use smithay::output::Output;
|
||||
use smithay::reexports::wayland_protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_feedback_v1::TrancheFlags;
|
||||
use smithay::utils::{Physical, Rectangle, Scale};
|
||||
use smithay::wayland::dmabuf::{DmabufFeedback, DmabufFeedbackBuilder, DmabufGlobal};
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::render::AnimatedWindowRenderElement;
|
||||
|
||||
fn compatible(render: &FormatSet, primary: &FormatSet, observed: Format) -> bool {
|
||||
// Do not equate implicit and explicit modifiers, alpha and opaque FourCCs,
|
||||
// or formats from cursor/overlay planes. Exact intersection preserves import.
|
||||
render.contains(&observed) && primary.contains(&observed)
|
||||
}
|
||||
|
||||
pub(super) struct OutputFeedback {
|
||||
render_device: libc::dev_t,
|
||||
scanout_device: libc::dev_t,
|
||||
render_formats: FormatSet,
|
||||
primary_formats: FormatSet,
|
||||
observed: Option<Format>,
|
||||
preferred: Option<DmabufFeedback>,
|
||||
visible: HashSet<Id>,
|
||||
candidates: HashSet<Id>,
|
||||
}
|
||||
|
||||
impl OutputFeedback {
|
||||
pub fn new(
|
||||
render_device: libc::dev_t,
|
||||
scanout_device: libc::dev_t,
|
||||
render_formats: FormatSet,
|
||||
primary_formats: FormatSet,
|
||||
) -> Self {
|
||||
Self {
|
||||
render_device,
|
||||
scanout_device,
|
||||
render_formats,
|
||||
primary_formats,
|
||||
observed: None,
|
||||
preferred: None,
|
||||
visible: HashSet::new(),
|
||||
candidates: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalidate(&mut self) {
|
||||
self.observed = None;
|
||||
self.preferred = None;
|
||||
self.visible.clear();
|
||||
self.candidates.clear();
|
||||
}
|
||||
|
||||
fn observe_format(&mut self, format: Option<Format>) {
|
||||
let Some(format) = format else {
|
||||
return;
|
||||
};
|
||||
if self.observed == Some(format) {
|
||||
return;
|
||||
}
|
||||
self.observed = Some(format);
|
||||
self.preferred = None;
|
||||
if compatible(&self.render_formats, &self.primary_formats, format) {
|
||||
// Restrict the preference to the observed primary format/modifier:
|
||||
// without ALLOW_PRIMARY_PLANE_SCANOUT_ANY Smithay compares the full
|
||||
// format with the swapchain. Renderer-only formats remain in fallback.
|
||||
match DmabufFeedbackBuilder::new(
|
||||
self.render_device,
|
||||
self.render_formats.iter().copied(),
|
||||
)
|
||||
.add_preference_tranche(self.scanout_device, TrancheFlags::Scanout, [format], 4..=6)
|
||||
.build()
|
||||
{
|
||||
Ok(feedback) => self.preferred = Some(feedback),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "output DMA-BUF feedback unavailable; retaining render fallback")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn observed_frame(
|
||||
&mut self,
|
||||
format: Option<Format>,
|
||||
states: &RenderElementStates,
|
||||
candidates: HashSet<Id>,
|
||||
) {
|
||||
self.observe_format(format);
|
||||
self.visible = states
|
||||
.states
|
||||
.iter()
|
||||
.filter(|(_, state)| crate::display_presentation::is_visible(**state))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
self.candidates = candidates
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
states.states.get(id).is_some_and(|state| {
|
||||
crate::display_presentation::is_visible(*state)
|
||||
&& state.presentation_state
|
||||
!= RenderElementPresentationState::Rendering {
|
||||
reason: Some(RenderingReason::ScanoutFailed),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn candidates(
|
||||
renderer: &mut GlesRenderer,
|
||||
elements: &[AnimatedWindowRenderElement],
|
||||
output: &Output,
|
||||
allowed: bool,
|
||||
) -> HashSet<Id> {
|
||||
if !allowed {
|
||||
return HashSet::new();
|
||||
}
|
||||
let Some(mode) = output.current_mode() else {
|
||||
return HashSet::new();
|
||||
};
|
||||
let bounds = Rectangle::from_size(output.current_transform().transform_size(mode.size));
|
||||
let scale = output.current_scale().fractional_scale().into();
|
||||
elements
|
||||
.iter()
|
||||
.filter(|&element| {
|
||||
candidate_geometry(element, bounds, scale, || {
|
||||
element.underlying_storage(renderer).is_some()
|
||||
})
|
||||
})
|
||||
.map(|element| element.id().clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn candidate_geometry(
|
||||
element: &impl Element,
|
||||
bounds: Rectangle<i32, Physical>,
|
||||
scale: Scale<f64>,
|
||||
has_storage: impl FnOnce() -> bool,
|
||||
) -> bool {
|
||||
let geometry = element.geometry(scale);
|
||||
if element.alpha() != 1.0 || !geometry.contains_rect(bounds) {
|
||||
return false;
|
||||
}
|
||||
// A single opaque rectangle covering the output is deliberately more
|
||||
// conservative than reconstructing an arbitrary union of client regions.
|
||||
element
|
||||
.opaque_regions(scale)
|
||||
.iter()
|
||||
.any(|region| Rectangle::new(region.loc + geometry.loc, region.size).contains_rect(bounds))
|
||||
&& has_storage()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct OutputView<'a> {
|
||||
global: DmabufGlobal,
|
||||
feedback: &'a OutputFeedback,
|
||||
eligible: Option<Id>,
|
||||
}
|
||||
|
||||
fn select<'a>(
|
||||
global: DmabufGlobal,
|
||||
surface: &Id,
|
||||
outputs: impl IntoIterator<Item = OutputView<'a>>,
|
||||
) -> Option<&'a DmabufFeedback> {
|
||||
let mut visible = outputs
|
||||
.into_iter()
|
||||
.filter(|output| output.feedback.visible.contains(surface));
|
||||
let output = visible.next()?;
|
||||
// A surface visible on multiple outputs gets render fallback rather than
|
||||
// whichever output happened to render last. Cross-GPU import is not assumed.
|
||||
if visible.next().is_some()
|
||||
|| output.global != global
|
||||
|| output.eligible.as_ref() != Some(surface)
|
||||
|| !output.feedback.candidates.contains(surface)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
output.feedback.preferred.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn refresh(state: &mut Ferese) {
|
||||
if state.dmabuf_feedback.requested().next().is_none() {
|
||||
return;
|
||||
}
|
||||
let mut preferred = HashMap::new();
|
||||
if let Some(backend) = state.direct_backend.as_ref() {
|
||||
// Eligibility belongs to an output, so compute it once for all subscribers.
|
||||
let outputs = backend
|
||||
.devices
|
||||
.values()
|
||||
.flat_map(|device| device.outputs.values().map(move |output| (device, output)))
|
||||
.filter(|(_, output)| state.output_id(&output.output).is_some());
|
||||
let views: Vec<_> = outputs
|
||||
.map(|(device, output)| {
|
||||
let eligible = (backend.can_render()
|
||||
&& device.drm.is_active()
|
||||
&& output.power.can_render()
|
||||
&& output.mirror_source.is_none()
|
||||
&& !state.session_lock.active()
|
||||
&& !state.output_has_animations(&output.output)
|
||||
&& state.output_has_fullscreen(&output.output))
|
||||
.then(|| {
|
||||
state
|
||||
.output_id(&output.output)
|
||||
.and_then(|id| state.output_workspaces.active_workspace(id))
|
||||
.and_then(|workspace| state.workspaces.workspace(workspace))
|
||||
.and_then(|workspace| workspace.fullscreen)
|
||||
.filter(|id| {
|
||||
state
|
||||
.current_window_presentation(*id)
|
||||
.is_some_and(|presentation| {
|
||||
presentation.alpha() == 1.0 && presentation.dim == 0.0
|
||||
})
|
||||
})
|
||||
.and_then(|id| state.windows.window(id))
|
||||
.and_then(|window| window.toplevel())
|
||||
.filter(|toplevel| {
|
||||
with_renderer_surface_state(toplevel.wl_surface(), |state| {
|
||||
state.buffer().is_some()
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|toplevel| Id::from(toplevel.wl_surface()))
|
||||
})
|
||||
.flatten();
|
||||
OutputView {
|
||||
global: device.dmabuf_global,
|
||||
feedback: &output.dmabuf_feedback,
|
||||
eligible,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if views.iter().any(|view| view.eligible.is_some()) {
|
||||
for (surface, global) in state.dmabuf_feedback.requested() {
|
||||
let id = Id::from(surface);
|
||||
if let Some(feedback) = select(global, &id, views.iter().cloned()) {
|
||||
preferred.insert(surface.clone(), feedback.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
state.dmabuf_feedback.refresh(&preferred);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use smithay::backend::allocator::{Fourcc, Modifier};
|
||||
use smithay::backend::renderer::element::{RenderElementPresentationState, RenderElementState};
|
||||
use smithay::reexports::wayland_server::Display;
|
||||
use smithay::wayland::dmabuf::DmabufState;
|
||||
|
||||
fn format(code: Fourcc, modifier: Modifier) -> Format {
|
||||
Format { code, modifier }
|
||||
}
|
||||
fn formats(values: &[Format]) -> FormatSet {
|
||||
values.iter().copied().collect()
|
||||
}
|
||||
fn output(render: &[Format], primary: &[Format]) -> OutputFeedback {
|
||||
OutputFeedback::new(10, 20, formats(render), formats(primary))
|
||||
}
|
||||
fn create_globals() -> (Display<Ferese>, DmabufGlobal, DmabufGlobal) {
|
||||
let display = Display::new().unwrap();
|
||||
let mut state = DmabufState::new();
|
||||
let feedback = DmabufFeedbackBuilder::new(10, [format(Fourcc::Argb8888, Modifier::Linear)])
|
||||
.build()
|
||||
.unwrap();
|
||||
let global =
|
||||
state.create_global_with_default_feedback::<Ferese>(&display.handle(), &feedback);
|
||||
let other =
|
||||
state.create_global_with_default_feedback::<Ferese>(&display.handle(), &feedback);
|
||||
(display, global, other)
|
||||
}
|
||||
fn visible(output: &mut OutputFeedback, id: &Id, f: Format) {
|
||||
let mut states = RenderElementStates::default();
|
||||
states.states.insert(
|
||||
id.clone(),
|
||||
RenderElementState {
|
||||
needs_capture: false,
|
||||
visible_area: 100,
|
||||
presentation_state: RenderElementPresentationState::Rendering { reason: None },
|
||||
},
|
||||
);
|
||||
output.observed_frame(Some(f), &states, HashSet::from([id.clone()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_require_exact_renderer_and_primary_intersection() {
|
||||
let linear = format(Fourcc::Argb8888, Modifier::Linear);
|
||||
let implicit = format(Fourcc::Argb8888, Modifier::Invalid);
|
||||
let opaque = format(Fourcc::Xrgb8888, Modifier::Linear);
|
||||
assert!(compatible(
|
||||
&formats(&[linear, implicit]),
|
||||
&formats(&[linear]),
|
||||
linear
|
||||
));
|
||||
assert!(!compatible(
|
||||
&formats(&[linear]),
|
||||
&formats(&[implicit]),
|
||||
linear
|
||||
));
|
||||
assert!(!compatible(
|
||||
&formats(&[linear]),
|
||||
&formats(&[opaque]),
|
||||
linear
|
||||
));
|
||||
assert!(!compatible(
|
||||
&formats(&[linear]),
|
||||
&formats(&[implicit]),
|
||||
implicit
|
||||
));
|
||||
assert!(!compatible(
|
||||
&formats(&[opaque]),
|
||||
&formats(&[linear]),
|
||||
linear
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observed_format_limits_preference_and_retains_renderer_fallback() {
|
||||
let linear = format(Fourcc::Argb8888, Modifier::Linear);
|
||||
let other = format(Fourcc::Abgr8888, Modifier::Linear);
|
||||
let mut o = output(&[linear, other], &[linear, other]);
|
||||
assert!(o.preferred.is_none());
|
||||
o.observe_format(Some(linear));
|
||||
let expected = DmabufFeedbackBuilder::new(10, [linear, other])
|
||||
.add_preference_tranche(20, TrancheFlags::Scanout, [linear], 4..=6)
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(o.preferred.as_ref(), Some(&expected));
|
||||
o.observe_format(Some(other));
|
||||
assert_ne!(o.preferred.as_ref(), Some(&expected));
|
||||
o.observe_format(Some(format(Fourcc::Rgb565, Modifier::Linear)));
|
||||
assert!(o.preferred.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_multi_output_cross_gpu_and_policy_changes_fall_back() {
|
||||
let (_display, global, other_global) = create_globals();
|
||||
let id = Id::new();
|
||||
let f = format(Fourcc::Argb8888, Modifier::Linear);
|
||||
let mut a = output(&[f], &[f]);
|
||||
let mut b = output(&[f], &[f]);
|
||||
visible(&mut a, &id, f);
|
||||
visible(&mut b, &id, f);
|
||||
fn view(
|
||||
global: DmabufGlobal,
|
||||
feedback: &OutputFeedback,
|
||||
eligible: Option<Id>,
|
||||
) -> OutputView<'_> {
|
||||
OutputView {
|
||||
global,
|
||||
feedback,
|
||||
eligible,
|
||||
}
|
||||
}
|
||||
assert!(select(global, &id, [view(global, &a, Some(id.clone()))]).is_some());
|
||||
assert!(select(global, &id, [view(global, &a, None)]).is_none());
|
||||
assert!(
|
||||
select(
|
||||
global,
|
||||
&id,
|
||||
[
|
||||
view(global, &a, Some(id.clone())),
|
||||
view(global, &b, Some(id.clone()))
|
||||
]
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(select(other_global, &id, [view(global, &a, Some(id.clone()))]).is_none());
|
||||
a.invalidate();
|
||||
assert!(select(global, &id, [view(global, &a, Some(id.clone()))]).is_none());
|
||||
assert!(
|
||||
select(
|
||||
global,
|
||||
&id,
|
||||
[
|
||||
view(global, &a, Some(id.clone())),
|
||||
view(global, &b, Some(id.clone()))
|
||||
]
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
b.candidates.clear();
|
||||
assert!(select(global, &id, [view(global, &b, Some(id.clone()))]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconfiguration_invalidates_observations_until_a_new_frame() {
|
||||
let (_display, global, _) = create_globals();
|
||||
let id = Id::new();
|
||||
let f = format(Fourcc::Argb8888, Modifier::Linear);
|
||||
let mut o = output(&[f], &[f]);
|
||||
visible(&mut o, &id, f);
|
||||
o.invalidate();
|
||||
assert!(
|
||||
o.observed.is_none()
|
||||
&& o.preferred.is_none()
|
||||
&& o.visible.is_empty()
|
||||
&& o.candidates.is_empty()
|
||||
);
|
||||
o.observe_format(None);
|
||||
assert!(o.preferred.is_none());
|
||||
visible(&mut o, &id, f);
|
||||
assert!(
|
||||
select(
|
||||
global,
|
||||
&id,
|
||||
[OutputView {
|
||||
global,
|
||||
feedback: &o,
|
||||
eligible: Some(id.clone())
|
||||
}]
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_geometry_requires_covering_opaque_content_and_storage() {
|
||||
use smithay::backend::renderer::element::Kind;
|
||||
use smithay::backend::renderer::element::solid::SolidColorRenderElement;
|
||||
use smithay::backend::renderer::utils::CommitCounter;
|
||||
use smithay::utils::Transform;
|
||||
for transform in [Transform::Normal, Transform::_90] {
|
||||
let bounds = Rectangle::from_size(transform.transform_size((100, 60).into()));
|
||||
for scale in [1.0, 1.25, 2.0] {
|
||||
let solid = |geometry, alpha| {
|
||||
SolidColorRenderElement::new(
|
||||
Id::new(),
|
||||
geometry,
|
||||
CommitCounter::default(),
|
||||
[1., 1., 1., alpha],
|
||||
Kind::Unspecified,
|
||||
)
|
||||
};
|
||||
let opaque = solid(bounds, 1.0);
|
||||
assert!(candidate_geometry(&opaque, bounds, scale.into(), || true));
|
||||
assert!(
|
||||
!candidate_geometry(&opaque, bounds, scale.into(), || false),
|
||||
"clipped storage must remain hidden"
|
||||
);
|
||||
let faded = solid(bounds, 0.8);
|
||||
assert!(!candidate_geometry(
|
||||
&faded,
|
||||
bounds,
|
||||
scale.into(),
|
||||
|| panic!("faded elements must not probe storage")
|
||||
));
|
||||
let mut partial = bounds;
|
||||
partial.size.w -= 1;
|
||||
assert!(!candidate_geometry(
|
||||
&solid(partial, 1.0),
|
||||
bounds,
|
||||
scale.into(),
|
||||
|| panic!("partial content must not probe storage")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_scanout_and_occlusion_withdraw_preferences_but_format_hints_can_recover() {
|
||||
let (_display, global, _) = create_globals();
|
||||
let id = Id::new();
|
||||
let f = format(Fourcc::Argb8888, Modifier::Linear);
|
||||
let mut o = output(&[f], &[f]);
|
||||
for (state, expected) in [
|
||||
(
|
||||
RenderElementState {
|
||||
needs_capture: false,
|
||||
visible_area: 100,
|
||||
presentation_state: RenderElementPresentationState::Rendering {
|
||||
reason: Some(RenderingReason::FormatUnsupported),
|
||||
},
|
||||
},
|
||||
true,
|
||||
),
|
||||
(
|
||||
RenderElementState {
|
||||
needs_capture: false,
|
||||
visible_area: 100,
|
||||
presentation_state: RenderElementPresentationState::Rendering {
|
||||
reason: Some(RenderingReason::ScanoutFailed),
|
||||
},
|
||||
},
|
||||
false,
|
||||
),
|
||||
(
|
||||
RenderElementState {
|
||||
needs_capture: false,
|
||||
visible_area: 0,
|
||||
presentation_state: RenderElementPresentationState::Skipped,
|
||||
},
|
||||
false,
|
||||
),
|
||||
(
|
||||
RenderElementState {
|
||||
needs_capture: false,
|
||||
visible_area: 100,
|
||||
presentation_state: RenderElementPresentationState::ZeroCopy,
|
||||
},
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let states = RenderElementStates {
|
||||
states: HashMap::from([(id.clone(), state)]),
|
||||
};
|
||||
o.observed_frame(Some(f), &states, HashSet::from([id.clone()]));
|
||||
assert_eq!(
|
||||
select(
|
||||
global,
|
||||
&id,
|
||||
[OutputView {
|
||||
global,
|
||||
feedback: &o,
|
||||
eligible: Some(id.clone())
|
||||
}]
|
||||
)
|
||||
.is_some(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_failure_never_publishes_prepared_feedback_observations() {
|
||||
let f = format(Fourcc::Argb8888, Modifier::Linear);
|
||||
let id = Id::new();
|
||||
let mut o = output(&[f], &[f]);
|
||||
let failed = super::super::capture::after_queue(Err::<(), _>("queue failed"), || {
|
||||
visible(&mut o, &id, f)
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
assert!(o.visible.is_empty() && o.preferred.is_none());
|
||||
super::super::capture::after_queue(Ok::<(), ()>(()), || visible(&mut o, &id, f)).unwrap();
|
||||
assert!(o.visible.contains(&id) && o.preferred.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use smithay::backend::drm::{DrmDevice, DrmDeviceFd};
|
||||
use smithay::output::Output;
|
||||
use smithay::reexports::drm::control::{Device as _, crtc};
|
||||
use smithay::reexports::wayland_protocols_wlr::gamma_control::v1::server::zwlr_gamma_control_v1::ZwlrGammaControlV1;
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::gamma::Ramp;
|
||||
use ferese_config::night_light::Settings;
|
||||
use ferese_ipc::night_light::Status;
|
||||
|
||||
const TRANSITION_DURATION: Duration = Duration::from_secs(1);
|
||||
const TRANSITION_INTERVAL: Duration = Duration::from_millis(16);
|
||||
|
||||
struct Transition {
|
||||
from: Ramp,
|
||||
started: Instant,
|
||||
duration: Duration,
|
||||
}
|
||||
|
||||
// A single owner determines both the desired ramp and who loses control on failure.
|
||||
enum Source {
|
||||
Original,
|
||||
Client {
|
||||
control: ZwlrGammaControlV1,
|
||||
ramp: Ramp,
|
||||
},
|
||||
NightLight {
|
||||
temperature: u16,
|
||||
ramp: Ramp,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) enum State {
|
||||
#[default]
|
||||
Unprobed,
|
||||
Unsupported,
|
||||
Ready(Box<Gamma>),
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn probe(&mut self, drm: &DrmDevice, crtc: crtc::Handle) -> Option<&mut Gamma> {
|
||||
if matches!(self, Self::Unprobed) {
|
||||
*self = match Gamma::new(drm, crtc) {
|
||||
Ok(gamma) => Self::Ready(Box::new(gamma)),
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, ?crtc, "output gamma unavailable");
|
||||
Self::Unsupported
|
||||
}
|
||||
};
|
||||
}
|
||||
self.controller_mut()
|
||||
}
|
||||
|
||||
fn controller(&self) -> Option<&Gamma> {
|
||||
match self {
|
||||
Self::Ready(gamma) => Some(gamma),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn controller_mut(&mut self) -> Option<&mut Gamma> {
|
||||
match self {
|
||||
Self::Ready(gamma) => Some(gamma),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn suspend(&mut self) {
|
||||
if let Some(gamma) = self.controller_mut() {
|
||||
gamma.suspend();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn retry_probe(&mut self) {
|
||||
if matches!(self, Self::Unsupported) {
|
||||
*self = Self::Unprobed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Gamma {
|
||||
fd: DrmDeviceFd,
|
||||
crtc: crtc::Handle,
|
||||
original: Ramp,
|
||||
current: Ramp,
|
||||
transition: Option<Transition>,
|
||||
source: Source,
|
||||
suspended: bool,
|
||||
modified: bool,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl Gamma {
|
||||
fn new(drm: &DrmDevice, crtc: crtc::Handle) -> io::Result<Self> {
|
||||
let mut original = Ramp::zeroed(drm.get_crtc(crtc)?.gamma_length() as usize)?;
|
||||
let (red, green, blue) = original.channels_mut();
|
||||
drm.get_gamma(crtc, red, green, blue)?;
|
||||
Ok(Self {
|
||||
fd: drm.device_fd().clone(),
|
||||
crtc,
|
||||
current: original.clone(),
|
||||
transition: None,
|
||||
original,
|
||||
source: Source::Original,
|
||||
suspended: false,
|
||||
modified: false,
|
||||
failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn write(&self, ramp: &Ramp) -> io::Result<()> {
|
||||
if self.fd.get_crtc(self.crtc)?.gamma_length() as usize != ramp.size() {
|
||||
return Err(io::ErrorKind::InvalidData.into());
|
||||
}
|
||||
let (red, green, blue) = ramp.channels();
|
||||
self.fd.set_gamma(self.crtc, red, green, blue)
|
||||
}
|
||||
|
||||
fn restore(&mut self) {
|
||||
if self.suspended || !self.modified {
|
||||
return;
|
||||
}
|
||||
match self.write(&self.original) {
|
||||
Ok(()) => {
|
||||
self.modified = false;
|
||||
self.current = self.original.clone();
|
||||
}
|
||||
Err(error) => tracing::warn!(%error, ?self.crtc, "could not restore output gamma"),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_source(&mut self, source: Source) {
|
||||
if let Source::Client { control, .. } = std::mem::replace(&mut self.source, source) {
|
||||
control.failed();
|
||||
}
|
||||
}
|
||||
|
||||
fn configure(&mut self, settings: Settings, duration: Duration, now: Instant) {
|
||||
if settings.enabled {
|
||||
if matches!(self.source, Source::NightLight { temperature, .. } if temperature == settings.temperature)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.replace_source(Source::NightLight {
|
||||
temperature: settings.temperature,
|
||||
ramp: self.original.at_temperature(settings.temperature),
|
||||
});
|
||||
} else if matches!(self.source, Source::NightLight { .. }) {
|
||||
self.source = Source::Original;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
let target = match &self.source {
|
||||
Source::Original => &self.original,
|
||||
Source::NightLight { ramp, .. } | Source::Client { ramp, .. } => ramp,
|
||||
};
|
||||
self.transition =
|
||||
(!duration.is_zero() && !self.suspended && self.current != *target).then(|| {
|
||||
Transition {
|
||||
from: self.current.clone(),
|
||||
started: now,
|
||||
duration,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn owns(&self, owner: &ZwlrGammaControlV1) -> bool {
|
||||
matches!(&self.source, Source::Client { control, .. } if control == owner)
|
||||
}
|
||||
|
||||
pub(super) fn suspend(&mut self) {
|
||||
self.transition = None;
|
||||
self.restore();
|
||||
self.suspended = true;
|
||||
}
|
||||
|
||||
fn resume(&mut self, now: Instant) {
|
||||
self.suspended = false;
|
||||
let ramp = match &self.source {
|
||||
Source::Original if !self.modified && self.transition.is_none() => {
|
||||
self.failed = false;
|
||||
return;
|
||||
}
|
||||
Source::Original => &self.original,
|
||||
Source::Client { ramp, .. } | Source::NightLight { ramp, .. } => ramp,
|
||||
};
|
||||
let animated;
|
||||
let ramp = if let Some(transition) = &self.transition {
|
||||
let progress = now
|
||||
.saturating_duration_since(transition.started)
|
||||
.as_secs_f64()
|
||||
/ transition.duration.as_secs_f64();
|
||||
if progress >= 1. {
|
||||
self.transition = None;
|
||||
ramp
|
||||
} else {
|
||||
let eased = progress * progress * (3. - 2. * progress);
|
||||
animated = transition.from.interpolate(ramp, eased);
|
||||
&animated
|
||||
}
|
||||
} else {
|
||||
ramp
|
||||
};
|
||||
// Even a failed driver call may need the original table restored.
|
||||
self.modified |= ramp != &self.original;
|
||||
match self.write(ramp) {
|
||||
Ok(()) => {
|
||||
self.current = ramp.clone();
|
||||
self.modified = self.current != self.original;
|
||||
self.failed = false;
|
||||
}
|
||||
Err(error) => {
|
||||
self.transition = None;
|
||||
self.failed = true;
|
||||
tracing::warn!(%error, ?self.crtc, "could not apply output gamma");
|
||||
if matches!(self.source, Source::Client { .. }) {
|
||||
self.replace_source(Source::Original);
|
||||
}
|
||||
self.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Gamma {
|
||||
fn drop(&mut self) {
|
||||
self.replace_source(Source::Original);
|
||||
self.restore();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn acquire(
|
||||
state: &mut Ferese,
|
||||
target: &Output,
|
||||
owner: &ZwlrGammaControlV1,
|
||||
) -> Option<usize> {
|
||||
// Reconcile a just-reloaded configuration before assigning ownership.
|
||||
sync_night_light(state);
|
||||
if state.night_light.enabled {
|
||||
return None;
|
||||
}
|
||||
let duration = state.animation_duration(TRANSITION_DURATION);
|
||||
let backend = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.filter(|backend| backend.active)?;
|
||||
for device in backend
|
||||
.devices
|
||||
.values_mut()
|
||||
.filter(|device| device.drm.is_active())
|
||||
{
|
||||
for (crtc, output) in &mut device.outputs {
|
||||
if &output.output != target || output.global.is_none() {
|
||||
continue;
|
||||
}
|
||||
let gamma = output.gamma.probe(&device.drm, *crtc)?;
|
||||
gamma.configure(state.night_light, duration, Instant::now());
|
||||
if !matches!(gamma.source, Source::Original) {
|
||||
return None;
|
||||
}
|
||||
gamma.transition = None;
|
||||
gamma.source = Source::Client {
|
||||
control: owner.clone(),
|
||||
ramp: gamma.original.clone(),
|
||||
};
|
||||
return Some(gamma.original.size());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn sync_night_light(state: &mut Ferese) {
|
||||
if !state.gamma_dirty {
|
||||
return;
|
||||
}
|
||||
if !state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.is_some_and(|backend| backend.active)
|
||||
{
|
||||
return;
|
||||
}
|
||||
state.gamma_dirty = false;
|
||||
if advance_night_light(state, true) && !state.gamma_timer_pending {
|
||||
let result = state.loop_handle.insert_source(
|
||||
Timer::from_duration(TRANSITION_INTERVAL),
|
||||
|_, _, state| {
|
||||
if advance_night_light(state, false) {
|
||||
TimeoutAction::ToDuration(TRANSITION_INTERVAL)
|
||||
} else {
|
||||
state.gamma_timer_pending = false;
|
||||
TimeoutAction::Drop
|
||||
}
|
||||
},
|
||||
);
|
||||
match result {
|
||||
Ok(_) => state.gamma_timer_pending = true,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "could not schedule night-light transition");
|
||||
if let Some(backend) = &mut state.direct_backend {
|
||||
for device in backend
|
||||
.devices
|
||||
.values_mut()
|
||||
.filter(|device| device.drm.is_active())
|
||||
{
|
||||
for output in device
|
||||
.outputs
|
||||
.values_mut()
|
||||
.filter(|output| !output.power.needs_wake())
|
||||
{
|
||||
if let Some(gamma) = output.gamma.controller_mut() {
|
||||
gamma.transition = None;
|
||||
gamma.resume(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_night_light(state: &mut Ferese, force: bool) -> bool {
|
||||
let duration = state.animation_duration(TRANSITION_DURATION);
|
||||
let now = Instant::now();
|
||||
let Some(backend) = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.filter(|backend| backend.active)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut pending = false;
|
||||
for device in backend
|
||||
.devices
|
||||
.values_mut()
|
||||
.filter(|device| device.drm.is_active())
|
||||
{
|
||||
for (crtc, output) in &mut device.outputs {
|
||||
let ready = !output.power.needs_wake();
|
||||
let gamma = if ready {
|
||||
output.gamma.probe(&device.drm, *crtc)
|
||||
} else {
|
||||
output.gamma.controller_mut()
|
||||
};
|
||||
if let Some(gamma) = gamma {
|
||||
gamma.configure(state.night_light, duration, now);
|
||||
let update = force || gamma.transition.is_some();
|
||||
if duration.is_zero() {
|
||||
gamma.transition = None;
|
||||
}
|
||||
if ready && update {
|
||||
gamma.resume(now);
|
||||
pending |= gamma.transition.is_some();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pending
|
||||
}
|
||||
|
||||
pub(crate) fn night_light_status(state: &Ferese) -> Status {
|
||||
let mut status = Status {
|
||||
enabled: state.night_light.enabled,
|
||||
temperature: state.night_light.temperature,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(backend) = &state.direct_backend {
|
||||
for device in backend.devices.values() {
|
||||
for output in device.outputs.values() {
|
||||
if let Some(gamma) = output.gamma.controller() {
|
||||
status.supported_outputs += 1;
|
||||
status.failed_outputs += usize::from(gamma.failed);
|
||||
status.active_outputs += usize::from(
|
||||
backend.active
|
||||
&& !output.power.needs_wake()
|
||||
&& matches!(gamma.source, Source::NightLight { .. })
|
||||
&& !gamma.failed
|
||||
&& !gamma.suspended,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
fn owned<'a>(
|
||||
state: &'a mut Ferese,
|
||||
target: &Output,
|
||||
owner: &ZwlrGammaControlV1,
|
||||
) -> Option<(&'a mut Gamma, bool)> {
|
||||
let duration = state.animation_duration(TRANSITION_DURATION);
|
||||
let backend = state.direct_backend.as_mut()?;
|
||||
for device in backend.devices.values_mut() {
|
||||
for output in device.outputs.values_mut() {
|
||||
if &output.output == target {
|
||||
let ready = backend.active && device.drm.is_active() && !output.power.needs_wake();
|
||||
let gamma = output.gamma.controller_mut()?;
|
||||
gamma.configure(state.night_light, duration, Instant::now());
|
||||
return gamma.owns(owner).then_some((gamma, ready));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn size(
|
||||
state: &mut Ferese,
|
||||
target: &Output,
|
||||
owner: &ZwlrGammaControlV1,
|
||||
) -> Option<usize> {
|
||||
owned(state, target, owner).map(|(gamma, _)| gamma.original.size())
|
||||
}
|
||||
|
||||
pub(crate) fn set(state: &mut Ferese, target: &Output, owner: &ZwlrGammaControlV1, ramp: Ramp) {
|
||||
if let Some((gamma, ready)) = owned(state, target, owner) {
|
||||
gamma.transition = None;
|
||||
gamma.source = Source::Client {
|
||||
control: owner.clone(),
|
||||
ramp,
|
||||
};
|
||||
if ready {
|
||||
gamma.resume(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn release(state: &mut Ferese, target: &Output, owner: &ZwlrGammaControlV1) {
|
||||
if let Some((gamma, ready)) = owned(state, target, owner) {
|
||||
gamma.transition = None;
|
||||
gamma.source = Source::Original;
|
||||
if ready {
|
||||
gamma.resume(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
//! Resume reads are asynchronous; newer switch events win over old replies.
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
|
||||
use super::{
|
||||
Duration, Error, Ferese, RegistrationToken, TimeoutAction, Timer, cancel_output_timers,
|
||||
reconcile_outputs,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct Probe {
|
||||
serial: u64,
|
||||
revision: u64,
|
||||
reactivate: bool,
|
||||
wake: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct LidState {
|
||||
value: Option<bool>,
|
||||
revision: u64,
|
||||
serial: u64,
|
||||
pending: Option<Probe>,
|
||||
}
|
||||
|
||||
impl LidState {
|
||||
pub fn closed(&self) -> bool {
|
||||
self.value == Some(true)
|
||||
}
|
||||
pub fn pending(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
|
||||
pub fn observe(&mut self, closed: bool) -> bool {
|
||||
let changed = self.value != Some(closed);
|
||||
self.value = Some(closed);
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
changed
|
||||
}
|
||||
|
||||
fn begin(&mut self, reactivate: bool, wake: bool) -> Probe {
|
||||
self.serial = self.serial.wrapping_add(1);
|
||||
let probe = Probe {
|
||||
serial: self.serial,
|
||||
revision: self.revision,
|
||||
reactivate: reactivate || self.pending.is_some_and(|pending| pending.reactivate),
|
||||
wake: wake || self.pending.is_some_and(|pending| pending.wake),
|
||||
};
|
||||
self.pending = Some(probe);
|
||||
probe
|
||||
}
|
||||
|
||||
fn complete(&mut self, probe: Probe, value: Option<bool>) -> Option<bool> {
|
||||
if self.pending != Some(probe) {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.pending = None;
|
||||
if probe.revision == self.revision
|
||||
&& let Some(value) = value
|
||||
{
|
||||
self.observe(value);
|
||||
}
|
||||
Some(probe.reactivate)
|
||||
}
|
||||
|
||||
pub fn pause(&mut self) {
|
||||
self.pending = None;
|
||||
}
|
||||
}
|
||||
|
||||
struct InhibitorLease<T> {
|
||||
active: bool,
|
||||
generation: u64,
|
||||
descriptor: Option<T>,
|
||||
suspend: bool,
|
||||
}
|
||||
|
||||
impl<T> Default for InhibitorLease<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
generation: 0,
|
||||
descriptor: None,
|
||||
suspend: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> InhibitorLease<T> {
|
||||
fn activate(&mut self, active: bool) -> u64 {
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
self.active = active;
|
||||
self.suspend = false;
|
||||
// Closing here releases logind immediately, independently of worker progress.
|
||||
self.descriptor = None;
|
||||
self.generation
|
||||
}
|
||||
|
||||
fn accept(&mut self, generation: u64, descriptor: T) {
|
||||
if self.active && self.generation == generation {
|
||||
self.descriptor = Some(descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PolicyRequest {
|
||||
Activate(u64),
|
||||
Suspend,
|
||||
}
|
||||
|
||||
pub(super) struct Reader {
|
||||
requests: mpsc::Sender<Probe>,
|
||||
suspend: mpsc::Sender<PolicyRequest>,
|
||||
lease: Arc<Mutex<InhibitorLease<zbus::zvariant::OwnedFd>>>,
|
||||
suspend_requested: bool,
|
||||
pub deadline: Option<RegistrationToken>,
|
||||
}
|
||||
|
||||
fn read_logind(connection: &zbus::blocking::Connection) -> zbus::Result<bool> {
|
||||
// A direct Get avoids retaining a property cache across suspend.
|
||||
let value: zbus::zvariant::OwnedValue = connection
|
||||
.call_method(
|
||||
Some("org.freedesktop.login1"),
|
||||
"/org/freedesktop/login1",
|
||||
Some("org.freedesktop.DBus.Properties"),
|
||||
"Get",
|
||||
&("org.freedesktop.login1.Manager", "LidClosed"),
|
||||
)?
|
||||
.body()
|
||||
.deserialize()?;
|
||||
bool::try_from(value).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn read_acpi() -> Option<bool> {
|
||||
let mut state = None;
|
||||
for entry in std::fs::read_dir("/proc/acpi/button/lid").ok()?.flatten() {
|
||||
let Ok(text) = std::fs::read_to_string(entry.path().join("state")) else {
|
||||
continue;
|
||||
};
|
||||
match text.split_whitespace().last() {
|
||||
Some("closed") => return Some(true),
|
||||
Some("open") => state = Some(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
pub fn set_active(&mut self, active: bool) {
|
||||
let generation = self
|
||||
.lease
|
||||
.lock()
|
||||
.expect("lid policy lease must remain consistent across workers")
|
||||
.activate(active);
|
||||
self.suspend_requested = false;
|
||||
if active {
|
||||
let _ = self.suspend.send(PolicyRequest::Activate(generation));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(state: &Ferese) -> Result<Self, Box<dyn Error>> {
|
||||
let (suspend, suspend_requests) = mpsc::channel::<PolicyRequest>();
|
||||
let lease = Arc::new(Mutex::new(InhibitorLease::default()));
|
||||
let worker_lease = lease.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("ferese-lid-policy".into())
|
||||
.spawn(move || {
|
||||
let connection = zbus::blocking::connection::Builder::system()
|
||||
.and_then(|builder| builder.method_timeout(Duration::from_secs(5)).build());
|
||||
let Ok(connection) = connection else { return };
|
||||
while let Ok(request) = suspend_requests.recv() {
|
||||
if let PolicyRequest::Activate(generation) = request {
|
||||
let current = worker_lease
|
||||
.lock()
|
||||
.expect("lid policy lease must remain consistent across workers");
|
||||
if !current.active || current.generation != generation {
|
||||
continue;
|
||||
}
|
||||
drop(current);
|
||||
let inhibitor = connection
|
||||
.call_method(
|
||||
Some("org.freedesktop.login1"),
|
||||
"/org/freedesktop/login1",
|
||||
Some("org.freedesktop.login1.Manager"),
|
||||
"Inhibit",
|
||||
&(
|
||||
"handle-lid-switch",
|
||||
"Ferese",
|
||||
"Reconcile monitors before lid action",
|
||||
"block",
|
||||
),
|
||||
)
|
||||
.and_then(|reply| {
|
||||
reply.body().deserialize::<zbus::zvariant::OwnedFd>()
|
||||
});
|
||||
match inhibitor {
|
||||
Ok(descriptor) => worker_lease
|
||||
.lock()
|
||||
.expect("lid policy lease must remain consistent across workers")
|
||||
.accept(generation, descriptor),
|
||||
Err(error) => tracing::warn!(%error, "could not own logind lid policy"),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let current = worker_lease
|
||||
.lock()
|
||||
.expect("lid policy lease must remain consistent across workers");
|
||||
if !current.active || !current.suspend {
|
||||
continue;
|
||||
}
|
||||
drop(current);
|
||||
if read_logind(&connection).ok() != Some(true) {
|
||||
continue;
|
||||
}
|
||||
let allowed = connection
|
||||
.call_method(
|
||||
Some("org.freedesktop.login1"),
|
||||
"/org/freedesktop/login1",
|
||||
Some("org.freedesktop.DBus.Properties"),
|
||||
"Get",
|
||||
&("org.freedesktop.login1.Manager", "BlockInhibited"),
|
||||
)
|
||||
.and_then(|reply| reply.body().deserialize::<zbus::zvariant::OwnedValue>())
|
||||
.and_then(|value| String::try_from(value).map_err(Into::into));
|
||||
if allowed
|
||||
.as_ref()
|
||||
.is_ok_and(|inhibitors| inhibitors.split(':').any(|kind| kind == "sleep"))
|
||||
{
|
||||
tracing::debug!("lid suspend prevented by sleep inhibitor");
|
||||
continue;
|
||||
}
|
||||
let current = worker_lease
|
||||
.lock()
|
||||
.expect("lid policy lease must remain consistent across workers");
|
||||
let requested = current.active && current.suspend;
|
||||
drop(current);
|
||||
if !requested {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = connection.call_method(
|
||||
Some("org.freedesktop.login1"),
|
||||
"/org/freedesktop/login1",
|
||||
Some("org.freedesktop.login1.Manager"),
|
||||
"Suspend",
|
||||
&(false,),
|
||||
) {
|
||||
tracing::warn!(%error, "logind rejected lid suspend");
|
||||
}
|
||||
}
|
||||
worker_lease
|
||||
.lock()
|
||||
.expect("lid policy lease must remain consistent across workers")
|
||||
.activate(false);
|
||||
})?;
|
||||
let (requests, receiver) = mpsc::channel::<Probe>();
|
||||
let (replies, events) = smithay::reexports::calloop::channel::channel();
|
||||
state.loop_handle.insert_source(events, |event, _, state| {
|
||||
if let smithay::reexports::calloop::channel::Event::Msg((probe, value)) = event {
|
||||
complete_refresh(state, probe, value);
|
||||
}
|
||||
})?;
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("ferese-lid-reader".into())
|
||||
.spawn(move || {
|
||||
while let Ok(mut probe) = receiver.recv() {
|
||||
for newer in receiver.try_iter() {
|
||||
probe = newer;
|
||||
}
|
||||
let value = zbus::blocking::connection::Builder::system()
|
||||
.and_then(|builder| builder.method_timeout(Duration::from_secs(2)).build())
|
||||
.and_then(|connection| read_logind(&connection))
|
||||
.ok()
|
||||
.or_else(read_acpi);
|
||||
if replies.send((probe, value)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut reader = Self {
|
||||
requests,
|
||||
suspend,
|
||||
lease,
|
||||
suspend_requested: false,
|
||||
deadline: None,
|
||||
};
|
||||
reader.set_active(true);
|
||||
Ok(reader)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn request_refresh(state: &mut Ferese, reactivate: bool) {
|
||||
request(state, reactivate, true);
|
||||
}
|
||||
|
||||
/// Listener recovery reconciles hardware without resetting idle activity.
|
||||
pub(super) fn request_reconcile(state: &mut Ferese) {
|
||||
request(state, false, false);
|
||||
}
|
||||
|
||||
fn request(state: &mut Ferese, reactivate: bool, wake: bool) {
|
||||
let Some(backend) = state.direct_backend.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if !backend.active {
|
||||
backend.topology.dirty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(token) = backend.lid_reader.deadline.take() {
|
||||
state.loop_handle.remove(token);
|
||||
}
|
||||
// A timer that fires while readiness is gated would otherwise leave a
|
||||
// stale registration token on an unchanged output.
|
||||
for device in backend.devices.values_mut() {
|
||||
for output in device.outputs.values_mut() {
|
||||
cancel_output_timers(&state.loop_handle, output);
|
||||
}
|
||||
}
|
||||
backend.lid_reader.suspend_requested = false;
|
||||
backend
|
||||
.lid_reader
|
||||
.lease
|
||||
.lock()
|
||||
.expect("lid policy lease must remain consistent across workers")
|
||||
.suspend = false;
|
||||
let probe = backend.lid.begin(reactivate, wake);
|
||||
let sent = backend.lid_reader.requests.send(probe).is_ok();
|
||||
let timer = state.loop_handle.insert_source(
|
||||
Timer::from_duration(Duration::from_secs(3)),
|
||||
move |_, _, state| {
|
||||
if let Some(backend) = state.direct_backend.as_mut() {
|
||||
backend.lid_reader.deadline = None;
|
||||
}
|
||||
tracing::warn!("lid refresh deadline elapsed; retaining the latest observed state");
|
||||
complete_refresh(state, probe, None);
|
||||
TimeoutAction::Drop
|
||||
},
|
||||
);
|
||||
|
||||
if sent && let Ok(token) = timer {
|
||||
state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.lid_reader
|
||||
.deadline = Some(token);
|
||||
} else {
|
||||
if let Ok(token) = timer {
|
||||
state.loop_handle.remove(token);
|
||||
}
|
||||
complete_refresh(state, probe, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_refresh(state: &mut Ferese, probe: Probe, value: Option<bool>) {
|
||||
let Some(backend) = state.direct_backend.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(reactivate) = backend.lid.complete(probe, value) else {
|
||||
return;
|
||||
};
|
||||
if let Some(token) = backend.lid_reader.deadline.take() {
|
||||
state.loop_handle.remove(token);
|
||||
}
|
||||
if !backend.active {
|
||||
return;
|
||||
}
|
||||
|
||||
// Waking a locked session may request redraws. Hold them until the fresh
|
||||
// device/connector snapshot has been reconciled.
|
||||
backend.lid_reader.suspend_requested = false;
|
||||
if probe.wake {
|
||||
backend.topology.reconciling = true;
|
||||
state.reset_animation_clock();
|
||||
state.lock_input_activity();
|
||||
state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.topology
|
||||
.reconciling = false;
|
||||
}
|
||||
|
||||
reconcile_outputs(state, reactivate);
|
||||
}
|
||||
|
||||
/// Only logind requests suspend. Inactive events wait for fresh resume topology.
|
||||
pub(super) fn apply_suspend_policy(state: &mut Ferese) {
|
||||
let Some(backend) = state.direct_backend.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let requested = backend.active && backend.desired_outputs.suspend && backend.lid.closed();
|
||||
backend
|
||||
.lid_reader
|
||||
.lease
|
||||
.lock()
|
||||
.expect("lid policy lease must remain consistent across workers")
|
||||
.suspend = requested;
|
||||
if backend.lid_reader.suspend_requested != requested {
|
||||
backend.lid_reader.suspend_requested = requested;
|
||||
let _ = backend.lid_reader.suspend.send(PolicyRequest::Suspend);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pause_releases_inhibitor_and_rejects_delayed_acquisition() {
|
||||
let mut lease = InhibitorLease::default();
|
||||
let first = lease.activate(true);
|
||||
lease.accept(first, "fd");
|
||||
assert_eq!(lease.descriptor, Some("fd"));
|
||||
lease.activate(false);
|
||||
assert_eq!(lease.descriptor, None);
|
||||
lease.accept(first, "late fd");
|
||||
assert_eq!(lease.descriptor, None);
|
||||
let next = lease.activate(true);
|
||||
lease.accept(first, "old session fd");
|
||||
assert_eq!(lease.descriptor, None);
|
||||
lease.accept(next, "new session fd");
|
||||
assert_eq!(lease.descriptor, Some("new session fd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires dbus-daemon and private test sockets"]
|
||||
fn logind_reads_are_fresh_without_property_change_signals() {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
struct Bus(std::process::Child);
|
||||
impl Drop for Bus {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
struct Manager(Arc<AtomicBool>);
|
||||
#[zbus::interface(name = "org.freedesktop.login1.Manager")]
|
||||
impl Manager {
|
||||
#[zbus(property)]
|
||||
fn lid_closed(&self) -> bool {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
let mut bus = Bus(Command::new("dbus-daemon")
|
||||
.args(["--session", "--nofork", "--print-address=1"])
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap());
|
||||
let mut address = String::new();
|
||||
BufReader::new(bus.0.stdout.take().unwrap())
|
||||
.read_line(&mut address)
|
||||
.unwrap();
|
||||
let closed = Arc::new(AtomicBool::new(true));
|
||||
let server = zbus::blocking::connection::Builder::address(address.trim())
|
||||
.unwrap()
|
||||
.name("org.freedesktop.login1")
|
||||
.unwrap()
|
||||
.serve_at("/org/freedesktop/login1", Manager(closed.clone()))
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
let client = zbus::blocking::connection::Builder::address(address.trim())
|
||||
.unwrap()
|
||||
.method_timeout(Duration::from_secs(1))
|
||||
.build()
|
||||
.unwrap();
|
||||
assert!(read_logind(&client).unwrap());
|
||||
closed.store(false, Ordering::Relaxed);
|
||||
assert!(!read_logind(&client).unwrap());
|
||||
server.release_name("org.freedesktop.login1").unwrap();
|
||||
assert!(
|
||||
read_logind(&client).is_err(),
|
||||
"missing logind must not masquerade as an open lid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_input_wins_over_a_delayed_resume_read() {
|
||||
let mut lid = LidState::default();
|
||||
lid.observe(true);
|
||||
let probe = lid.begin(true, true);
|
||||
lid.observe(false);
|
||||
assert_eq!(lid.complete(probe, Some(true)), Some(true));
|
||||
assert!(!lid.closed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_reads_preserve_known_state_and_unknown_is_not_an_open_observation() {
|
||||
let mut lid = LidState::default();
|
||||
let probe = lid.begin(false, true);
|
||||
lid.complete(probe, None);
|
||||
assert_eq!(lid.value, None);
|
||||
lid.observe(true);
|
||||
let probe = lid.begin(true, true);
|
||||
lid.complete(probe, None);
|
||||
assert_eq!(lid.value, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_and_newer_resume_invalidate_old_replies() {
|
||||
let mut lid = LidState::default();
|
||||
let old = lid.begin(true, true);
|
||||
lid.pause();
|
||||
assert_eq!(lid.complete(old, Some(true)), None);
|
||||
let current = lid.begin(true, true);
|
||||
assert_eq!(lid.complete(old, Some(true)), None);
|
||||
assert!(lid.pending());
|
||||
assert_eq!(lid.complete(current, Some(false)), Some(true));
|
||||
assert!(!lid.pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_recovery_does_not_request_idle_wake_or_reactivation() {
|
||||
let mut lid = LidState::default();
|
||||
let recovery = lid.begin(false, false);
|
||||
assert!(!recovery.wake);
|
||||
assert_eq!(lid.complete(recovery, Some(false)), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_recovery_preserves_a_pending_real_wake() {
|
||||
let mut lid = LidState::default();
|
||||
let resume = lid.begin(true, true);
|
||||
let recovery = lid.begin(false, false);
|
||||
assert!(recovery.wake);
|
||||
assert_eq!(lid.complete(resume, Some(false)), None);
|
||||
assert_eq!(lid.complete(recovery, Some(false)), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_wake_cannot_cancel_pending_session_reactivation() {
|
||||
let mut lid = LidState::default();
|
||||
lid.begin(true, true);
|
||||
let probe = lid.begin(false, true);
|
||||
assert_eq!(lid.complete(probe, Some(false)), Some(true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! A target displays a composition of the source, with no second workspace or input domain.
|
||||
//! Source alone releases client callbacks/presentation feedback. Targets have independent KMS clocks.
|
||||
#[cfg(test)]
|
||||
use smithay::backend::allocator::Fourcc;
|
||||
#[cfg(test)]
|
||||
use smithay::output::Scale;
|
||||
|
||||
use super::{
|
||||
CommitCounter, DirectOutput, Ferese, GlesRenderer, GlesTexture, Output, OutputMode,
|
||||
PhysicalProperties, Subpixel, Transform, capture, sampled_output_elements,
|
||||
};
|
||||
use smithay::backend::renderer::Texture;
|
||||
use smithay::utils::{Buffer, Physical, Rectangle};
|
||||
|
||||
/// Fit the complete source into the transformed target; black letterbox, never crop.
|
||||
fn fit(source: (i32, i32), target: (i32, i32)) -> Rectangle<i32, Physical> {
|
||||
let scale =
|
||||
(target.0 as f64 / source.0.max(1) as f64).min(target.1 as f64 / source.1.max(1) as f64);
|
||||
let width = (source.0 as f64 * scale).round().max(1.0) as i32;
|
||||
let height = (source.1 as f64 * scale).round().max(1.0) as i32;
|
||||
Rectangle::new(
|
||||
((target.0 - width) / 2, (target.1 - height) / 2).into(),
|
||||
(width, height).into(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn elements(
|
||||
state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
source: &Output,
|
||||
frame: &crate::state::FrameScene,
|
||||
target: &mut DirectOutput,
|
||||
) -> Result<
|
||||
Vec<crate::render::AnimatedWindowRenderElement>,
|
||||
smithay::backend::renderer::gles::GlesError,
|
||||
> {
|
||||
let elements = sampled_output_elements(state, renderer, source, true, frame);
|
||||
// Target renderer owns this texture, including when source and target use different GPUs.
|
||||
let canvas = target.mirror_canvas.get_or_insert_with(|| {
|
||||
Output::new(
|
||||
"mirror-canvas".into(),
|
||||
PhysicalProperties {
|
||||
serial_number: String::new(),
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "Ferese".into(),
|
||||
model: "Mirror composition".into(),
|
||||
},
|
||||
)
|
||||
});
|
||||
compose(
|
||||
renderer,
|
||||
&mut target.mirror_texture,
|
||||
canvas,
|
||||
source,
|
||||
&elements,
|
||||
)?;
|
||||
let texture = target
|
||||
.mirror_texture
|
||||
.as_ref()
|
||||
.expect("source composition has a texture")
|
||||
.clone();
|
||||
target.mirror_commit.increment();
|
||||
Ok(vec![
|
||||
fitted_texture(
|
||||
texture,
|
||||
&target.output,
|
||||
target.mirror_id.clone(),
|
||||
target.mirror_commit,
|
||||
)
|
||||
.into(),
|
||||
])
|
||||
}
|
||||
|
||||
pub(super) fn compose(
|
||||
renderer: &mut GlesRenderer,
|
||||
texture: &mut Option<GlesTexture>,
|
||||
canvas: &Output,
|
||||
source: &Output,
|
||||
elements: &[crate::render::AnimatedWindowRenderElement],
|
||||
) -> Result<(), smithay::backend::renderer::gles::GlesError> {
|
||||
configure_canvas(canvas, source);
|
||||
let framebuffer = capture::render_texture(renderer, texture, canvas, elements)?;
|
||||
drop(framebuffer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn configure_canvas(canvas: &Output, source: &Output) {
|
||||
let source_mode = source.current_mode().expect("source has a mode");
|
||||
canvas.change_current_state(
|
||||
Some(OutputMode {
|
||||
size: source.current_transform().transform_size(source_mode.size),
|
||||
refresh: source_mode.refresh,
|
||||
}),
|
||||
Some(Transform::Normal),
|
||||
Some(source.current_scale()),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn fitted_texture(
|
||||
texture: GlesTexture,
|
||||
target: &Output,
|
||||
id: smithay::backend::renderer::element::Id,
|
||||
commit: CommitCounter,
|
||||
) -> crate::presentation::NativeTextureElement {
|
||||
let source_size = texture.size();
|
||||
let size = target
|
||||
.current_transform()
|
||||
.transform_size(target.current_mode().expect("target has a mode").size);
|
||||
crate::presentation::NativeTextureElement {
|
||||
id,
|
||||
commit,
|
||||
source: Rectangle::<f64, Buffer>::from_size(source_size.to_f64()),
|
||||
texture,
|
||||
geometry: fit((source_size.w, source_size.h), (size.w, size.h)),
|
||||
alpha: 1.0,
|
||||
program: None,
|
||||
uniforms: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn different_native_sizes_fit_without_cropping() {
|
||||
assert_eq!(
|
||||
fit((1920, 1080), (1280, 1024)),
|
||||
Rectangle::new((0, 152).into(), (1280, 720).into())
|
||||
);
|
||||
assert_eq!(
|
||||
fit((1920, 1080), (1080, 1920)),
|
||||
Rectangle::new((0, 656).into(), (1080, 608).into())
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires an EGL rendering device"]
|
||||
fn mirrored_pixels_fit_rotated_source_and_target_without_cropping() {
|
||||
use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay};
|
||||
use smithay::backend::renderer::element::solid::SolidColorRenderElement;
|
||||
use smithay::backend::renderer::element::{Id, Kind};
|
||||
use smithay::backend::renderer::{Color32F, ExportMem};
|
||||
let device = EGLDevice::enumerate().unwrap().last().expect("EGL device");
|
||||
// SAFETY: The native display comes from a live enumerated EGLDevice and its lifetime is
|
||||
// retained by the EGL objects created here.
|
||||
let display = unsafe { EGLDisplay::new(device).unwrap() };
|
||||
let context = EGLContext::new(&display).unwrap();
|
||||
// SAFETY: The fresh EGL context is exclusively owned by this renderer; this test issues
|
||||
// GL work only through its renderer methods.
|
||||
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
|
||||
let output = |name: &str| {
|
||||
Output::new(
|
||||
name.into(),
|
||||
PhysicalProperties {
|
||||
serial_number: String::new(),
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
)
|
||||
};
|
||||
let source = output("source");
|
||||
source.change_current_state(
|
||||
Some(OutputMode {
|
||||
size: (64, 32).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
Some(Transform::_90),
|
||||
Some(Scale::Fractional(2.0)),
|
||||
None,
|
||||
);
|
||||
let canvas = output("canvas");
|
||||
let colors: Vec<crate::render::AnimatedWindowRenderElement> =
|
||||
[(0, [1., 0., 0., 1.]), (32, [0., 0., 1., 1.])]
|
||||
.into_iter()
|
||||
.map(|(y, color)| {
|
||||
SolidColorRenderElement::new(
|
||||
Id::new(),
|
||||
Rectangle::new((0, y).into(), (32, 32).into()),
|
||||
CommitCounter::default(),
|
||||
Color32F::from(color),
|
||||
Kind::Unspecified,
|
||||
)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
let mut texture = None;
|
||||
compose(&mut renderer, &mut texture, &canvas, &source, &colors).unwrap();
|
||||
assert_eq!(texture.as_ref().unwrap().size(), (32, 64).into());
|
||||
let target = output("target");
|
||||
for (transform, colored_area) in [(Transform::Normal, 2048), (Transform::_90, 4608)] {
|
||||
target.change_current_state(
|
||||
Some(OutputMode {
|
||||
size: (96, 64).into(),
|
||||
refresh: 120_000,
|
||||
}),
|
||||
Some(transform),
|
||||
Some(Scale::Fractional(1.5)),
|
||||
None,
|
||||
);
|
||||
let element = fitted_texture(
|
||||
texture.as_ref().unwrap().clone(),
|
||||
&target,
|
||||
Id::new(),
|
||||
CommitCounter::default(),
|
||||
);
|
||||
let mut target_texture = None;
|
||||
let framebuffer =
|
||||
capture::render_capture(&mut renderer, &mut target_texture, &target, &[element])
|
||||
.unwrap();
|
||||
let mapping = renderer
|
||||
.copy_framebuffer(
|
||||
&framebuffer,
|
||||
Rectangle::from_size((96, 64).into()),
|
||||
Fourcc::Abgr8888,
|
||||
)
|
||||
.unwrap();
|
||||
let pixels = renderer.map_texture(&mapping).unwrap();
|
||||
let red = pixels
|
||||
.chunks_exact(4)
|
||||
.filter(|pixel| pixel[0] > 100 && pixel[0] > pixel[2])
|
||||
.count();
|
||||
let blue = pixels
|
||||
.chunks_exact(4)
|
||||
.filter(|pixel| pixel[2] > 100 && pixel[2] > pixel[0])
|
||||
.count();
|
||||
assert_eq!(red, colored_area / 2, "{transform:?}");
|
||||
assert_eq!(blue, colored_area / 2, "{transform:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Runtime power is independent of connector presence and configured enablement.
|
||||
//! Only successful hardware transitions change the applied state.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum Power {
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct OutputPower {
|
||||
requested: Power,
|
||||
applied: Option<Power>,
|
||||
}
|
||||
|
||||
impl Default for OutputPower {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
requested: Power::On,
|
||||
applied: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OutputPower {
|
||||
pub fn request(&mut self, power: Power) {
|
||||
self.requested = power;
|
||||
}
|
||||
|
||||
pub fn can_render(&self) -> bool {
|
||||
self.requested == Power::On
|
||||
}
|
||||
|
||||
pub fn needs_power_off(&self) -> bool {
|
||||
self.requested == Power::Off && self.applied != Some(Power::Off)
|
||||
}
|
||||
|
||||
pub fn needs_wake(&self) -> bool {
|
||||
self.requested == Power::Off || self.applied != Some(Power::On)
|
||||
}
|
||||
|
||||
pub fn powered_off(&mut self) {
|
||||
self.applied = Some(Power::Off);
|
||||
}
|
||||
|
||||
pub fn presented(&mut self) {
|
||||
self.applied = Some(Power::On);
|
||||
}
|
||||
|
||||
pub fn suspended(&mut self) {
|
||||
self.applied = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn failed_power_off_keeps_the_applied_state_and_remains_retryable() {
|
||||
let mut power = OutputPower::default();
|
||||
power.presented();
|
||||
power.request(Power::Off);
|
||||
assert!(!power.can_render());
|
||||
assert_eq!(power.applied, Some(Power::On));
|
||||
assert!(power.needs_power_off());
|
||||
power.powered_off();
|
||||
assert!(!power.needs_power_off());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waking_does_not_claim_success_before_presentation() {
|
||||
let mut power = OutputPower::default();
|
||||
power.request(Power::Off);
|
||||
power.powered_off();
|
||||
power.request(Power::On);
|
||||
assert!(power.can_render());
|
||||
assert_eq!(power.applied, Some(Power::Off));
|
||||
power.presented();
|
||||
assert!(!power.needs_wake());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_frame_retirement_and_suspend_do_not_lose_requested_power() {
|
||||
let mut power = OutputPower::default();
|
||||
power.request(Power::Off);
|
||||
power.presented();
|
||||
assert!(power.needs_power_off());
|
||||
power.suspended();
|
||||
assert_eq!(power.applied, None);
|
||||
assert!(power.needs_power_off());
|
||||
assert!(!power.can_render());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Uncertain KMS completion requires device reconciliation, not buffer reuse.
|
||||
use super::{
|
||||
DirectOutput, DrmNode, Duration, Ferese, Instant, TimeoutAction, Timer, crtc, reconcile_outputs,
|
||||
};
|
||||
|
||||
const COMPLETION_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
#[cfg(feature = "drm-fault-injection")]
|
||||
pub(super) fn inject(mode: &str) -> bool {
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static MODE: LazyLock<Option<String>> =
|
||||
LazyLock::new(|| std::env::var("FERESE_TEST_DRM_FAILURE").ok());
|
||||
static USED: AtomicBool = AtomicBool::new(false);
|
||||
MODE.as_deref() == Some(mode) && !USED.swap(true, Ordering::AcqRel)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Completion {
|
||||
pending: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Completion {
|
||||
fn queued(&mut self, now: Instant) {
|
||||
self.pending = Some(now);
|
||||
}
|
||||
|
||||
pub fn retired(&mut self) {
|
||||
self.pending = None;
|
||||
}
|
||||
|
||||
fn remaining(&self, now: Instant) -> Option<Duration> {
|
||||
self.pending
|
||||
.map(|queued| COMPLETION_TIMEOUT.saturating_sub(now.saturating_duration_since(queued)))
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the submission result and the recovery decision together. An error must
|
||||
// not be mistaken for a completed frame with no presentation feedback.
|
||||
pub(super) fn retirement<T, E>(result: Result<Option<T>, E>) -> (Option<T>, bool, Option<E>) {
|
||||
match result {
|
||||
Ok(feedback) => {
|
||||
let retired = feedback.is_some();
|
||||
(feedback, retired, None)
|
||||
}
|
||||
Err(error) => (None, false, Some(error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn arm_completion(
|
||||
handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>,
|
||||
node: DrmNode,
|
||||
crtc: crtc::Handle,
|
||||
output: &mut DirectOutput,
|
||||
) -> bool {
|
||||
output.completion.queued(Instant::now());
|
||||
if output.completion_timer.is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let identity = output.output.clone();
|
||||
match handle.insert_source(
|
||||
Timer::from_duration(COMPLETION_TIMEOUT),
|
||||
move |_, _, state| {
|
||||
let Some(output) = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.and_then(|backend| backend.devices.get_mut(&node))
|
||||
.and_then(|device| device.outputs.get_mut(&crtc))
|
||||
.filter(|output| output.output == identity)
|
||||
else {
|
||||
return TimeoutAction::Drop;
|
||||
};
|
||||
|
||||
if let Some(remaining) = output.completion.remaining(Instant::now()) {
|
||||
if !remaining.is_zero() {
|
||||
return TimeoutAction::ToDuration(remaining);
|
||||
}
|
||||
|
||||
output.completion_timer = None;
|
||||
tracing::error!(
|
||||
?node,
|
||||
?crtc,
|
||||
"DRM frame completion timed out; reconciling device"
|
||||
);
|
||||
reconcile_device(state, node);
|
||||
} else {
|
||||
output.completion_timer = None;
|
||||
}
|
||||
|
||||
TimeoutAction::Drop
|
||||
},
|
||||
) {
|
||||
Ok(token) => {
|
||||
output.completion_timer = Some(token);
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(%error, ?node, ?crtc, "could not arm DRM completion watchdog");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reconcile_device(state: &mut Ferese, node: DrmNode) {
|
||||
let Some(backend) = state.direct_backend.as_mut() else {
|
||||
return;
|
||||
};
|
||||
backend.topology.dirty = true;
|
||||
if !backend.topology.invalidated.insert(node) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Defer teardown until the DRM notifier callback has returned. Inactive
|
||||
// sessions retain the invalidation for fresh enumeration on activation.
|
||||
let active = backend.active;
|
||||
if active {
|
||||
match state
|
||||
.loop_handle
|
||||
.insert_source(Timer::immediate(), |_, _, state| {
|
||||
reconcile_outputs(state, false);
|
||||
TimeoutAction::Drop
|
||||
}) {
|
||||
Ok(_) => (),
|
||||
Err(error) => tracing::error!(%error, "could not schedule DRM device recovery"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn failed_retirement_does_not_release_pending_buffer_ownership() {
|
||||
let now = Instant::now();
|
||||
let mut completion = Completion::default();
|
||||
completion.queued(now);
|
||||
let (feedback, retired, error) =
|
||||
retirement::<(), _>(Err("injected KMS retirement failure"));
|
||||
assert_eq!(feedback, None);
|
||||
assert!(!retired);
|
||||
assert!(
|
||||
error.is_some(),
|
||||
"the production handler must reconcile the device"
|
||||
);
|
||||
assert_eq!(completion.remaining(now), Some(COMPLETION_TIMEOUT));
|
||||
assert_eq!(retirement::<(), &str>(Ok(None)), (None, false, None));
|
||||
assert_eq!(retirement::<(), &str>(Ok(Some(()))), (Some(()), true, None));
|
||||
|
||||
// Only successful retirement or device teardown can end uncertainty.
|
||||
completion = Completion::default();
|
||||
assert_eq!(completion.remaining(now), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_completion_remains_pending_until_hardware_reconciliation() {
|
||||
let now = Instant::now();
|
||||
let mut completion = Completion::default();
|
||||
completion.queued(now);
|
||||
assert_eq!(
|
||||
completion.remaining(now + COMPLETION_TIMEOUT - Duration::from_millis(1)),
|
||||
Some(Duration::from_millis(1))
|
||||
);
|
||||
// A timeout observes uncertainty; it never frees the current buffer.
|
||||
assert_eq!(
|
||||
completion.remaining(now + COMPLETION_TIMEOUT),
|
||||
Some(Duration::ZERO)
|
||||
);
|
||||
assert_eq!(
|
||||
completion.remaining(now + COMPLETION_TIMEOUT + Duration::from_secs(1)),
|
||||
Some(Duration::ZERO)
|
||||
);
|
||||
completion.retired();
|
||||
assert_eq!(completion.remaining(now), None);
|
||||
completion.queued(now + Duration::from_secs(3));
|
||||
assert_eq!(
|
||||
completion.remaining(now + Duration::from_secs(4)),
|
||||
Some(Duration::from_secs(1)),
|
||||
"existing timer must respect the newer frame's deadline"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
//! Redraw/fallback measurements and the orphaned-animation watchdog policy.
|
||||
use std::collections::HashMap;
|
||||
use std::panic::Location;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct AnimationFallback {
|
||||
orphaned_since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl AnimationFallback {
|
||||
pub fn waiting_for_frame(&self) -> bool {
|
||||
self.orphaned_since.is_some()
|
||||
}
|
||||
|
||||
pub fn deadline(
|
||||
&mut self,
|
||||
now: Instant,
|
||||
interval: Duration,
|
||||
active: bool,
|
||||
chained: bool,
|
||||
) -> Option<Instant> {
|
||||
if !active || chained {
|
||||
self.orphaned_since = None;
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(*self.orphaned_since.get_or_insert(now) + interval.saturating_mul(2))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RedrawCount {
|
||||
calls: u64,
|
||||
requested_outputs: u64,
|
||||
connected_outputs: u64,
|
||||
}
|
||||
|
||||
pub(super) struct SchedulingMetrics {
|
||||
enabled: bool,
|
||||
started: Instant,
|
||||
redraws: HashMap<(&'static str, u32, bool), RedrawCount>,
|
||||
pub fallback_arms: u64,
|
||||
pub fallback_cancels: u64,
|
||||
pub fallback_wakes: u64,
|
||||
pub fallback_recoveries: u64,
|
||||
pub chained_animation_observations: u64,
|
||||
}
|
||||
|
||||
impl SchedulingMetrics {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
enabled: std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some(),
|
||||
started: Instant::now(),
|
||||
redraws: HashMap::new(),
|
||||
fallback_arms: 0,
|
||||
fallback_cancels: 0,
|
||||
fallback_wakes: 0,
|
||||
fallback_recoveries: 0,
|
||||
chained_animation_observations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redraw(
|
||||
&mut self,
|
||||
source: &'static Location<'static>,
|
||||
global: bool,
|
||||
requested: usize,
|
||||
connected: usize,
|
||||
) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let count = self
|
||||
.redraws
|
||||
.entry((source.file(), source.line(), global))
|
||||
.or_default();
|
||||
count.calls += 1;
|
||||
count.requested_outputs += requested as u64;
|
||||
count.connected_outputs += connected as u64;
|
||||
if self.started.elapsed() >= Duration::from_secs(5) {
|
||||
self.dump();
|
||||
self.started = Instant::now();
|
||||
self.redraws.clear();
|
||||
self.fallback_arms = 0;
|
||||
self.fallback_cancels = 0;
|
||||
self.fallback_wakes = 0;
|
||||
self.fallback_recoveries = 0;
|
||||
self.chained_animation_observations = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dump(&self) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
for ((file, line, global), count) in &self.redraws {
|
||||
tracing::info!(target: "ferese::render", file, line, global,
|
||||
calls = count.calls, requested_outputs = count.requested_outputs,
|
||||
connected_outputs = count.connected_outputs, "redraw requests");
|
||||
}
|
||||
|
||||
tracing::info!(target: "ferese::render", fallback_arms = self.fallback_arms,
|
||||
fallback_cancels = self.fallback_cancels, fallback_wakes = self.fallback_wakes,
|
||||
fallback_recoveries = self.fallback_recoveries,
|
||||
chained_animation_observations = self.chained_animation_observations,
|
||||
"animation fallback scheduling");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cursor_on_output(state: &crate::Ferese, output: &smithay::output::Output) -> bool {
|
||||
use smithay::input::pointer::{CursorImageStatus, CursorImageSurfaceData};
|
||||
use smithay::utils::{Logical, Rectangle};
|
||||
use smithay::wayland::compositor::with_states;
|
||||
if state.input_capture.active() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(pointer) = state.seat.get_pointer() else {
|
||||
return false;
|
||||
};
|
||||
let Some(bounds) = state.space.output_geometry(output) else {
|
||||
return false;
|
||||
};
|
||||
let location = pointer.current_location();
|
||||
let rect = match &state.cursor_status {
|
||||
CursorImageStatus::Surface(surface) => {
|
||||
let hotspot = with_states(surface, |states| {
|
||||
states
|
||||
.data_map
|
||||
.get::<CursorImageSurfaceData>()
|
||||
.map(|attributes| {
|
||||
attributes
|
||||
.lock()
|
||||
.expect(
|
||||
"cursor surface attributes must remain consistent across callbacks",
|
||||
)
|
||||
.hotspot
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
smithay::desktop::utils::bbox_from_surface_tree(
|
||||
surface,
|
||||
(location - hotspot.to_f64()).to_i32_round(),
|
||||
)
|
||||
.to_f64()
|
||||
}
|
||||
|
||||
CursorImageStatus::Named(_) => {
|
||||
let Some(cursor) = state.named_cursor_frame() else {
|
||||
return false;
|
||||
};
|
||||
cursor.logical_rect(location)
|
||||
}
|
||||
|
||||
CursorImageStatus::Hidden => return false,
|
||||
};
|
||||
// One logical pixel covers physical rounding at fractional scales.
|
||||
let bounds = Rectangle::<f64, Logical>::new(
|
||||
(f64::from(bounds.loc.x) - 1.0, f64::from(bounds.loc.y) - 1.0).into(),
|
||||
(
|
||||
f64::from(bounds.size.w) + 2.0,
|
||||
f64::from(bounds.size.h) + 2.0,
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
rect.overlaps(bounds)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mixed_refresh_only_orphaned_animations_need_a_watchdog() {
|
||||
let now = Instant::now();
|
||||
let fast = Duration::from_nanos(1_000_000_000 / 240);
|
||||
let slow = Duration::from_nanos(1_000_000_000 / 60);
|
||||
let mut idle_fast = AnimationFallback::default();
|
||||
let mut moving_slow = AnimationFallback::default();
|
||||
assert_eq!(idle_fast.deadline(now, fast, false, false), None);
|
||||
assert_eq!(
|
||||
moving_slow.deadline(now, slow, true, false),
|
||||
Some(now + slow * 2)
|
||||
);
|
||||
assert_eq!(
|
||||
moving_slow.deadline(now + slow, slow, true, false),
|
||||
Some(now + slow * 2)
|
||||
);
|
||||
assert_eq!(moving_slow.deadline(now + slow, slow, true, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_and_reconnect_do_not_keep_an_old_deadline() {
|
||||
let now = Instant::now();
|
||||
let interval = Duration::from_millis(16);
|
||||
let mut output = AnimationFallback::default();
|
||||
output.deadline(now, interval, true, false);
|
||||
assert_eq!(
|
||||
output.deadline(now + interval, interval, false, false),
|
||||
None
|
||||
);
|
||||
let reconnected = now + Duration::from_secs(1);
|
||||
assert_eq!(
|
||||
output.deadline(reconnected, interval, true, false),
|
||||
Some(reconnected + interval * 2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_legacy_fast_monitor_watchdog_wakes_with_an_intact_slow_chain() {
|
||||
// The old watchdog wakes at last_animation_tick + 2*fast_interval,
|
||||
// then advances the shared clock, even with a slow render in flight.
|
||||
let mut last_tick_us = 0;
|
||||
let mut old_wakes = 0;
|
||||
let mut policy = AnimationFallback::default();
|
||||
let start = Instant::now();
|
||||
for now_us in 0..1_000_000 {
|
||||
if now_us % 16_667 == 0 {
|
||||
last_tick_us = now_us;
|
||||
}
|
||||
|
||||
if now_us >= last_tick_us + 8_334 {
|
||||
old_wakes += 1;
|
||||
last_tick_us = now_us;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
policy.deadline(start, Duration::from_micros(16_667), true, true),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(old_wakes, 60);
|
||||
eprintln!("watchdog replay: legacy={old_wakes} unnecessary wakes/s, intact-chain policy=0");
|
||||
}
|
||||
#[test]
|
||||
fn scheduled_and_in_flight_frames_suppress_fallback_until_the_chain_is_lost() {
|
||||
use crate::frame_scheduler::FrameScheduler;
|
||||
let start = Instant::now();
|
||||
let interval = Duration::from_millis(16);
|
||||
let mut scheduler = FrameScheduler::new(interval);
|
||||
let mut fallback = AnimationFallback::default();
|
||||
scheduler.request(Duration::ZERO);
|
||||
let scheduled = scheduler.plan(Duration::ZERO).unwrap();
|
||||
assert_eq!(fallback.deadline(start, interval, true, true), None);
|
||||
let plan = scheduler.begin(scheduled.render_at).unwrap();
|
||||
assert_eq!(plan, scheduled);
|
||||
scheduler.rendered(
|
||||
plan,
|
||||
Duration::from_millis(2),
|
||||
Duration::from_millis(2),
|
||||
true,
|
||||
);
|
||||
assert_eq!(scheduler.plan(interval), None);
|
||||
assert_eq!(
|
||||
fallback.deadline(start + interval, interval, true, true),
|
||||
None
|
||||
);
|
||||
scheduler.presented(interval);
|
||||
assert_eq!(
|
||||
fallback.deadline(start + interval, interval, true, false),
|
||||
Some(start + interval * 3)
|
||||
);
|
||||
scheduler.request(interval);
|
||||
assert!(scheduler.plan(interval).is_some());
|
||||
assert_eq!(
|
||||
fallback.deadline(start + interval, interval, true, true),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settled_animation_still_recovers_its_requested_final_frame_after_scheduling_failure() {
|
||||
use crate::frame_scheduler::FrameScheduler;
|
||||
let start = Instant::now();
|
||||
let interval = Duration::from_millis(16);
|
||||
let mut scheduler = FrameScheduler::new(interval);
|
||||
let mut fallback = AnimationFallback::default();
|
||||
scheduler.request(Duration::ZERO);
|
||||
scheduler.plan(Duration::ZERO).unwrap();
|
||||
// Timer insertion failed: a scheduler plan without an event-loop
|
||||
// source is not a live chain. Another output can settle the spring.
|
||||
assert!(scheduler.has_pending_request());
|
||||
let deadline = fallback.deadline(start, interval, true, false).unwrap();
|
||||
let animation_active = false;
|
||||
let needs_final_frame =
|
||||
animation_active || (fallback.waiting_for_frame() && scheduler.has_pending_request());
|
||||
assert_eq!(
|
||||
fallback.deadline(deadline, interval, needs_final_frame, false),
|
||||
Some(deadline)
|
||||
);
|
||||
// A successful retry installs the normal timer and cancels fallback.
|
||||
assert_eq!(
|
||||
fallback.deadline(deadline, interval, needs_final_frame, true),
|
||||
None
|
||||
);
|
||||
scheduler.begin(deadline.duration_since(start)).unwrap();
|
||||
assert!(!scheduler.has_pending_request());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires private sockets; subprocess supplies its own runtime directory"]
|
||||
fn cursor_footprints_limit_output_local_redraws_and_clear_both_sides_of_a_crossing() {
|
||||
if std::env::var_os("FERESE_CURSOR_REDRAW_TEST_CHILD").is_none() {
|
||||
let runtime = tempfile::tempdir().unwrap();
|
||||
let result = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "backends::direct::redraw::tests::cursor_footprints_limit_output_local_redraws_and_clear_both_sides_of_a_crossing", "--include-ignored", "--nocapture"])
|
||||
.env("XDG_RUNTIME_DIR", runtime.path())
|
||||
.env("FERESE_CURSOR_REDRAW_TEST_CHILD", "1")
|
||||
.output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
use smithay::input::pointer::{CursorImageStatus, MotionEvent};
|
||||
use smithay::output::{Mode, Output, PhysicalProperties, Subpixel};
|
||||
use smithay::utils::SERIAL_COUNTER;
|
||||
let mut event_loop = smithay::reexports::calloop::EventLoop::try_new().unwrap();
|
||||
let display = smithay::reexports::wayland_server::Display::new().unwrap();
|
||||
let config = crate::config::Config::default().runtime_config().unwrap();
|
||||
let mut state = crate::Ferese::new(&mut event_loop, display, config).unwrap();
|
||||
let mut outputs = Vec::new();
|
||||
for (index, refresh) in [60_000, 240_000].into_iter().enumerate() {
|
||||
let output = Output::new(
|
||||
format!("cursor-test-{index}"),
|
||||
PhysicalProperties {
|
||||
serial_number: String::new(),
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
output.change_current_state(
|
||||
Some(Mode {
|
||||
size: if index == 0 {
|
||||
(3840, 2160).into()
|
||||
} else {
|
||||
(1920, 1080).into()
|
||||
},
|
||||
refresh,
|
||||
}),
|
||||
None,
|
||||
Some(smithay::output::Scale::Fractional(if index == 0 {
|
||||
2.0
|
||||
} else {
|
||||
1.0
|
||||
})),
|
||||
Some(((index as i32) * 1920, 0).into()),
|
||||
);
|
||||
state.space.map_output(&output, ((index as i32) * 1920, 0));
|
||||
state.register_output(&output, output.name());
|
||||
outputs.push(output);
|
||||
}
|
||||
|
||||
let pointer = state.seat.get_pointer().unwrap();
|
||||
let mut old = [false; 2];
|
||||
let mut local_requests = 0;
|
||||
for x in [500.0, 501.0, 502.0] {
|
||||
pointer.motion(
|
||||
&mut state,
|
||||
None,
|
||||
&MotionEvent {
|
||||
location: (x, 500.0).into(),
|
||||
serial: SERIAL_COUNTER.next_serial(),
|
||||
time: smithay::backend::input::InputTime::from_millis(0),
|
||||
},
|
||||
);
|
||||
let current = [
|
||||
cursor_on_output(&state, &outputs[0]),
|
||||
cursor_on_output(&state, &outputs[1]),
|
||||
];
|
||||
assert_eq!(current, [true, false]);
|
||||
local_requests += old
|
||||
.into_iter()
|
||||
.zip(current)
|
||||
.filter(|(before, after)| *before || *after)
|
||||
.count();
|
||||
old = current;
|
||||
}
|
||||
|
||||
assert_eq!(local_requests, 3);
|
||||
eprintln!("cursor replay: legacy=6 output requests, footprint policy={local_requests}");
|
||||
pointer.motion(
|
||||
&mut state,
|
||||
None,
|
||||
&MotionEvent {
|
||||
location: (1919.0, 500.0).into(),
|
||||
serial: SERIAL_COUNTER.next_serial(),
|
||||
time: smithay::backend::input::InputTime::from_millis(0),
|
||||
},
|
||||
);
|
||||
assert!(cursor_on_output(&state, &outputs[0]));
|
||||
assert!(
|
||||
cursor_on_output(&state, &outputs[1]),
|
||||
"cursor image straddles the seam"
|
||||
);
|
||||
if let Some(cursor) = state.named_cursor_frame() {
|
||||
let x = 1920.0 - f64::from(cursor.size.w) / f64::from(cursor.buffer_scale) * 0.75
|
||||
+ f64::from(cursor.hotspot.x) / f64::from(cursor.buffer_scale);
|
||||
pointer.motion(
|
||||
&mut state,
|
||||
None,
|
||||
&MotionEvent {
|
||||
location: (x, 500.0).into(),
|
||||
serial: SERIAL_COUNTER.next_serial(),
|
||||
time: smithay::backend::input::InputTime::from_millis(0),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
cursor_on_output(&state, &outputs[1]),
|
||||
"fractional-scale footprint matches rendered cursor size"
|
||||
);
|
||||
}
|
||||
|
||||
old = [true, true];
|
||||
state.cursor_status = CursorImageStatus::Hidden;
|
||||
let current = [
|
||||
cursor_on_output(&state, &outputs[0]),
|
||||
cursor_on_output(&state, &outputs[1]),
|
||||
];
|
||||
assert_eq!(current, [false, false]);
|
||||
assert_eq!(
|
||||
old.into_iter()
|
||||
.zip(current)
|
||||
.filter(|(before, after)| *before || *after)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
// Grabs and held gestures defer window/output work independently of
|
||||
// the cursor, which may be on another display or hidden entirely.
|
||||
state.defer_output_redraw(outputs[0].clone());
|
||||
state.defer_output_redraw(outputs[1].clone());
|
||||
state.defer_output_redraw(outputs[1].clone());
|
||||
assert_eq!(state.output_redraw_pending, outputs);
|
||||
state.unregister_output(&outputs[1]);
|
||||
assert_eq!(state.output_redraw_pending, vec![outputs[0].clone()]);
|
||||
assert!(!cursor_on_output(&state, &outputs[1]));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
DirectOutput, DrmNode, Duration, Ferese, Instant, Monotonic, Output, OutputMode, TimeoutAction,
|
||||
Timer, arm_animation_timer, crtc, render_output, sleep_locked_outputs,
|
||||
};
|
||||
use crate::frame_scheduler::FramePlan;
|
||||
|
||||
pub(super) fn monotonic_now() -> Duration {
|
||||
@@ -9,9 +12,14 @@ pub(super) fn cancel_output_timers(
|
||||
handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>,
|
||||
output: &mut DirectOutput,
|
||||
) {
|
||||
for token in [output.render_timer.take(), output.callback_timer.take()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
output.completion.retired();
|
||||
for token in [
|
||||
output.render_timer.take(),
|
||||
output.callback_timer.take(),
|
||||
output.completion_timer.take(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
handle.remove(token);
|
||||
}
|
||||
@@ -31,7 +39,7 @@ pub(super) fn request_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handl
|
||||
let Some(output) = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.filter(|backend| backend.active)
|
||||
.filter(|backend| backend.can_render())
|
||||
.and_then(|backend| backend.devices.get_mut(&node))
|
||||
.filter(|device| device.drm.is_active())
|
||||
.and_then(|device| device.outputs.get_mut(&crtc))
|
||||
@@ -39,10 +47,13 @@ pub(super) fn request_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handl
|
||||
return;
|
||||
};
|
||||
|
||||
if output.power_off {
|
||||
if !output.power.can_render() {
|
||||
return;
|
||||
}
|
||||
|
||||
output
|
||||
.render_metrics
|
||||
.record_request(output.render_timer.is_some(), output.frame_pending);
|
||||
output.scheduler.request(now);
|
||||
arm_frame(state, node, crtc);
|
||||
}
|
||||
@@ -52,7 +63,7 @@ pub(super) fn arm_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
|
||||
let Some(output) = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.filter(|backend| backend.active)
|
||||
.filter(|backend| backend.can_render())
|
||||
.and_then(|backend| backend.devices.get_mut(&node))
|
||||
.filter(|device| device.drm.is_active())
|
||||
.and_then(|device| device.outputs.get_mut(&crtc))
|
||||
@@ -60,7 +71,11 @@ pub(super) fn arm_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
|
||||
return;
|
||||
};
|
||||
|
||||
if output.render_timer.is_some() || output.frame_pending || output.power_off || state.session_lock.sleeping {
|
||||
if output.render_timer.is_some()
|
||||
|| output.frame_pending
|
||||
|| !output.power.can_render()
|
||||
|| state.session_lock.sleeping
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -97,7 +112,7 @@ fn dispatch_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, identit
|
||||
let Some(output) = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.filter(|backend| backend.active)
|
||||
.filter(|backend| backend.can_render())
|
||||
.and_then(|backend| backend.devices.get_mut(&node))
|
||||
.filter(|device| device.drm.is_active())
|
||||
.and_then(|device| device.outputs.get_mut(&crtc))
|
||||
@@ -107,29 +122,34 @@ fn dispatch_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, identit
|
||||
};
|
||||
|
||||
output.render_timer = None;
|
||||
if output.frame_pending || output.power_off || state.session_lock.sleeping {
|
||||
if output.frame_pending || !output.power.can_render() || state.session_lock.sleeping {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(plan) = output.scheduler.begin() else {
|
||||
let Some(plan) = output.scheduler.begin(monotonic_now()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let animating = state.advance_animations(Instant::now());
|
||||
if let Some(backend) = state.direct_backend.as_mut() {
|
||||
backend.animation_active = animating;
|
||||
}
|
||||
state.advance_animations(Instant::now());
|
||||
|
||||
render_output(state, node, crtc, plan);
|
||||
if state.output_has_animations(identity) {
|
||||
let output_animating = render_output(state, node, crtc, plan);
|
||||
|
||||
if output_animating {
|
||||
request_frame(state, node, crtc);
|
||||
}
|
||||
|
||||
arm_animation_timer(state);
|
||||
}
|
||||
|
||||
pub(super) fn record_frame_schedule(output: &mut DirectOutput, plan: FramePlan, cost: Duration, submitted: bool) {
|
||||
output.scheduler.rendered(plan, monotonic_now(), cost, submitted);
|
||||
pub(super) fn record_frame_schedule(
|
||||
output: &mut DirectOutput,
|
||||
plan: FramePlan,
|
||||
cost: Duration,
|
||||
submitted: bool,
|
||||
) {
|
||||
output
|
||||
.scheduler
|
||||
.rendered(plan, monotonic_now(), cost, submitted);
|
||||
if std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some() {
|
||||
let started = monotonic_now().saturating_sub(cost);
|
||||
tracing::debug!(target: "ferese::render", output = %output.output.name(),
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use smithay::backend::session::Session;
|
||||
use smithay::reexports::rustix::fs::OFlags;
|
||||
use smithay::utils::DeviceFd;
|
||||
use std::io;
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::path::Path;
|
||||
|
||||
pub(super) struct SessionDevice<S: Session> {
|
||||
session: S,
|
||||
fd: Option<OwnedFd>,
|
||||
}
|
||||
|
||||
impl<S: Session> SessionDevice<S> {
|
||||
pub fn open(mut session: S, path: &Path) -> Result<Self, S::Error> {
|
||||
let fd = session.open(
|
||||
path,
|
||||
OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOCTTY | OFlags::NONBLOCK,
|
||||
)?;
|
||||
Ok(Self {
|
||||
session,
|
||||
fd: Some(fd),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn device_fd(&self) -> io::Result<DeviceFd> {
|
||||
self.fd
|
||||
.as_ref()
|
||||
.expect("the session device retains its descriptor until Drop")
|
||||
.try_clone()
|
||||
.map(DeviceFd::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Session> Drop for SessionDevice<S> {
|
||||
fn drop(&mut self) {
|
||||
if let Err(error) = self.session.close(
|
||||
self.fd
|
||||
.take()
|
||||
.expect("the session device retains its descriptor until Drop"),
|
||||
) {
|
||||
tracing::warn!(
|
||||
?error,
|
||||
"could not release DRM device through the session manager"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
use std::fs::File;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct TestSession(Rc<Cell<Option<i32>>>);
|
||||
|
||||
impl Session for TestSession {
|
||||
type Error = ();
|
||||
fn open(&mut self, _: &Path, _: OFlags) -> Result<OwnedFd, ()> {
|
||||
if self.0.get().is_some() {
|
||||
return Err(());
|
||||
}
|
||||
let fd: OwnedFd = File::open("/dev/null").unwrap().into();
|
||||
self.0.set(Some(fd.as_raw_fd()));
|
||||
Ok(fd)
|
||||
}
|
||||
fn close(&mut self, fd: OwnedFd) -> Result<(), ()> {
|
||||
assert_eq!(self.0.replace(None), Some(fd.as_raw_fd()));
|
||||
Ok(())
|
||||
}
|
||||
fn change_vt(&mut self, _: i32) -> Result<(), ()> {
|
||||
Ok(())
|
||||
}
|
||||
fn is_active(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn seat(&self) -> String {
|
||||
"seat0".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teardown_releases_original_session_lease_before_reopening() {
|
||||
let session = TestSession::default();
|
||||
for _ in 0..3 {
|
||||
let lease = SessionDevice::open(session.clone(), Path::new("/dev/null")).unwrap();
|
||||
let rendering = lease.device_fd().unwrap();
|
||||
assert_ne!(Some(rendering.as_raw_fd()), session.0.get());
|
||||
drop(rendering);
|
||||
assert!(session.0.get().is_some());
|
||||
drop(lease);
|
||||
assert!(session.0.get().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_renderer_initialization_releases_lease() {
|
||||
fn initialize(session: TestSession) -> Result<(), ()> {
|
||||
let lease = SessionDevice::open(session, Path::new("/dev/null"))?;
|
||||
let _rendering = lease.device_fd().unwrap();
|
||||
Err(())
|
||||
}
|
||||
let session = TestSession::default();
|
||||
assert!(initialize(session.clone()).is_err());
|
||||
assert!(session.0.get().is_none());
|
||||
assert!(SessionDevice::open(session, Path::new("/dev/null")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,889 @@
|
||||
//! Desired device identities survive temporary loss of their DRM resources.
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{
|
||||
DrmNode, Duration, Ferese, HashMap, HashSet, Instant, OutputMode, Path, RegistrationToken,
|
||||
Scale, Session, TimeoutAction, Timer, all_gpus, cancel_output_timers, drm_device_key, lid,
|
||||
monotonic_now, open_device, output_transform, refresh_connected_info, refresh_monitor_policy,
|
||||
remember_applied_configuration, remove_device, rescan_device, sync_battery_timer, transaction,
|
||||
validate_desired_outputs,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Availability<Node = DrmNode> {
|
||||
Ready(Node),
|
||||
Degraded(Node),
|
||||
Missing,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub(super) struct DeviceState<Node = DrmNode> {
|
||||
pub availability: Availability<Node>,
|
||||
identity: Option<PathBuf>,
|
||||
failures: u32,
|
||||
retry_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl<Node> Default for DeviceState<Node> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
availability: Availability::Missing,
|
||||
identity: None,
|
||||
failures: 0,
|
||||
retry_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn device_identity(path: &Path) -> Option<PathBuf> {
|
||||
// The parent hardware identity survives cardN renumbering after unplug.
|
||||
std::fs::canonicalize(
|
||||
Path::new("/sys/class/drm")
|
||||
.join(path.file_name()?)
|
||||
.join("device"),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
impl<Node> DeviceState<Node> {
|
||||
fn current_path<'a>(
|
||||
&self,
|
||||
original: &Path,
|
||||
observed: &'a [(PathBuf, Option<PathBuf>)],
|
||||
) -> Option<&'a Path> {
|
||||
observed
|
||||
.iter()
|
||||
.find(|(path, identity)| match &self.identity {
|
||||
Some(expected) => identity.as_ref() == Some(expected),
|
||||
None => path == original,
|
||||
})
|
||||
.map(|(path, _)| path.as_path())
|
||||
}
|
||||
|
||||
pub fn failed(&mut self, now: Instant) {
|
||||
self.availability = Availability::Unavailable;
|
||||
self.failures = self.failures.saturating_add(1);
|
||||
self.retry_at =
|
||||
Some(now + Duration::from_secs(1u64 << self.failures.saturating_sub(1).min(5)));
|
||||
}
|
||||
|
||||
pub fn ready(&mut self, node: Node) {
|
||||
self.availability = Availability::Ready(node);
|
||||
self.failures = 0;
|
||||
self.retry_at = None;
|
||||
}
|
||||
|
||||
pub fn missing(&mut self) {
|
||||
self.availability = Availability::Missing;
|
||||
self.failures = 0;
|
||||
self.retry_at = None;
|
||||
}
|
||||
|
||||
fn degraded(&mut self, node: Node, now: Instant) {
|
||||
self.failed(now);
|
||||
self.availability = Availability::Degraded(node);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Topology<Node = DrmNode> {
|
||||
// Hardware paths are stable keys; use the card path only when sysfs identity
|
||||
// is unavailable. A reused card number must not replace another GPU's slot.
|
||||
pub devices: HashMap<PathBuf, DeviceState<Node>>,
|
||||
pub invalidated: HashSet<Node>,
|
||||
pub reconciling: bool,
|
||||
pub dirty: bool,
|
||||
pub layout_dirty: bool,
|
||||
pub remember_applied: bool,
|
||||
pub retry_timer: Option<RegistrationToken>,
|
||||
pub settle_timer: Option<RegistrationToken>,
|
||||
pub coalescer: crate::output_policy::Debouncer,
|
||||
}
|
||||
|
||||
impl<Node> Default for Topology<Node> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
devices: HashMap::new(),
|
||||
invalidated: HashSet::new(),
|
||||
reconciling: false,
|
||||
dirty: false,
|
||||
layout_dirty: false,
|
||||
remember_applied: false,
|
||||
retry_timer: None,
|
||||
settle_timer: None,
|
||||
coalescer: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Node identity is opaque to reconciliation; tests use integers without opening
|
||||
// DRM devices or depending on which nodes happen to exist in the host's sysfs.
|
||||
impl<Node: Copy + Eq + std::hash::Hash> Topology<Node> {
|
||||
pub fn observe_devices(&mut self, observed: &[(PathBuf, Option<PathBuf>)]) {
|
||||
for (path, identity) in observed {
|
||||
let key = identity.as_ref().unwrap_or(path);
|
||||
let fallback = if identity.is_some() {
|
||||
self.devices.remove(path)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if self.devices.contains_key(key) {
|
||||
continue;
|
||||
}
|
||||
// Preserve a slot first discovered before its sysfs identity was
|
||||
// available, including its live DRM resources and retry state.
|
||||
let mut slot = fallback.unwrap_or_default();
|
||||
slot.identity = identity.clone();
|
||||
self.devices.insert(key.clone(), slot);
|
||||
}
|
||||
}
|
||||
|
||||
fn retire_changed_devices(
|
||||
&mut self,
|
||||
observed: &[(PathBuf, Option<PathBuf>)],
|
||||
node_for_path: impl Fn(&Path) -> Option<Node>,
|
||||
) -> HashSet<Node> {
|
||||
let mut retired = HashSet::new();
|
||||
for (key, slot) in &mut self.devices {
|
||||
if let Availability::Ready(previous) | Availability::Degraded(previous) =
|
||||
slot.availability
|
||||
&& slot.current_path(key, observed).and_then(&node_for_path) != Some(previous)
|
||||
{
|
||||
retired.insert(previous);
|
||||
slot.missing();
|
||||
}
|
||||
}
|
||||
retired
|
||||
}
|
||||
|
||||
pub fn request(&mut self, active: bool) -> bool {
|
||||
self.dirty = true;
|
||||
active && !self.reconciling
|
||||
}
|
||||
|
||||
pub fn retry_deadline(&self, active: bool) -> Option<Instant> {
|
||||
active
|
||||
.then(|| {
|
||||
self.devices
|
||||
.values()
|
||||
.filter_map(|device| device.retry_at)
|
||||
.min()
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
fn slot_mut<'a>(state: &'a mut Ferese, path: &Path) -> &'a mut DeviceState {
|
||||
state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.topology
|
||||
.devices
|
||||
.get_mut(path)
|
||||
.expect("the discovery path must have a topology slot")
|
||||
}
|
||||
|
||||
/// The only entry point for applying the current output configuration to DRM.
|
||||
pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
||||
let Some(backend) = state.direct_backend.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if !backend
|
||||
.topology
|
||||
.request(backend.active && !backend.lid.pending())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(token) = backend.topology.settle_timer.take() {
|
||||
state.loop_handle.remove(token);
|
||||
}
|
||||
backend.topology.coalescer.clear();
|
||||
backend.topology.reconciling = true;
|
||||
backend.topology.dirty = false;
|
||||
backend.output_error = None;
|
||||
if reactivate {
|
||||
for clock in backend.presentation.values_mut() {
|
||||
clock.reset_timing();
|
||||
}
|
||||
}
|
||||
if let Some(token) = backend.topology.retry_timer.take() {
|
||||
state.loop_handle.remove(token);
|
||||
}
|
||||
let mut invalidated = std::mem::take(&mut backend.topology.invalidated);
|
||||
let observed = all_gpus(backend.session.seat()).map(|paths| {
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let identity = device_identity(&path);
|
||||
(path, identity)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let automatic = state
|
||||
.output_profiles
|
||||
.iter()
|
||||
.any(|profile| profile.outputs.iter().any(|output| output.auto_refresh));
|
||||
backend.low_power = automatic
|
||||
&& super::super::power::low_power(
|
||||
backend.low_power,
|
||||
super::super::power::battery_percent(),
|
||||
);
|
||||
|
||||
if let Ok(observed) = &observed {
|
||||
backend.topology.observe_devices(observed);
|
||||
// Retire every obsolete node before opening any replacement. Otherwise
|
||||
// a later missing slot could remove a new GPU that reused its dev_t.
|
||||
invalidated.extend(
|
||||
backend
|
||||
.topology
|
||||
.retire_changed_devices(observed, |path| DrmNode::from_path(path).ok()),
|
||||
);
|
||||
}
|
||||
let mut paths = backend.topology.devices.keys().cloned().collect::<Vec<_>>();
|
||||
paths.sort();
|
||||
let mut ready = Vec::new();
|
||||
state.begin_desktop_transition();
|
||||
|
||||
for node in invalidated {
|
||||
remove_device(state, node);
|
||||
}
|
||||
|
||||
for path in paths {
|
||||
let slot = &state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.topology
|
||||
.devices[&path];
|
||||
let current_path = observed
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|devices| slot.current_path(&path, devices));
|
||||
let existing = current_path
|
||||
.and_then(|path| DrmNode::from_path(path).ok())
|
||||
.filter(|node| {
|
||||
state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.devices
|
||||
.contains_key(node)
|
||||
});
|
||||
let previous = match slot.availability {
|
||||
Availability::Ready(node) | Availability::Degraded(node) => Some(node),
|
||||
_ => existing,
|
||||
};
|
||||
let found = observed.as_ref().map(|_| current_path.is_some());
|
||||
if !matches!(found, Ok(true)) {
|
||||
if let Some(node) = previous {
|
||||
remove_device(state, node);
|
||||
}
|
||||
let entry = slot_mut(state, &path);
|
||||
match found {
|
||||
Ok(false) => entry.missing(),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, ?path, "could not enumerate DRM topology");
|
||||
entry.failed(Instant::now());
|
||||
}
|
||||
Ok(true) => unreachable!(),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(node) = previous.filter(|node| Some(*node) != existing) {
|
||||
remove_device(state, node);
|
||||
}
|
||||
let node = match existing {
|
||||
Some(node) => node,
|
||||
None => match open_device(
|
||||
state,
|
||||
current_path.expect("device discovery found a path before attempting to open it"),
|
||||
) {
|
||||
Ok(node) => node,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, ?path, device_path = ?current_path, "DRM device unavailable; will retry");
|
||||
slot_mut(state, &path).failed(Instant::now());
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let mut failed_outputs = Vec::new();
|
||||
let activation = if reactivate && existing.is_some() {
|
||||
let device = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.devices
|
||||
.get_mut(&node)
|
||||
.expect("the selected DRM device must remain registered during this operation");
|
||||
device.drm.activate(true).map(|()| {
|
||||
for (crtc, output) in &mut device.outputs {
|
||||
cancel_output_timers(&state.loop_handle, output);
|
||||
output.surface.reset_buffers();
|
||||
output.dmabuf_feedback.invalidate();
|
||||
output.primary_commit = None;
|
||||
output.capture_texture = Default::default();
|
||||
output.frame_pending = false;
|
||||
output.lock_frame_pending = false;
|
||||
output.power.suspended();
|
||||
state.session_lock.output_added(&output.output);
|
||||
state.display_presentation.remove_output(&output.output);
|
||||
state
|
||||
.desktop_transition
|
||||
.as_mut()
|
||||
.expect("desktop transition must be started before publication or retirement")
|
||||
.redraw
|
||||
.insert(output.output.clone());
|
||||
|
||||
if let Err(error) = output.surface.clear().and_then(|()| output.surface.reset_state()) {
|
||||
tracing::warn!(%error, ?node, ?crtc, "output reset failed; recreating output");
|
||||
failed_outputs.push(*crtc);
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if let Err(error) = activation {
|
||||
tracing::warn!(%error, ?node, "DRM reactivation failed; retiring unavailable outputs");
|
||||
remove_device(state, node);
|
||||
slot_mut(state, &path).failed(Instant::now());
|
||||
continue;
|
||||
}
|
||||
|
||||
for crtc in failed_outputs {
|
||||
let mut output = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.devices
|
||||
.get_mut(&node)
|
||||
.expect("the selected DRM device must remain registered during this operation")
|
||||
.outputs
|
||||
.remove(&crtc)
|
||||
.expect("the selected CRTC must remain registered during this operation");
|
||||
cancel_output_timers(&state.loop_handle, &mut output);
|
||||
if let Some(global) = output.global.take() {
|
||||
state.display_handle.disable_global::<Ferese>(global);
|
||||
}
|
||||
state.unregister_output(&output.output);
|
||||
state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.presentation
|
||||
.remove(&(node, crtc));
|
||||
}
|
||||
|
||||
ready.push((path, node));
|
||||
}
|
||||
|
||||
let known_good = state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.applied_outputs
|
||||
.clone();
|
||||
if let Err(error) = refresh_monitor_policy(state).and_then(|()| {
|
||||
transaction::retire_disconnected(state);
|
||||
validate_desired_outputs(state)
|
||||
}) {
|
||||
tracing::warn!(%error, "monitor policy validation failed; retaining applied outputs");
|
||||
state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.output_error = Some(error);
|
||||
for (path, node) in &ready {
|
||||
slot_mut(state, path).degraded(*node, Instant::now());
|
||||
}
|
||||
} else {
|
||||
// Activate destinations before disabling former owners, including across GPUs.
|
||||
ready.sort_by_key(|(_, node)| {
|
||||
let backend = state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.expect("DRM callbacks require an installed direct backend");
|
||||
let prefix = format!("{}/", drm_device_key(&backend.devices[node].drm));
|
||||
let desired = backend
|
||||
.desired_outputs
|
||||
.outputs
|
||||
.iter()
|
||||
.filter(|output| output.settings.enabled && output.key.starts_with(&prefix));
|
||||
let logical_sources = desired
|
||||
.clone()
|
||||
.filter(|output| output.mirror_source.is_none())
|
||||
.count();
|
||||
std::cmp::Reverse((logical_sources, desired.count()))
|
||||
});
|
||||
let mut succeeded = Vec::new();
|
||||
for (path, node) in &ready {
|
||||
let complete = rescan_device(state, *node);
|
||||
let entry = slot_mut(state, path);
|
||||
if complete == Ok(true) {
|
||||
entry.ready(*node);
|
||||
succeeded.push(*node);
|
||||
} else {
|
||||
entry.degraded(*node, Instant::now());
|
||||
// Device boundaries are not cross-GPU atomic. Compensate already
|
||||
// applied devices using their last actual hardware settings.
|
||||
if !known_good.outputs.is_empty() {
|
||||
let backend = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend");
|
||||
let requested = backend.desired_outputs.clone();
|
||||
backend.desired_outputs =
|
||||
crate::output_policy::restore_configuration(&known_good, &backend.monitors);
|
||||
for previous in succeeded {
|
||||
let _ = rescan_device(state, previous);
|
||||
}
|
||||
state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.desired_outputs = requested;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.output_error
|
||||
.is_none()
|
||||
{
|
||||
remember_applied_configuration(state);
|
||||
}
|
||||
}
|
||||
|
||||
transaction::recover_last_output(state);
|
||||
let mut desktop_changes = publish_runtime_desktop(state);
|
||||
if std::mem::take(
|
||||
&mut state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.topology
|
||||
.layout_dirty,
|
||||
) {
|
||||
desktop_changes.layout = true;
|
||||
desktop_changes.shell = true;
|
||||
desktop_changes
|
||||
.outputs
|
||||
.extend(state.space.outputs().cloned());
|
||||
}
|
||||
let backend = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend");
|
||||
backend.topology.reconciling = false;
|
||||
let retry = backend.topology.retry_deadline(backend.active);
|
||||
if std::mem::take(&mut backend.topology.remember_applied) {
|
||||
remember_applied_configuration(state);
|
||||
}
|
||||
refresh_connected_info(state);
|
||||
if let Some(deadline) = retry {
|
||||
match state
|
||||
.loop_handle
|
||||
.insert_source(Timer::from_deadline(deadline), |_, _, state| {
|
||||
if let Some(backend) = state.direct_backend.as_mut() {
|
||||
backend.topology.retry_timer = None;
|
||||
}
|
||||
reconcile_outputs(state, false);
|
||||
TimeoutAction::Drop
|
||||
}) {
|
||||
Ok(token) => {
|
||||
state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.topology
|
||||
.retry_timer = Some(token)
|
||||
}
|
||||
Err(error) => tracing::warn!(%error, "could not arm DRM recovery deadline"),
|
||||
}
|
||||
}
|
||||
|
||||
sync_battery_timer(state);
|
||||
state.finish_desktop_transition(desktop_changes);
|
||||
state.notify_monitor_state();
|
||||
lid::apply_suspend_policy(state);
|
||||
}
|
||||
|
||||
/// Publish only hardware present after application, compensation and recovery.
|
||||
fn publish_runtime_desktop(state: &mut Ferese) -> crate::state::DesktopChanges {
|
||||
let mut outputs = state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.devices
|
||||
.values()
|
||||
.flat_map(|device| device.outputs.values())
|
||||
.filter(|output| output.mirror_source.is_none())
|
||||
.map(|output| {
|
||||
(
|
||||
output.identity.clone(),
|
||||
output.output.clone(),
|
||||
output.mode,
|
||||
output.settings.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
outputs.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
let mut right_edge = 0;
|
||||
let runtime = outputs
|
||||
.into_iter()
|
||||
.map(|(identity, output, mode, settings)| {
|
||||
let position = settings.position.unwrap_or_else(|| {
|
||||
state
|
||||
.space
|
||||
.output_geometry(&output)
|
||||
.map(|geometry| [geometry.loc.x, geometry.loc.y])
|
||||
.unwrap_or([right_edge, 0])
|
||||
});
|
||||
let mode = OutputMode::from(mode);
|
||||
let transform = output_transform(settings.transform);
|
||||
let width =
|
||||
(transform.transform_size(mode.size).w as f64 / settings.scale).ceil() as i32;
|
||||
right_edge = right_edge.max(position[0].saturating_add(width));
|
||||
crate::state::DesktopOutput {
|
||||
output,
|
||||
identity,
|
||||
mode,
|
||||
transform,
|
||||
scale: Scale::Fractional(settings.scale),
|
||||
position: (position[0], position[1]).into(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let changes = state
|
||||
.publish_desktop(runtime)
|
||||
.expect("validated usable output inventory");
|
||||
for device in state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.devices
|
||||
.values_mut()
|
||||
{
|
||||
for output in device.outputs.values_mut() {
|
||||
if output.mirror_source.is_none() {
|
||||
if output.global.is_none() {
|
||||
output.global =
|
||||
Some(output.output.create_global::<Ferese>(&state.display_handle));
|
||||
}
|
||||
} else {
|
||||
// Mirrors are physical scanouts, not workspace-owning outputs.
|
||||
output.output.change_current_state(
|
||||
Some(OutputMode::from(output.mode)),
|
||||
Some(output_transform(output.settings.transform)),
|
||||
Some(Scale::Fractional(output.settings.scale)),
|
||||
None,
|
||||
);
|
||||
}
|
||||
output
|
||||
.surface
|
||||
.set_output_mode_source((&output.output).into());
|
||||
}
|
||||
}
|
||||
changes
|
||||
}
|
||||
|
||||
const SETTLE: Duration = Duration::from_millis(150);
|
||||
|
||||
/// Trailing-edge coalescing; inactive events retain dirtiness without arming a timer.
|
||||
pub(super) fn hotplug(state: &mut Ferese) {
|
||||
let Some(backend) = state.direct_backend.as_mut() else {
|
||||
return;
|
||||
};
|
||||
backend.topology.dirty = true;
|
||||
if let Some(token) = backend.topology.settle_timer.take() {
|
||||
state.loop_handle.remove(token);
|
||||
}
|
||||
let deadline = backend
|
||||
.topology
|
||||
.coalescer
|
||||
.request(monotonic_now(), backend.active && !backend.lid.pending());
|
||||
if deadline.is_none() {
|
||||
return;
|
||||
}
|
||||
match state
|
||||
.loop_handle
|
||||
.insert_source(Timer::from_duration(SETTLE), |_, _, state| {
|
||||
if let Some(backend) = state.direct_backend.as_mut() {
|
||||
backend.topology.settle_timer = None;
|
||||
if !backend.topology.coalescer.take_due(monotonic_now()) {
|
||||
return TimeoutAction::Drop;
|
||||
}
|
||||
}
|
||||
reconcile_outputs(state, false);
|
||||
TimeoutAction::Drop
|
||||
}) {
|
||||
Ok(token) => {
|
||||
state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.topology
|
||||
.settle_timer = Some(token)
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "could not debounce output events");
|
||||
reconcile_outputs(state, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replacement_gpu_at_the_same_card_path_gets_its_own_slot() {
|
||||
let card = PathBuf::from("/dev/dri/card0");
|
||||
let a = PathBuf::from("/sys/devices/gpu-a");
|
||||
let b = PathBuf::from("/sys/devices/gpu-b");
|
||||
let node = 1;
|
||||
let mut topology = Topology::<u64>::default();
|
||||
topology.observe_devices(&[(card.clone(), Some(a.clone()))]);
|
||||
topology.devices.get_mut(&a).unwrap().ready(node);
|
||||
// A policy failure keeps the device open, so it must retain ownership
|
||||
// of its node until replacement retirement runs.
|
||||
topology
|
||||
.devices
|
||||
.get_mut(&a)
|
||||
.unwrap()
|
||||
.degraded(node, Instant::now());
|
||||
let observed = [(card.clone(), Some(b.clone()))];
|
||||
topology.observe_devices(&observed);
|
||||
assert_eq!(
|
||||
topology.retire_changed_devices(&observed, |_| Some(node)),
|
||||
HashSet::from([node])
|
||||
);
|
||||
assert_eq!(topology.devices[&a].availability, Availability::Missing);
|
||||
assert_eq!(topology.devices[&a].current_path(&a, &observed), None);
|
||||
assert_eq!(
|
||||
topology.devices[&b].current_path(&b, &observed),
|
||||
Some(card.as_path())
|
||||
);
|
||||
topology.devices.get_mut(&b).unwrap().ready(node);
|
||||
assert!(
|
||||
topology
|
||||
.retire_changed_devices(&observed, |_| Some(node))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renumbered_and_returning_gpus_keep_independent_slots() {
|
||||
let a = PathBuf::from("/sys/devices/gpu-a");
|
||||
let b = PathBuf::from("/sys/devices/gpu-b");
|
||||
let card0 = PathBuf::from("/dev/dri/card0");
|
||||
let card2 = PathBuf::from("/dev/dri/card2");
|
||||
let node0 = 1;
|
||||
let node2 = 2;
|
||||
let mut topology = Topology::<u64>::default();
|
||||
topology.observe_devices(&[(card0.clone(), Some(a.clone()))]);
|
||||
topology.devices.get_mut(&a).unwrap().ready(node0);
|
||||
let observed = [
|
||||
(card0.clone(), Some(b.clone())),
|
||||
(card2.clone(), Some(a.clone())),
|
||||
];
|
||||
for _ in 0..2 {
|
||||
topology.observe_devices(&observed);
|
||||
}
|
||||
assert_eq!(topology.devices.len(), 2);
|
||||
assert_eq!(
|
||||
topology.devices[&a].current_path(&a, &observed),
|
||||
Some(card2.as_path())
|
||||
);
|
||||
assert_eq!(
|
||||
topology.devices[&b].current_path(&b, &observed),
|
||||
Some(card0.as_path())
|
||||
);
|
||||
assert_eq!(
|
||||
topology.retire_changed_devices(&observed, |path| Some(if path == card0 {
|
||||
node0
|
||||
} else {
|
||||
node2
|
||||
})),
|
||||
HashSet::from([node0])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovering_sysfs_identity_preserves_the_existing_slot() {
|
||||
let card = PathBuf::from("/dev/dri/card0");
|
||||
let identity = PathBuf::from("/sys/devices/gpu");
|
||||
let node = 1;
|
||||
let mut topology = Topology::<u64>::default();
|
||||
topology.observe_devices(&[(card.clone(), None)]);
|
||||
topology.devices.get_mut(&card).unwrap().ready(node);
|
||||
topology.observe_devices(&[(card.clone(), Some(identity.clone()))]);
|
||||
assert_eq!(topology.devices.len(), 1);
|
||||
assert_eq!(
|
||||
topology.devices[&identity].availability,
|
||||
Availability::Ready(node)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_identity_loss_reconciles_through_the_card_path() {
|
||||
let card = PathBuf::from("/dev/dri/card0");
|
||||
let identity = PathBuf::from("/sys/devices/gpu");
|
||||
let known = [(card.clone(), Some(identity.clone()))];
|
||||
let unknown = [(card.clone(), None)];
|
||||
let mut topology = Topology::<u64>::default();
|
||||
topology.observe_devices(&known);
|
||||
topology.devices.get_mut(&identity).unwrap().ready(1);
|
||||
|
||||
topology.observe_devices(&unknown);
|
||||
assert_eq!(
|
||||
topology.retire_changed_devices(&unknown, |_| Some(1)),
|
||||
HashSet::from([1])
|
||||
);
|
||||
assert_eq!(
|
||||
topology.devices[&identity].availability,
|
||||
Availability::Missing
|
||||
);
|
||||
assert_eq!(
|
||||
topology.devices[&card].current_path(&card, &unknown),
|
||||
Some(card.as_path())
|
||||
);
|
||||
// Reconciliation reopens the node using its card-path fallback.
|
||||
topology.devices.get_mut(&card).unwrap().ready(1);
|
||||
|
||||
topology.observe_devices(&known);
|
||||
assert_eq!(topology.devices.len(), 1);
|
||||
assert!(!topology.devices.contains_key(&card));
|
||||
assert_eq!(
|
||||
topology.devices[&identity].current_path(&identity, &known),
|
||||
Some(card.as_path())
|
||||
);
|
||||
// The reopened node is still valid and can be reused for this identity.
|
||||
assert!(
|
||||
topology
|
||||
.retire_changed_devices(&known, |_| Some(1))
|
||||
.is_empty()
|
||||
);
|
||||
topology.devices.get_mut(&identity).unwrap().ready(1);
|
||||
assert!(
|
||||
topology
|
||||
.retire_changed_devices(&known, |_| Some(1))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapping_device_numbers_retires_both_before_reopening_either() {
|
||||
let a = PathBuf::from("/sys/devices/gpu-a");
|
||||
let b = PathBuf::from("/sys/devices/gpu-b");
|
||||
let card0 = PathBuf::from("/dev/dri/card0");
|
||||
let card1 = PathBuf::from("/dev/dri/card1");
|
||||
let mut topology = Topology::<u64>::default();
|
||||
topology.observe_devices(&[
|
||||
(card0.clone(), Some(a.clone())),
|
||||
(card1.clone(), Some(b.clone())),
|
||||
]);
|
||||
topology.devices.get_mut(&a).unwrap().ready(0);
|
||||
topology.devices.get_mut(&b).unwrap().ready(1);
|
||||
let swapped = [
|
||||
(card0.clone(), Some(b.clone())),
|
||||
(card1.clone(), Some(a.clone())),
|
||||
];
|
||||
let node_for_path = |path: &Path| Some(u64::from(path == card1));
|
||||
topology.observe_devices(&swapped);
|
||||
assert_eq!(
|
||||
topology.retire_changed_devices(&swapped, node_for_path),
|
||||
HashSet::from([0, 1])
|
||||
);
|
||||
assert_eq!(topology.devices[&a].availability, Availability::Missing);
|
||||
assert_eq!(topology.devices[&b].availability, Availability::Missing);
|
||||
topology.devices.get_mut(&a).unwrap().ready(1);
|
||||
topology.devices.get_mut(&b).unwrap().ready(0);
|
||||
assert!(
|
||||
topology
|
||||
.retire_changed_devices(&swapped, node_for_path)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_changes_coalesce_and_reconciliation_cannot_reenter() {
|
||||
let mut topology = Topology::<u64>::default();
|
||||
assert!(!topology.request(false));
|
||||
assert!(!topology.request(false));
|
||||
assert!(topology.dirty);
|
||||
assert!(topology.request(true));
|
||||
topology.reconciling = true;
|
||||
assert!(!topology.request(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replug_matches_hardware_even_when_card_numbers_change() {
|
||||
let original = Path::new("/dev/dri/card0");
|
||||
let identity = PathBuf::from("/sys/devices/pci0000:00/0000:00:02.0");
|
||||
let mut slot = DeviceState::<u64> {
|
||||
identity: Some(identity.clone()),
|
||||
..DeviceState::default()
|
||||
};
|
||||
slot.failed(Instant::now());
|
||||
slot.missing();
|
||||
let observed = vec![
|
||||
(
|
||||
PathBuf::from("/dev/dri/card0"),
|
||||
Some(PathBuf::from("/sys/devices/other-gpu")),
|
||||
),
|
||||
(PathBuf::from("/dev/dri/card2"), Some(identity)),
|
||||
];
|
||||
assert_eq!(
|
||||
slot.current_path(original, &observed),
|
||||
Some(Path::new("/dev/dri/card2"))
|
||||
);
|
||||
assert!(slot.current_path(original, &observed[..1]).is_none());
|
||||
assert!(slot.current_path(original, &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_devices_retry_independently_with_capped_backoff() {
|
||||
let now = Instant::now();
|
||||
let mut topology = Topology::<u64>::default();
|
||||
topology
|
||||
.devices
|
||||
.insert("/dev/dri/card0".into(), DeviceState::default());
|
||||
let healthy = 1;
|
||||
let mut second = DeviceState::default();
|
||||
second.ready(healthy);
|
||||
topology.devices.insert("/dev/dri/card1".into(), second);
|
||||
let device = topology
|
||||
.devices
|
||||
.get_mut(Path::new("/dev/dri/card0"))
|
||||
.unwrap();
|
||||
device.failed(now);
|
||||
assert_eq!(device.retry_at, Some(now + Duration::from_secs(1)));
|
||||
for _ in 0..20 {
|
||||
device.failed(now);
|
||||
}
|
||||
assert_eq!(device.retry_at, Some(now + Duration::from_secs(32)));
|
||||
assert_eq!(
|
||||
topology.devices[Path::new("/dev/dri/card1")].availability,
|
||||
Availability::Ready(healthy)
|
||||
);
|
||||
assert!(topology.retry_deadline(false).is_none());
|
||||
assert_eq!(
|
||||
topology.retry_deadline(true),
|
||||
Some(now + Duration::from_secs(32))
|
||||
);
|
||||
topology
|
||||
.devices
|
||||
.get_mut(Path::new("/dev/dri/card0"))
|
||||
.unwrap()
|
||||
.missing();
|
||||
assert!(topology.retry_deadline(true).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
//! Validate a device-wide desired topology, then use Smithay for every real commit.
|
||||
//! Smithay exposes TEST_ONLY per surface, but not a combined CRTC request. The
|
||||
//! one raw request here is validation only; mode/plane ownership stays in Smithay.
|
||||
use super::{
|
||||
ControlDevice, DesiredOutputConfiguration, DirectDevice, DirectOutput, DrmDevice, DrmDeviceFd,
|
||||
DrmMode, DrmNode, Error, Ferese, Fourcc, GbmAllocator, GbmBufferFlags, GbmFramebufferExporter,
|
||||
GlesRenderer, HashMap, HashSet, OutputCompositor, OutputMode, OutputModeSource, OutputScan,
|
||||
OutputSelection, OutputSettings, PrimaryPlaneElement, cancel_output_timers,
|
||||
connected_mode_info, connector, create_direct_output, create_output, crtc, drm_device_key,
|
||||
internal_connector, io, output_transform, property, remember_applied_configuration,
|
||||
rescan_device, validate_desired_outputs,
|
||||
};
|
||||
use smithay::backend::allocator::Allocator;
|
||||
use smithay::backend::drm::exporter::{ExportBuffer, ExportFramebuffer};
|
||||
use smithay::reexports::drm::control::atomic::AtomicModeReq;
|
||||
use smithay::reexports::drm::control::{AtomicCommitFlags, ResourceHandle};
|
||||
|
||||
fn property<D: ControlDevice, H: ResourceHandle>(
|
||||
drm: &D,
|
||||
handle: H,
|
||||
name: &str,
|
||||
) -> io::Result<property::Handle> {
|
||||
for (property, _) in drm.get_properties(handle)?.iter() {
|
||||
if drm.get_property(*property)?.name().to_bytes() == name.as_bytes() {
|
||||
return Ok(*property);
|
||||
}
|
||||
}
|
||||
Err(io::Error::other(format!("missing DRM property {name}")))
|
||||
}
|
||||
|
||||
fn add<H: ResourceHandle>(
|
||||
drm: &DrmDevice,
|
||||
request: &mut AtomicModeReq,
|
||||
handle: H,
|
||||
name: &str,
|
||||
value: u64,
|
||||
) -> io::Result<()> {
|
||||
request.add_raw_property(handle.into(), property(drm, handle, name)?, value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_secondary_planes(
|
||||
drm: &DrmDevice,
|
||||
request: &mut AtomicModeReq,
|
||||
crtc: crtc::Handle,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let planes = drm.planes(&crtc)?;
|
||||
for plane in planes.cursor.iter().chain(&planes.overlay) {
|
||||
let properties = drm.get_properties(plane.handle)?;
|
||||
let owned = properties.iter().any(|(handle, value)| {
|
||||
drm.get_property(*handle).is_ok_and(|info| {
|
||||
info.name().to_bytes() == b"CRTC_ID" && *value == u32::from(crtc) as u64
|
||||
})
|
||||
});
|
||||
if owned {
|
||||
add(drm, request, plane.handle, "FB_ID", 0)?;
|
||||
add(drm, request, plane.handle, "CRTC_ID", 0)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Blobs {
|
||||
fd: DrmDeviceFd,
|
||||
ids: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Drop for Blobs {
|
||||
fn drop(&mut self) {
|
||||
for id in &self.ids {
|
||||
let _ = self.fd.destroy_property_blob(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_device(
|
||||
device: &DirectDevice,
|
||||
scan: &OutputScan,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
if scan.selections.iter().all(|selection| {
|
||||
device.outputs.get(&selection.crtc).is_some_and(|output| {
|
||||
output.connector == selection.connector.handle() && output.mode == selection.mode
|
||||
})
|
||||
}) && scan.selections.len() == device.outputs.len()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// Legacy KMS has no atomic test. Smithay validates its surface/mode as far as
|
||||
// the API permits; commit failures still go through the restoration path.
|
||||
if !device.drm.is_atomic() {
|
||||
return Ok(());
|
||||
}
|
||||
let drm = &device.drm;
|
||||
let mut request = AtomicModeReq::new();
|
||||
let mut blobs = Blobs {
|
||||
fd: drm.device_fd().clone(),
|
||||
ids: Vec::new(),
|
||||
};
|
||||
let mut buffers = Vec::new();
|
||||
let mut framebuffers = Vec::new();
|
||||
let mut allocator = GbmAllocator::new(
|
||||
device.gbm.clone(),
|
||||
GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT,
|
||||
);
|
||||
let exporter = GbmFramebufferExporter::new(device.gbm.clone(), Some(device.render_node).into());
|
||||
let mut used_planes = HashSet::new();
|
||||
let desired_crtcs = scan
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| selection.crtc)
|
||||
.collect::<HashSet<_>>();
|
||||
let desired_connectors = scan
|
||||
.selections
|
||||
.iter()
|
||||
.map(|selection| selection.connector.handle())
|
||||
.collect::<HashSet<_>>();
|
||||
for output in device.outputs.values() {
|
||||
if !desired_connectors.contains(&output.connector) {
|
||||
add(drm, &mut request, output.connector, "CRTC_ID", 0)?;
|
||||
}
|
||||
if !desired_crtcs.contains(&output.surface.crtc()) {
|
||||
clear_secondary_planes(drm, &mut request, output.surface.crtc())?;
|
||||
add(drm, &mut request, output.surface.crtc(), "ACTIVE", 0)?;
|
||||
add(drm, &mut request, output.surface.crtc(), "MODE_ID", 0)?;
|
||||
add(drm, &mut request, output.surface.plane(), "FB_ID", 0)?;
|
||||
add(drm, &mut request, output.surface.plane(), "CRTC_ID", 0)?;
|
||||
}
|
||||
}
|
||||
for selection in &scan.selections {
|
||||
clear_secondary_planes(drm, &mut request, selection.crtc)?;
|
||||
let planes = drm.planes(&selection.crtc)?;
|
||||
let primary = device
|
||||
.outputs
|
||||
.get(&selection.crtc)
|
||||
.map(|output| output.surface.plane())
|
||||
.or_else(|| {
|
||||
planes
|
||||
.primary
|
||||
.iter()
|
||||
.find(|plane| !used_planes.contains(&plane.handle))
|
||||
.map(|plane| plane.handle)
|
||||
})
|
||||
.ok_or("no usable primary plane")?;
|
||||
used_planes.insert(primary);
|
||||
if let Ok(rotation) = property(drm, primary, "rotation") {
|
||||
request.add_raw_property(primary.into(), rotation, 1);
|
||||
}
|
||||
let blob: u64 = drm.create_property_blob(&selection.mode)?.into();
|
||||
blobs.ids.push(blob);
|
||||
add(drm, &mut request, selection.crtc, "MODE_ID", blob)?;
|
||||
add(drm, &mut request, selection.crtc, "ACTIVE", 1)?;
|
||||
add(
|
||||
drm,
|
||||
&mut request,
|
||||
selection.connector.handle(),
|
||||
"CRTC_ID",
|
||||
u32::from(selection.crtc) as u64,
|
||||
)?;
|
||||
let (width, height) = selection.mode.size();
|
||||
let formats = &planes
|
||||
.primary
|
||||
.iter()
|
||||
.find(|plane| plane.handle == primary)
|
||||
.ok_or("primary plane disappeared")?
|
||||
.formats;
|
||||
let mut allocated = None;
|
||||
for format in formats.iter().filter(|format| {
|
||||
matches!(
|
||||
format.code,
|
||||
Fourcc::Argb8888 | Fourcc::Abgr8888 | Fourcc::Xrgb8888 | Fourcc::Xbgr8888
|
||||
)
|
||||
}) {
|
||||
if let Ok(buffer) = allocator.create_buffer(
|
||||
width.into(),
|
||||
height.into(),
|
||||
format.code,
|
||||
&[format.modifier],
|
||||
) && let Ok(Some(fb)) =
|
||||
exporter.add_framebuffer(drm.device_fd(), ExportBuffer::Allocator(&buffer), false)
|
||||
{
|
||||
allocated = Some((buffer, fb));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (buffer, fb) = allocated.ok_or("unable to allocate a DRM validation buffer")?;
|
||||
add(
|
||||
drm,
|
||||
&mut request,
|
||||
primary,
|
||||
"FB_ID",
|
||||
u32::from(*fb.as_ref()) as u64,
|
||||
)?;
|
||||
add(
|
||||
drm,
|
||||
&mut request,
|
||||
primary,
|
||||
"CRTC_ID",
|
||||
u32::from(selection.crtc) as u64,
|
||||
)?;
|
||||
for (name, value) in [
|
||||
("SRC_X", 0),
|
||||
("SRC_Y", 0),
|
||||
("SRC_W", (width as u64) << 16),
|
||||
("SRC_H", (height as u64) << 16),
|
||||
("CRTC_X", 0),
|
||||
("CRTC_Y", 0),
|
||||
("CRTC_W", width as u64),
|
||||
("CRTC_H", height as u64),
|
||||
] {
|
||||
add(drm, &mut request, primary, name, value)?;
|
||||
}
|
||||
buffers.push(buffer);
|
||||
framebuffers.push(fb);
|
||||
}
|
||||
drm.atomic_commit(
|
||||
AtomicCommitFlags::TEST_ONLY | AtomicCommitFlags::ALLOW_MODESET,
|
||||
request,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
surface: &mut OutputCompositor,
|
||||
renderer: &mut GlesRenderer,
|
||||
mode: DrmMode,
|
||||
settings: &OutputSettings,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
surface.use_mode(mode)?;
|
||||
surface.set_output_mode_source(OutputModeSource::Static {
|
||||
size: OutputMode::from(mode).size,
|
||||
scale: settings.scale.into(),
|
||||
transform: output_transform(settings.transform),
|
||||
});
|
||||
surface.reset_buffer_ages();
|
||||
let elements: Vec<crate::render::AnimatedWindowRenderElement> = Vec::new();
|
||||
let result = surface.render_frame(
|
||||
renderer,
|
||||
&elements,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
smithay::backend::drm::compositor::FrameFlags::empty(),
|
||||
)?;
|
||||
if let PrimaryPlaneElement::Swapchain(primary) = &result.primary_element {
|
||||
primary.sync.wait()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retire a logical output only after its hardware is unavailable or confirmed disabled.
|
||||
fn retire(state: &mut Ferese, output: &mut DirectOutput, node: DrmNode, crtc: crtc::Handle) {
|
||||
output.gamma = Default::default();
|
||||
cancel_output_timers(&state.loop_handle, output);
|
||||
if let Some(global) = output.global.take() {
|
||||
state.display_handle.disable_global::<Ferese>(global);
|
||||
}
|
||||
state.session_lock.output_removed(&output.output);
|
||||
state.unregister_output(&output.output);
|
||||
state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.presentation
|
||||
.remove(&(node, crtc));
|
||||
}
|
||||
|
||||
struct DeviceTransaction<'a> {
|
||||
state: &'a mut Ferese,
|
||||
device: &'a mut DirectDevice,
|
||||
selections: &'a HashMap<crtc::Handle, OutputSelection>,
|
||||
staged: HashMap<crtc::Handle, DirectOutput>,
|
||||
replaced: HashSet<crtc::Handle>,
|
||||
}
|
||||
|
||||
impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> {
|
||||
type Key = crtc::Handle;
|
||||
|
||||
fn stage(&mut self, crtc: crtc::Handle) -> Result<(), String> {
|
||||
let selection = &self.selections[&crtc];
|
||||
if self
|
||||
.device
|
||||
.outputs
|
||||
.get(&crtc)
|
||||
.is_none_or(|output| output.connector != selection.connector.handle())
|
||||
{
|
||||
let output = create_direct_output(
|
||||
self.state,
|
||||
&mut self.device.drm,
|
||||
&self.device.gbm,
|
||||
&self.device.renderer,
|
||||
selection.clone(),
|
||||
false,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.staged.insert(crtc, output);
|
||||
}
|
||||
let output = self
|
||||
.staged
|
||||
.get_mut(&crtc)
|
||||
.or_else(|| self.device.outputs.get_mut(&crtc))
|
||||
.expect("the selected CRTC has a live or staged output in this transaction");
|
||||
output
|
||||
.surface
|
||||
.set_connectors(&[selection.connector.handle()])
|
||||
.map_err(|error| error.to_string())?;
|
||||
output.dmabuf_feedback.invalidate();
|
||||
prepare(
|
||||
&mut output.surface,
|
||||
&mut self.device.renderer,
|
||||
selection.mode,
|
||||
&selection.settings,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn commit(&mut self, crtc: crtc::Handle) -> Result<(), String> {
|
||||
if self.staged.contains_key(&crtc) && self.device.outputs.contains_key(&crtc) {
|
||||
// Connector and mode cannot be changed together through use_mode /
|
||||
// set_connectors: each tests against the other's old value. Stage a
|
||||
// replacement surface, retire the old one *before* its first commit
|
||||
// (Smithay clears the CRTC on surface drop), then publish metadata.
|
||||
let replacement = self
|
||||
.staged
|
||||
.remove(&crtc)
|
||||
.expect("the selected CRTC has a live or staged output in this transaction");
|
||||
let previous = self
|
||||
.device
|
||||
.outputs
|
||||
.get_mut(&crtc)
|
||||
.expect("the selected CRTC has a live or staged output in this transaction");
|
||||
previous.surface = replacement.surface;
|
||||
previous.dmabuf_feedback = replacement.dmabuf_feedback;
|
||||
self.replaced.insert(crtc);
|
||||
}
|
||||
self.staged
|
||||
.get_mut(&crtc)
|
||||
.or_else(|| self.device.outputs.get_mut(&crtc))
|
||||
.expect("the selected CRTC has a live or staged output in this transaction")
|
||||
.surface
|
||||
.commit_frame()
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn disable(&mut self, crtc: crtc::Handle) -> Result<(), String> {
|
||||
self.device
|
||||
.outputs
|
||||
.get_mut(&crtc)
|
||||
.expect("the selected CRTC has a live or staged output in this transaction")
|
||||
.surface
|
||||
.clear()
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn abort_staged(&mut self) {
|
||||
for output in self.staged.values_mut() {
|
||||
let _ = output.surface.clear();
|
||||
}
|
||||
self.staged.clear();
|
||||
}
|
||||
|
||||
fn restore(&mut self, crtc: crtc::Handle) -> Result<(), String> {
|
||||
if self.replaced.contains(&crtc) {
|
||||
let output = &self.device.outputs[&crtc];
|
||||
let connector = self
|
||||
.device
|
||||
.drm
|
||||
.get_connector(output.connector, true)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if connector.state() != connector::State::Connected {
|
||||
return Err("previous connector is no longer available".into());
|
||||
}
|
||||
let selection = OutputSelection {
|
||||
connector,
|
||||
crtc,
|
||||
mode: output.mode,
|
||||
settings: output.settings.clone(),
|
||||
identity: output.identity.clone(),
|
||||
mirror_source: output.mirror_source.clone(),
|
||||
};
|
||||
let mut replacement = create_direct_output(
|
||||
self.state,
|
||||
&mut self.device.drm,
|
||||
&self.device.gbm,
|
||||
&self.device.renderer,
|
||||
selection,
|
||||
false,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
prepare(
|
||||
&mut replacement.surface,
|
||||
&mut self.device.renderer,
|
||||
replacement.mode,
|
||||
&replacement.settings,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let previous = self
|
||||
.device
|
||||
.outputs
|
||||
.get_mut(&crtc)
|
||||
.expect("the selected CRTC has a live or staged output in this transaction");
|
||||
previous.surface = replacement.surface;
|
||||
previous.dmabuf_feedback = replacement.dmabuf_feedback;
|
||||
previous
|
||||
.surface
|
||||
.commit_frame()
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
let Some(output) = self.device.outputs.get_mut(&crtc) else {
|
||||
return Ok(());
|
||||
};
|
||||
output.dmabuf_feedback.invalidate();
|
||||
output
|
||||
.surface
|
||||
.reset_state()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let restored = output
|
||||
.surface
|
||||
.set_connectors(&[output.connector])
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|()| {
|
||||
prepare(
|
||||
&mut output.surface,
|
||||
&mut self.device.renderer,
|
||||
output.mode,
|
||||
&output.settings,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.and_then(|()| {
|
||||
output
|
||||
.surface
|
||||
.commit_frame()
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
self.state.session_lock.output_added(&output.output);
|
||||
self.state
|
||||
.display_presentation
|
||||
.remove_output(&output.output);
|
||||
self.state
|
||||
.desktop_transition
|
||||
.as_mut()
|
||||
.expect("desktop transition must be started before publication or retirement")
|
||||
.redraw
|
||||
.insert(output.output.clone());
|
||||
output.lock_frame_pending = false;
|
||||
output
|
||||
.surface
|
||||
.set_output_mode_source((&output.output).into());
|
||||
cancel_output_timers(&self.state.loop_handle, output);
|
||||
output.frame_pending = false;
|
||||
restored
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete device test precedes application. Release former connector owners
|
||||
/// before per-surface tests; publish only after the final inventory is usable.
|
||||
pub(super) fn apply_device(
|
||||
state: &mut Ferese,
|
||||
node: DrmNode,
|
||||
device: &mut DirectDevice,
|
||||
scan: OutputScan,
|
||||
) -> Result<(), String> {
|
||||
let selections = scan
|
||||
.selections
|
||||
.into_iter()
|
||||
.map(|selection| (selection.crtc, selection))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut changed = selections
|
||||
.iter()
|
||||
.filter_map(|(crtc, selection)| {
|
||||
let same = device.outputs.get(crtc).is_some_and(|output| {
|
||||
output.connector == selection.connector.handle()
|
||||
&& output.identity == selection.identity
|
||||
&& output.mode == selection.mode
|
||||
&& output.settings.same_configuration(&selection.settings)
|
||||
&& output.mirror_source == selection.mirror_source
|
||||
});
|
||||
(!same).then_some(*crtc)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
changed.sort_by_key(|crtc| (selections[crtc].mirror_source.is_some(), u32::from(*crtc)));
|
||||
let removals = device
|
||||
.outputs
|
||||
.keys()
|
||||
.filter(|crtc| !selections.contains_key(crtc))
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
// A connector replacement can reuse the same CRTC, so it is not a removal
|
||||
// from the keyed inventory. Its old routing/resources must still be released
|
||||
// before staging any other output's new mode (for example HDMI -> DP).
|
||||
let mut releases = removals.clone();
|
||||
releases.extend(selections.iter().filter_map(|(crtc, selection)| {
|
||||
device
|
||||
.outputs
|
||||
.get(crtc)
|
||||
.and_then(|output| (output.connector != selection.connector.handle()).then_some(*crtc))
|
||||
}));
|
||||
releases.sort_by_key(|crtc| u32::from(*crtc));
|
||||
let hardware_changes = changed
|
||||
.iter()
|
||||
.filter(|crtc| {
|
||||
!device.outputs.get(crtc).is_some_and(|output| {
|
||||
let selection = &selections[crtc];
|
||||
output.connector == selection.connector.handle() && output.mode == selection.mode
|
||||
})
|
||||
})
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
let mut driver = DeviceTransaction {
|
||||
state,
|
||||
device,
|
||||
selections: &selections,
|
||||
staged: HashMap::new(),
|
||||
replaced: HashSet::new(),
|
||||
};
|
||||
let result = crate::output_transaction::apply(&mut driver, &hardware_changes, &releases);
|
||||
let mut staged = std::mem::take(&mut driver.staged);
|
||||
if let Err(failure) = result {
|
||||
for crtc in failure.unavailable {
|
||||
if let Some(mut output) = device.outputs.remove(&crtc) {
|
||||
retire(state, &mut output, node, crtc);
|
||||
}
|
||||
}
|
||||
return Err(failure.error);
|
||||
}
|
||||
for crtc in &changed {
|
||||
let selection = &selections[crtc];
|
||||
let mut output = staged
|
||||
.remove(crtc)
|
||||
.or_else(|| device.outputs.remove(crtc))
|
||||
.expect("the selected CRTC has a live or staged output in this transaction");
|
||||
if output.connector != selection.connector.handle() || output.identity != selection.identity
|
||||
{
|
||||
retire(state, &mut output, node, *crtc);
|
||||
let (handle, _) = create_output(
|
||||
state,
|
||||
&selection.connector,
|
||||
selection.mode,
|
||||
selection.identity.clone(),
|
||||
&selection.settings,
|
||||
false,
|
||||
);
|
||||
output.output = handle;
|
||||
}
|
||||
if selection.mirror_source.is_some() && output.global.is_some() {
|
||||
retire(state, &mut output, node, *crtc);
|
||||
}
|
||||
output.connector = selection.connector.handle();
|
||||
output.internal = internal_connector(selection.connector.interface());
|
||||
output.identity = selection.identity.clone();
|
||||
output.settings = selection.settings.clone();
|
||||
output.mode = selection.mode;
|
||||
output.mirror_source = selection.mirror_source.clone();
|
||||
// Resource metadata describes applied hardware. Wayland output state,
|
||||
// workspace ownership and floating positions publish once after all GPUs.
|
||||
state
|
||||
.desktop_transition
|
||||
.as_mut()
|
||||
.expect("output reconciliation boundary")
|
||||
.redraw
|
||||
.insert(output.output.clone());
|
||||
state.session_lock.output_added(&output.output);
|
||||
if hardware_changes.contains(crtc) {
|
||||
state.display_presentation.remove_output(&output.output);
|
||||
}
|
||||
output.lock_frame_pending = false;
|
||||
output
|
||||
.surface
|
||||
.set_output_mode_source((&output.output).into());
|
||||
cancel_output_timers(&state.loop_handle, &mut output);
|
||||
output.frame_pending = false;
|
||||
output.primary_commit = None;
|
||||
output.capture_texture = Default::default();
|
||||
output.power.suspended();
|
||||
let clock = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.presentation
|
||||
.entry((node, *crtc))
|
||||
.or_default();
|
||||
clock.set_refresh(OutputMode::from(output.mode).refresh);
|
||||
clock.reset_timing();
|
||||
device.outputs.insert(*crtc, output);
|
||||
}
|
||||
// Retire resources now; the final inventory determines evacuation once.
|
||||
for crtc in removals {
|
||||
let mut output = device
|
||||
.outputs
|
||||
.remove(&crtc)
|
||||
.expect("the selected CRTC has a live or staged output in this transaction");
|
||||
retire(state, &mut output, node, crtc);
|
||||
}
|
||||
device.connected_outputs = scan.connected_outputs;
|
||||
update_applied_info(device);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn update_applied_info(device: &mut DirectDevice) {
|
||||
for info in &mut device.connected_outputs {
|
||||
info.enabled = false;
|
||||
info.current_mode = None;
|
||||
if let Some(output) = device
|
||||
.outputs
|
||||
.values()
|
||||
.find(|output| output.identity == info.identity)
|
||||
{
|
||||
info.enabled = true;
|
||||
info.current_mode = Some(connected_mode_info(output.mode));
|
||||
info.scale = output.settings.scale;
|
||||
info.transform = output.settings.transform;
|
||||
info.configured_position = output.settings.position;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnected hardware cannot remain logically live, even if a new policy fails validation.
|
||||
pub(super) fn retire_disconnected(state: &mut Ferese) {
|
||||
let missing = state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.devices
|
||||
.iter()
|
||||
.flat_map(|(node, device)| {
|
||||
device.outputs.iter().filter_map(|(crtc, output)| {
|
||||
device
|
||||
.drm
|
||||
.get_connector(output.connector, true)
|
||||
.ok()
|
||||
.filter(|connector| connector.state() != connector::State::Connected)
|
||||
.map(|_| (*node, *crtc))
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for (node, crtc) in missing {
|
||||
if let Some(mut output) = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.devices
|
||||
.get_mut(&node)
|
||||
.expect("the selected DRM device must remain registered during this operation")
|
||||
.outputs
|
||||
.remove(&crtc)
|
||||
{
|
||||
let _ = output.surface.clear();
|
||||
retire(state, &mut output, node, crtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// If initial activation or rollback leaves no usable desktop, try a preferred
|
||||
/// mode on each physical monitor independently. Never disable a surviving output.
|
||||
pub(super) fn recover_last_output(state: &mut Ferese) {
|
||||
if state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.devices
|
||||
.values()
|
||||
.any(|device| {
|
||||
device
|
||||
.outputs
|
||||
.values()
|
||||
.any(|output| output.mirror_source.is_none())
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
let backend = state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.expect("DRM callbacks require an installed direct backend");
|
||||
let requested = backend.desired_outputs.clone();
|
||||
let original_error = backend.output_error.clone();
|
||||
let mut candidates = backend
|
||||
.monitors
|
||||
.iter()
|
||||
.filter(|monitor| monitor.usable)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort_by_key(|monitor| (!monitor.internal, monitor.key.clone()));
|
||||
for candidate in candidates {
|
||||
let backend = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend");
|
||||
backend.desired_outputs = DesiredOutputConfiguration {
|
||||
profile: None,
|
||||
suspend: requested.suspend,
|
||||
outputs: backend
|
||||
.monitors
|
||||
.iter()
|
||||
.map(|monitor| {
|
||||
let mut settings = crate::output_policy::defaults(monitor.identity.clone());
|
||||
settings.enabled = monitor.key == candidate.key;
|
||||
crate::output_policy::DesiredOutput {
|
||||
key: monitor.key.clone(),
|
||||
settings,
|
||||
mirror_source: None,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let nodes = backend
|
||||
.devices
|
||||
.iter()
|
||||
.filter(|(_, device)| {
|
||||
candidate
|
||||
.key
|
||||
.starts_with(&format!("{}/", drm_device_key(&device.drm)))
|
||||
})
|
||||
.map(|(node, _)| *node)
|
||||
.collect::<Vec<_>>();
|
||||
if validate_desired_outputs(state).is_ok() {
|
||||
for node in nodes {
|
||||
let _ = rescan_device(state, node);
|
||||
}
|
||||
if state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.devices
|
||||
.values()
|
||||
.any(|device| {
|
||||
device
|
||||
.outputs
|
||||
.values()
|
||||
.any(|output| output.mirror_source.is_none())
|
||||
})
|
||||
{
|
||||
remember_applied_configuration(state);
|
||||
state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend")
|
||||
.output_error = original_error;
|
||||
tracing::warn!(identity = %candidate.identity, "recovered last usable output with preferred mode");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let backend = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.expect("DRM callbacks require an installed direct backend");
|
||||
backend.desired_outputs = requested;
|
||||
backend.output_error = original_error;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use smithay::reexports::calloop::EventLoop;
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::private_client::ClientCapabilities;
|
||||
use crate::session_environment::SessionPolicy;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum BackendKind {
|
||||
@@ -17,22 +18,29 @@ pub enum BackendKind {
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct LaunchConfig {
|
||||
pub backend: BackendKind,
|
||||
pub(crate) session_policy: SessionPolicy,
|
||||
pub client: Vec<OsString>,
|
||||
pub client_capabilities: ClientCapabilities,
|
||||
pub supervise_client: bool,
|
||||
}
|
||||
|
||||
impl LaunchConfig {
|
||||
pub fn from_environment() -> Result<Self, String> {
|
||||
let graphical_session = std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some();
|
||||
let graphical_session =
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some();
|
||||
|
||||
Self::parse(std::env::args_os().skip(1), graphical_session)
|
||||
}
|
||||
|
||||
fn parse(args: impl IntoIterator<Item = OsString>, graphical_session: bool) -> Result<Self, String> {
|
||||
fn parse(
|
||||
args: impl IntoIterator<Item = OsString>,
|
||||
graphical_session: bool,
|
||||
) -> Result<Self, String> {
|
||||
let mut args = args.into_iter().peekable();
|
||||
let mut requested_backend = None;
|
||||
let mut client = Vec::new();
|
||||
let mut client_capabilities = ClientCapabilities::default();
|
||||
let mut supervise_client = false;
|
||||
|
||||
while let Some(argument) = args.next() {
|
||||
if argument == "--" {
|
||||
@@ -50,6 +58,10 @@ impl LaunchConfig {
|
||||
client_capabilities.insert(ClientCapabilities::EFFECTS);
|
||||
continue;
|
||||
}
|
||||
if argument == "--supervise-client" {
|
||||
supervise_client = true;
|
||||
continue;
|
||||
}
|
||||
if argument == "--grant-shell-control" {
|
||||
client_capabilities.insert(ClientCapabilities::SHELL_CONTROL);
|
||||
continue;
|
||||
@@ -73,10 +85,18 @@ impl LaunchConfig {
|
||||
BackendKind::Drm
|
||||
});
|
||||
|
||||
if supervise_client && client.is_empty() {
|
||||
return Err("--supervise-client requires a client command".into());
|
||||
}
|
||||
Ok(Self {
|
||||
backend,
|
||||
session_policy: match backend {
|
||||
BackendKind::Drm => SessionPolicy::Desktop,
|
||||
BackendKind::Nested => SessionPolicy::Embedded,
|
||||
},
|
||||
client,
|
||||
client_capabilities,
|
||||
supervise_client,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -97,7 +117,10 @@ fn parse_backend(value: &OsString) -> Result<Option<BackendKind>, String> {
|
||||
Some("auto") => Ok(None),
|
||||
Some("nested") => Ok(Some(BackendKind::Nested)),
|
||||
Some("drm") => Ok(Some(BackendKind::Drm)),
|
||||
_ => Err(format!("unknown backend {:?}; expected auto, nested, or drm", value)),
|
||||
_ => Err(format!(
|
||||
"unknown backend {:?}; expected auto, nested, or drm",
|
||||
value
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,8 +133,33 @@ mod tests {
|
||||
let config = LaunchConfig::parse([OsString::from("foot")], true).unwrap();
|
||||
|
||||
assert_eq!(config.backend, BackendKind::Nested);
|
||||
assert_eq!(config.session_policy, SessionPolicy::Embedded);
|
||||
assert_eq!(config.client, [OsString::from("foot")]);
|
||||
assert!(config.client_capabilities.is_empty());
|
||||
assert!(!config.supervise_client);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supervision_requires_an_explicit_client() {
|
||||
assert!(LaunchConfig::parse([OsString::from("--supervise-client")], true).is_err());
|
||||
let config = LaunchConfig::parse(
|
||||
[
|
||||
"--supervise-client",
|
||||
"--grant-shell-control",
|
||||
"--",
|
||||
"ferese-session-shell",
|
||||
]
|
||||
.map(OsString::from),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(config.supervise_client);
|
||||
assert_eq!(config.client, [OsString::from("ferese-session-shell")]);
|
||||
assert!(
|
||||
config
|
||||
.client_capabilities
|
||||
.contains(ClientCapabilities::SHELL_CONTROL)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -119,6 +167,7 @@ mod tests {
|
||||
let config = LaunchConfig::parse(Vec::<OsString>::new(), false).unwrap();
|
||||
|
||||
assert_eq!(config.backend, BackendKind::Drm);
|
||||
assert_eq!(config.session_policy, SessionPolicy::Desktop);
|
||||
assert!(config.client.is_empty());
|
||||
assert!(config.client_capabilities.is_empty());
|
||||
}
|
||||
@@ -156,26 +205,54 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(config.client, [OsString::from("ferese-effects-probe")]);
|
||||
assert!(config.client_capabilities.contains(ClientCapabilities::EFFECTS));
|
||||
assert!(
|
||||
config
|
||||
.client_capabilities
|
||||
.contains(ClientCapabilities::EFFECTS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicitly_grants_shell_control_to_the_launched_private_client() {
|
||||
let config = LaunchConfig::parse(
|
||||
[OsString::from("--grant-shell-control"), OsString::from("ferese-shell")],
|
||||
[
|
||||
OsString::from("--grant-shell-control"),
|
||||
OsString::from("ferese-shell"),
|
||||
],
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(config.client, [OsString::from("ferese-shell")]);
|
||||
assert!(config.client_capabilities.contains(ClientCapabilities::SHELL_CONTROL));
|
||||
assert!(!config.client_capabilities.contains(ClientCapabilities::EFFECTS));
|
||||
assert!(
|
||||
config
|
||||
.client_capabilities
|
||||
.contains(ClientCapabilities::SHELL_CONTROL)
|
||||
);
|
||||
assert!(
|
||||
!config
|
||||
.client_capabilities
|
||||
.contains(ClientCapabilities::EFFECTS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_unknown_backend() {
|
||||
let error = LaunchConfig::parse([OsString::from("--backend"), OsString::from("other")], true).unwrap_err();
|
||||
let error =
|
||||
LaunchConfig::parse([OsString::from("--backend"), OsString::from("other")], true)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.contains("unknown backend"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_backend_selects_the_default_session_policy() {
|
||||
for (backend, graphical, policy) in [
|
||||
("--backend=drm", true, SessionPolicy::Desktop),
|
||||
("--backend=nested", false, SessionPolicy::Embedded),
|
||||
] {
|
||||
let config = LaunchConfig::parse([OsString::from(backend)], graphical).unwrap();
|
||||
assert_eq!(config.session_policy, policy);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,10 @@ fn read_battery_percent(root: &Path) -> Option<u8> {
|
||||
.ok()
|
||||
.map(|text| text.trim().to_owned())
|
||||
};
|
||||
if entries
|
||||
.iter()
|
||||
.any(|path| read(path, "online").as_deref() == Some("1") && read(path, "type").as_deref() != Some("Battery"))
|
||||
{
|
||||
if entries.iter().any(|path| {
|
||||
read(path, "online").as_deref() == Some("1")
|
||||
&& read(path, "type").as_deref() != Some("Battery")
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
entries.iter().find_map(|path| {
|
||||
@@ -57,7 +57,9 @@ pub(super) fn low_refresh_mode(size: (u16, u16), modes: &[(u16, u16, i32)]) -> O
|
||||
modes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (width, height, refresh))| (*width, *height) == size && (59_000..=61_000).contains(refresh))
|
||||
.filter(|(_, (width, height, refresh))| {
|
||||
(*width, *height) == size && (59_000..=61_000).contains(refresh)
|
||||
})
|
||||
.min_by_key(|(_, (_, _, refresh))| refresh.abs_diff(60_000))
|
||||
.map(|(index, _)| index)
|
||||
}
|
||||
@@ -96,7 +98,10 @@ mod tests {
|
||||
assert_eq!(low_refresh_mode((2880, 1800), &modes), Some(3));
|
||||
assert_eq!(low_refresh_mode((2880, 1800), &modes[..3]), Some(2));
|
||||
assert_eq!(low_refresh_mode((2880, 1800), &modes[..2]), None);
|
||||
assert_eq!(low_refresh_mode((2880, 1800), &[(2880, 1800, -60_000)]), None);
|
||||
assert_eq!(
|
||||
low_refresh_mode((2880, 1800), &[(2880, 1800, -60_000)]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,45 +1,72 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Config, ConfigError, Deserialize, default_border_width, default_focus_ring_width,
|
||||
default_shadow_blur, default_shadow_offset_y, default_shadow_opacity, default_window_radius,
|
||||
};
|
||||
|
||||
impl Config {
|
||||
pub(crate) fn inactive_dim_settings(&self) -> Result<InactiveDimSettings, ConfigError> {
|
||||
Ok(InactiveDimSettings {
|
||||
enabled: self.appearance.inactive_dim.enabled,
|
||||
amount: unit_theme_value(self.appearance.inactive_dim.amount, "appearance.inactive_dim.amount")?,
|
||||
pub(crate) fn focus_effect_settings(&self) -> Result<FocusEffectSettings, ConfigError> {
|
||||
let effect = &self.appearance.focus_effect;
|
||||
Ok(FocusEffectSettings {
|
||||
enabled: effect.enabled,
|
||||
blur: effect.blur,
|
||||
active_opacity: unit_theme_value(
|
||||
effect.active_opacity,
|
||||
"appearance.focus_effect.active_opacity",
|
||||
)?,
|
||||
inactive_opacity: unit_theme_value(
|
||||
effect.inactive_opacity,
|
||||
"appearance.focus_effect.inactive_opacity",
|
||||
)?,
|
||||
inactive_dim: unit_theme_value(
|
||||
effect.inactive_dim,
|
||||
"appearance.focus_effect.inactive_dim",
|
||||
)?,
|
||||
duration_ms: nonnegative_theme_value(
|
||||
self.appearance.inactive_dim.duration_ms,
|
||||
"appearance.inactive_dim.duration_ms",
|
||||
effect.duration_ms,
|
||||
"appearance.focus_effect.duration_ms",
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn theme_settings(&self) -> Result<ThemeSettings, ConfigError> {
|
||||
let border_width = nonnegative_theme_value(self.theme.geometry.border_width, "geometry.border_width")?;
|
||||
let focus_ring_width =
|
||||
nonnegative_theme_value(self.theme.geometry.focus_ring_width, "geometry.focus_ring_width")?;
|
||||
let window_radius = nonnegative_theme_value(self.theme.geometry.window_radius, "geometry.window_radius")?;
|
||||
let shadow_offset_y = finite_theme_value(self.theme.shadow.soft.offset_y, "shadow.soft.offset_y")?;
|
||||
let border_width =
|
||||
nonnegative_theme_value(self.theme.geometry.border_width, "geometry.border_width")?;
|
||||
let focus_ring_width = nonnegative_theme_value(
|
||||
self.theme.geometry.focus_ring_width,
|
||||
"geometry.focus_ring_width",
|
||||
)?;
|
||||
let window_radius =
|
||||
nonnegative_theme_value(self.theme.geometry.window_radius, "geometry.window_radius")?;
|
||||
let shadow_offset_y =
|
||||
finite_theme_value(self.theme.shadow.soft.offset_y, "shadow.soft.offset_y")?;
|
||||
let shadow_blur = nonnegative_theme_value(self.theme.shadow.soft.blur, "shadow.soft.blur")?;
|
||||
let shadow_opacity = unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?;
|
||||
let shadow_opacity =
|
||||
unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?;
|
||||
|
||||
let material_radius = nonnegative_theme_value(
|
||||
self.theme
|
||||
.geometry
|
||||
.shell_radius
|
||||
.or(self.appearance.corner_radius)
|
||||
.or(self.theme.geometry.top_bar_radius)
|
||||
.unwrap_or(14.0),
|
||||
self.theme.geometry.shell_radius.unwrap_or(14.0),
|
||||
"geometry.shell_radius",
|
||||
)?;
|
||||
Ok(ThemeSettings {
|
||||
border_width,
|
||||
focus_ring_width,
|
||||
border_color: parse_color(&self.theme.colors.border, "colors.border")?,
|
||||
text_primary_color: parse_color(&self.theme.colors.text_primary, "colors.text_primary")?,
|
||||
text_primary_color: parse_color(
|
||||
&self.theme.colors.text_primary,
|
||||
"colors.text_primary",
|
||||
)?,
|
||||
accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?,
|
||||
border_gradient: self.theme.border.settings("border")?,
|
||||
focus_ring_gradient: self.theme.focus_ring.settings("focus_ring")?,
|
||||
shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?,
|
||||
surface_base_color: parse_color(&self.theme.colors.surface_base, "colors.surface_base")?,
|
||||
surface_base_color: parse_color(
|
||||
&self.theme.colors.surface_base,
|
||||
"colors.surface_base",
|
||||
)?,
|
||||
surface_raised_color: parse_color(
|
||||
&self.theme.colors.surface_raised,
|
||||
"colors.surface_raised",
|
||||
)?,
|
||||
bar_background_color: parse_color(
|
||||
self.theme
|
||||
.surface
|
||||
@@ -55,10 +82,18 @@ impl Config {
|
||||
shadow_blur,
|
||||
shadow_opacity,
|
||||
material_style: self.theme.material.style,
|
||||
backdrop_blur: nonnegative_theme_value(self.theme.material.blur_radius, "material.blur_radius")?.min(32.0),
|
||||
material_tint_strength: unit_theme_value(self.theme.material.tint_strength, "material.tint_strength")?,
|
||||
backdrop_blur: nonnegative_theme_value(
|
||||
self.theme.material.blur_radius,
|
||||
"material.blur_radius",
|
||||
)?
|
||||
.min(32.0),
|
||||
material_tint_strength: unit_theme_value(
|
||||
self.theme.material.tint_strength,
|
||||
"material.tint_strength",
|
||||
)?,
|
||||
material_radius,
|
||||
panel_radius: material_radius,
|
||||
reduce_transparency: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -145,6 +180,10 @@ pub(super) fn default_surface_base_color() -> String {
|
||||
"#111821".to_owned()
|
||||
}
|
||||
|
||||
pub(super) fn default_surface_raised_color() -> String {
|
||||
ferese_config::theme::default_tokens().colors.surface_raised
|
||||
}
|
||||
|
||||
pub(super) fn default_accent_color() -> String {
|
||||
"#3D7BE6".to_owned()
|
||||
}
|
||||
@@ -163,12 +202,14 @@ pub struct BorderGradient {
|
||||
pub angle: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
pub(super) struct BorderPaintConfig {
|
||||
#[serde(default)]
|
||||
pub(super) style: ferese_config::theme::PaintStyle,
|
||||
pub(super) gradient: Option<BorderGradientConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
pub(super) struct BorderGradientConfig {
|
||||
pub(super) from: String,
|
||||
pub(super) to: String,
|
||||
@@ -185,9 +226,14 @@ impl BorderPaintConfig {
|
||||
"focus_ring.gradient.angle",
|
||||
)
|
||||
} else {
|
||||
("border.gradient.from", "border.gradient.to", "border.gradient.angle")
|
||||
(
|
||||
"border.gradient.from",
|
||||
"border.gradient.to",
|
||||
"border.gradient.angle",
|
||||
)
|
||||
};
|
||||
self.gradient
|
||||
let gradient = self
|
||||
.gradient
|
||||
.as_ref()
|
||||
.map(|gradient| {
|
||||
Ok(BorderGradient {
|
||||
@@ -196,7 +242,13 @@ impl BorderPaintConfig {
|
||||
angle: finite_theme_value(gradient.angle, angle_name)?.rem_euclid(360.0),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
.transpose()?;
|
||||
|
||||
Ok(if self.style == ferese_config::theme::PaintStyle::Solid {
|
||||
None
|
||||
} else {
|
||||
gradient
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +262,7 @@ pub struct ThemeSettings {
|
||||
pub focus_ring_gradient: Option<BorderGradient>,
|
||||
pub shadow_color: RgbaColor,
|
||||
pub surface_base_color: RgbaColor,
|
||||
pub surface_raised_color: RgbaColor,
|
||||
pub bar_background_color: RgbaColor,
|
||||
pub text_primary_color: RgbaColor,
|
||||
pub shell_opacity: f64,
|
||||
@@ -222,9 +275,12 @@ pub struct ThemeSettings {
|
||||
pub material_tint_strength: f64,
|
||||
pub material_radius: f64,
|
||||
pub panel_radius: f64,
|
||||
pub reduce_transparency: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, schemars::JsonSchema, serde::Serialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MaterialStyle {
|
||||
Translucent,
|
||||
@@ -232,39 +288,48 @@ pub enum MaterialStyle {
|
||||
Solid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct AppearanceConfig {
|
||||
pub(super) corner_radius: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(super) inactive_dim: InactiveDimConfig,
|
||||
pub(super) focus_effect: FocusEffectConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct InactiveDimSettings {
|
||||
pub struct FocusEffectSettings {
|
||||
pub enabled: bool,
|
||||
pub amount: f64,
|
||||
pub blur: bool,
|
||||
pub active_opacity: f64,
|
||||
pub inactive_opacity: f64,
|
||||
pub inactive_dim: f64,
|
||||
pub duration_ms: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub(super) struct InactiveDimConfig {
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub(super) struct FocusEffectConfig {
|
||||
pub(super) enabled: bool,
|
||||
pub(super) amount: f64,
|
||||
pub(super) blur: bool,
|
||||
pub(super) active_opacity: f64,
|
||||
pub(super) inactive_opacity: f64,
|
||||
pub(super) inactive_dim: f64,
|
||||
pub(super) duration_ms: f64,
|
||||
}
|
||||
|
||||
impl Default for InactiveDimConfig {
|
||||
impl Default for FocusEffectConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
amount: 0.15,
|
||||
enabled: true,
|
||||
blur: true,
|
||||
active_opacity: 1.0,
|
||||
inactive_opacity: 1.0,
|
||||
inactive_dim: 0.0,
|
||||
duration_ms: 150.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
pub(super) struct ThemeConfig {
|
||||
#[serde(default)]
|
||||
pub(super) border: BorderPaintConfig,
|
||||
@@ -286,23 +351,23 @@ pub(super) struct ThemeConfig {
|
||||
pub(super) surface: ThemeSurfaceConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
pub(super) struct ThemeSurfaceConfig {
|
||||
#[serde(default)]
|
||||
pub(super) bar: ThemeBarConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
pub(super) struct ThemeBarConfig {
|
||||
pub(super) background: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
pub(super) struct OverviewTypographyConfig {
|
||||
pub(super) font_family: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
pub(super) struct ThemeMaterialConfig {
|
||||
#[serde(default = "default_shell_opacity")]
|
||||
pub(super) opacity: f64,
|
||||
@@ -337,12 +402,14 @@ fn default_backdrop_blur() -> f64 {
|
||||
12.0
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
pub(super) struct ThemeColorsConfig {
|
||||
#[serde(default = "default_text_primary_color")]
|
||||
pub(super) text_primary: String,
|
||||
#[serde(default = "default_surface_base_color")]
|
||||
pub(super) surface_base: String,
|
||||
#[serde(default = "default_surface_raised_color")]
|
||||
pub(super) surface_raised: String,
|
||||
#[serde(default = "default_border_color")]
|
||||
pub(super) border: String,
|
||||
#[serde(default = "default_accent_color")]
|
||||
@@ -355,6 +422,7 @@ impl Default for ThemeColorsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
surface_base: default_surface_base_color(),
|
||||
surface_raised: default_surface_raised_color(),
|
||||
text_primary: default_text_primary_color(),
|
||||
border: default_border_color(),
|
||||
accent: default_accent_color(),
|
||||
@@ -363,13 +431,13 @@ impl Default for ThemeColorsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
pub(super) struct ThemeShadowConfig {
|
||||
#[serde(default)]
|
||||
pub(super) soft: SoftShadowConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
pub(super) struct SoftShadowConfig {
|
||||
#[serde(default = "default_shadow_offset_y")]
|
||||
pub(super) offset_y: f64,
|
||||
@@ -389,8 +457,11 @@ impl Default for SoftShadowConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct ThemeGeometryConfig {
|
||||
#[serde(default, rename = "control_gap")]
|
||||
_control_gap: Option<f64>,
|
||||
#[serde(default = "default_border_width")]
|
||||
pub(super) border_width: f64,
|
||||
#[serde(default = "default_focus_ring_width")]
|
||||
@@ -398,18 +469,16 @@ pub(super) struct ThemeGeometryConfig {
|
||||
#[serde(default = "default_window_radius")]
|
||||
pub(super) window_radius: f64,
|
||||
#[serde(default)]
|
||||
pub(super) top_bar_radius: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(super) shell_radius: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for ThemeGeometryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
_control_gap: None,
|
||||
border_width: default_border_width(),
|
||||
focus_ring_width: default_focus_ring_width(),
|
||||
window_radius: default_window_radius(),
|
||||
top_bar_radius: None,
|
||||
shell_radius: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{
|
||||
BindingIdentity, Config, ConfigError, Deserialize, Direction, HashMap, HashSet, InputSettings,
|
||||
Keycode, keysyms, xkb,
|
||||
};
|
||||
|
||||
mod index;
|
||||
mod modes;
|
||||
mod pointer;
|
||||
pub(crate) use index::BindingSet;
|
||||
pub(super) use modes::BindingModeConfig;
|
||||
pub(crate) use modes::BindingModes;
|
||||
pub(crate) use pointer::{PointerTrigger, ScrollDirection};
|
||||
|
||||
impl Config {
|
||||
pub fn bindings(&self, input: &InputSettings) -> Result<Vec<Binding>, ConfigError> {
|
||||
fn binding_commands(&self) -> HashMap<String, Vec<String>> {
|
||||
let mut commands = HashMap::from([
|
||||
("terminal".to_owned(), vec!["foot".to_owned()]),
|
||||
("screenshot".to_owned(), vec!["ferese-screenshot".to_owned()]),
|
||||
(
|
||||
"screenshot".to_owned(),
|
||||
vec!["ferese-screenshot".to_owned()],
|
||||
),
|
||||
(
|
||||
"screenshot-full".to_owned(),
|
||||
vec!["ferese-screenshot".to_owned(), "--full".to_owned()],
|
||||
),
|
||||
]);
|
||||
commands.extend(self.commands.clone());
|
||||
commands
|
||||
}
|
||||
|
||||
pub fn bindings(&self, input: &InputSettings) -> Result<Vec<Binding>, ConfigError> {
|
||||
let commands = self.binding_commands();
|
||||
validate_commands(&commands)?;
|
||||
|
||||
let keymap = physical_keymap(input)?;
|
||||
@@ -23,7 +44,10 @@ impl Config {
|
||||
for configured in &self.bindings {
|
||||
let identity = binding_identity(configured, &keymap)?;
|
||||
if !supplied.insert(identity.clone()) {
|
||||
return Err(ConfigError::Binding(format!("duplicate binding {:?}", configured.keys)));
|
||||
return Err(ConfigError::Binding(format!(
|
||||
"duplicate binding {:?}",
|
||||
configured.keys
|
||||
)));
|
||||
}
|
||||
|
||||
bindings.retain(|binding| binding_identity_for_runtime(binding) != identity);
|
||||
@@ -43,13 +67,19 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_commands(commands: &HashMap<String, Vec<String>>) -> Result<(), ConfigError> {
|
||||
pub(super) fn validate_commands(
|
||||
commands: &HashMap<String, Vec<String>>,
|
||||
) -> Result<(), ConfigError> {
|
||||
for (name, argv) in commands {
|
||||
if name.trim().is_empty() {
|
||||
return Err(ConfigError::Binding("command names cannot be empty".to_owned()));
|
||||
return Err(ConfigError::Binding(
|
||||
"command names cannot be empty".to_owned(),
|
||||
));
|
||||
}
|
||||
if argv.is_empty() || argv[0].is_empty() {
|
||||
return Err(ConfigError::Binding(format!("command {name:?} must contain a program")));
|
||||
return Err(ConfigError::Binding(format!(
|
||||
"command {name:?} must contain a program"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,14 +115,15 @@ pub(super) fn parse_binding(
|
||||
}
|
||||
|
||||
let identity = binding_identity(configured, keymap)?;
|
||||
let action = configured
|
||||
.action
|
||||
.as_deref()
|
||||
.ok_or_else(|| ConfigError::Binding(format!("binding {:?} has no action", configured.keys)))?;
|
||||
let action = configured.action.as_deref().ok_or_else(|| {
|
||||
ConfigError::Binding(format!("binding {:?} has no action", configured.keys))
|
||||
})?;
|
||||
let action = parse_action(action, configured.argument.as_deref(), commands)?;
|
||||
let trigger = match identity.match_mode {
|
||||
BindingMatch::Keysym => BindingTrigger::Keysym(identity.key),
|
||||
BindingMatch::Physical => BindingTrigger::Physical(Keycode::new(identity.key)),
|
||||
BindingMatch::Pointer => BindingTrigger::Pointer(PointerTrigger::from_key(identity.key)),
|
||||
BindingMatch::SuperTap => BindingTrigger::SuperTap,
|
||||
BindingMatch::Swipe => BindingTrigger::Swipe {
|
||||
fingers: identity.key / 4,
|
||||
direction: [
|
||||
@@ -104,6 +135,15 @@ pub(super) fn parse_binding(
|
||||
},
|
||||
};
|
||||
|
||||
if matches!(
|
||||
action,
|
||||
BindingAction::PointerMove | BindingAction::PointerResize
|
||||
) && !matches!(trigger, BindingTrigger::Pointer(PointerTrigger::Button(_)))
|
||||
{
|
||||
return Err(ConfigError::Binding(
|
||||
"pointer-move and pointer-resize require a mouse button".into(),
|
||||
));
|
||||
}
|
||||
Ok(Binding {
|
||||
modifiers: identity.modifiers,
|
||||
trigger,
|
||||
@@ -115,9 +155,38 @@ pub(super) fn binding_identity(
|
||||
configured: &BindingConfig,
|
||||
keymap: &xkb::Keymap,
|
||||
) -> Result<BindingIdentity, ConfigError> {
|
||||
if matches!(
|
||||
configured.keys.trim().to_ascii_lowercase().as_str(),
|
||||
"super" | "logo" | "mod4"
|
||||
) {
|
||||
if configured.match_mode != BindingMatch::Keysym {
|
||||
return Err(ConfigError::Binding(
|
||||
"Super tap cannot use physical key matching".into(),
|
||||
));
|
||||
}
|
||||
return Ok(BindingIdentity {
|
||||
modifiers: BindingModifiers::default(),
|
||||
match_mode: BindingMatch::SuperTap,
|
||||
key: 0,
|
||||
});
|
||||
}
|
||||
let (modifiers, key) = parse_chord(&configured.keys)?;
|
||||
if let Some(trigger) = PointerTrigger::parse(&key)? {
|
||||
if configured.match_mode != BindingMatch::Keysym {
|
||||
return Err(ConfigError::Binding(
|
||||
"mouse bindings cannot use physical key matching".into(),
|
||||
));
|
||||
}
|
||||
return Ok(BindingIdentity {
|
||||
modifiers,
|
||||
match_mode: BindingMatch::Pointer,
|
||||
key: trigger.key(),
|
||||
});
|
||||
}
|
||||
if let Some((fingers, direction)) = parse_swipe(&key) {
|
||||
if configured.match_mode == BindingMatch::Physical || modifiers != BindingModifiers::default() {
|
||||
if configured.match_mode == BindingMatch::Physical
|
||||
|| modifiers != BindingModifiers::default()
|
||||
{
|
||||
return Err(ConfigError::Binding(
|
||||
"swipes cannot use keyboard modifiers or physical key matching".into(),
|
||||
));
|
||||
@@ -139,7 +208,7 @@ pub(super) fn binding_identity(
|
||||
configured.keys
|
||||
))
|
||||
})?,
|
||||
BindingMatch::Swipe => {
|
||||
BindingMatch::Swipe | BindingMatch::Pointer | BindingMatch::SuperTap => {
|
||||
return Err(ConfigError::Binding(
|
||||
"gesture keys must use Swipe3Left/Right/Up/Down (3–5 fingers)".into(),
|
||||
));
|
||||
@@ -157,7 +226,11 @@ pub(super) fn binding_identity_for_runtime(binding: &Binding) -> BindingIdentity
|
||||
let (match_mode, key) = match binding.trigger {
|
||||
BindingTrigger::Keysym(key) => (BindingMatch::Keysym, key),
|
||||
BindingTrigger::Physical(key) => (BindingMatch::Physical, key.raw()),
|
||||
BindingTrigger::Swipe { fingers, direction } => (BindingMatch::Swipe, fingers * 4 + direction as u32),
|
||||
BindingTrigger::Pointer(trigger) => (BindingMatch::Pointer, trigger.key()),
|
||||
BindingTrigger::SuperTap => (BindingMatch::SuperTap, 0),
|
||||
BindingTrigger::Swipe { fingers, direction } => {
|
||||
(BindingMatch::Swipe, fingers * 4 + direction as u32)
|
||||
}
|
||||
};
|
||||
|
||||
BindingIdentity {
|
||||
@@ -185,7 +258,9 @@ pub(super) fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), Con
|
||||
};
|
||||
if let Some(slot) = slot {
|
||||
if *slot {
|
||||
return Err(ConfigError::Binding(format!("duplicate modifier in {chord:?}")));
|
||||
return Err(ConfigError::Binding(format!(
|
||||
"duplicate modifier in {chord:?}"
|
||||
)));
|
||||
}
|
||||
*slot = true;
|
||||
} else if key.replace(component.to_owned()).is_some() {
|
||||
@@ -195,7 +270,9 @@ pub(super) fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), Con
|
||||
}
|
||||
}
|
||||
|
||||
let key = key.ok_or_else(|| ConfigError::Binding(format!("key chord {chord:?} does not contain a key")))?;
|
||||
let key = key.ok_or_else(|| {
|
||||
ConfigError::Binding(format!("key chord {chord:?} does not contain a key"))
|
||||
})?;
|
||||
Ok((modifiers, key))
|
||||
}
|
||||
|
||||
@@ -251,17 +328,30 @@ pub(super) fn parse_action(
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
let required_argument =
|
||||
|| argument.ok_or_else(|| ConfigError::Binding(format!("action {action:?} requires an argument")));
|
||||
let required_argument = || {
|
||||
argument
|
||||
.ok_or_else(|| ConfigError::Binding(format!("action {action:?} requires an argument")))
|
||||
};
|
||||
|
||||
match action {
|
||||
"pointer-move" | "pointer-resize" => {
|
||||
no_argument()?;
|
||||
Ok(if action == "pointer-move" {
|
||||
BindingAction::PointerMove
|
||||
} else {
|
||||
BindingAction::PointerResize
|
||||
})
|
||||
}
|
||||
"binding-mode" => Ok(BindingAction::BindingMode(required_argument()?.to_owned())),
|
||||
"none" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::None)
|
||||
}
|
||||
"workspace-next" | "workspace-previous" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::SwitchRelativeWorkspace(action == "workspace-next"))
|
||||
Ok(BindingAction::SwitchRelativeWorkspace(
|
||||
action == "workspace-next",
|
||||
))
|
||||
}
|
||||
"workspace-back-and-forth" => {
|
||||
no_argument()?;
|
||||
@@ -271,6 +361,10 @@ pub(super) fn parse_action(
|
||||
no_argument()?;
|
||||
Ok(BindingAction::FocusLastWindow)
|
||||
}
|
||||
"focus-floating" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::FocusFloating)
|
||||
}
|
||||
"focus-mru-next" | "focus-mru-previous" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::FocusMru(action == "focus-mru-previous"))
|
||||
@@ -280,7 +374,7 @@ pub(super) fn parse_action(
|
||||
let argv = commands
|
||||
.get(command)
|
||||
.ok_or_else(|| ConfigError::Binding(format!("unknown command {command:?}")))?;
|
||||
Ok(BindingAction::Spawn(argv.clone()))
|
||||
Ok(BindingAction::Spawn(argv.clone().into()))
|
||||
}
|
||||
"close" => {
|
||||
no_argument()?;
|
||||
@@ -292,9 +386,15 @@ pub(super) fn parse_action(
|
||||
}
|
||||
"focus" => Ok(BindingAction::Focus(parse_direction(required_argument()?)?)),
|
||||
"move" => Ok(BindingAction::Move(parse_direction(required_argument()?)?)),
|
||||
"resize" => Ok(BindingAction::Resize(parse_direction(required_argument()?)?)),
|
||||
"workspace" => Ok(BindingAction::SwitchWorkspace(parse_workspace(required_argument()?)?)),
|
||||
"move-to-workspace" => Ok(BindingAction::MoveToWorkspace(parse_workspace(required_argument()?)?)),
|
||||
"resize" => Ok(BindingAction::Resize(
|
||||
parse_direction(required_argument()?)?,
|
||||
)),
|
||||
"workspace" => Ok(BindingAction::SwitchWorkspace(parse_workspace(
|
||||
required_argument()?,
|
||||
)?)),
|
||||
"move-to-workspace" => Ok(BindingAction::MoveToWorkspace(parse_workspace(
|
||||
required_argument()?,
|
||||
)?)),
|
||||
"toggle-maximized" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::ToggleMaximized)
|
||||
@@ -315,6 +415,25 @@ pub(super) fn parse_action(
|
||||
no_argument()?;
|
||||
Ok(BindingAction::CenterColumn)
|
||||
}
|
||||
"toggle-column-tabbed" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::ToggleColumnTabbed)
|
||||
}
|
||||
"join-tab" => Ok(BindingAction::JoinTab(parse_direction(
|
||||
required_argument()?
|
||||
)?)),
|
||||
"leave-tab" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::LeaveTab)
|
||||
}
|
||||
"next-tab" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::CycleTab(false))
|
||||
}
|
||||
"previous-tab" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::CycleTab(true))
|
||||
}
|
||||
"consume" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::Consume)
|
||||
@@ -331,6 +450,22 @@ pub(super) fn parse_action(
|
||||
no_argument()?;
|
||||
Ok(BindingAction::ToggleOverview)
|
||||
}
|
||||
"media-play-pause" | "media-next" | "media-previous" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::Media(match action {
|
||||
"media-next" => "next",
|
||||
"media-previous" => "previous",
|
||||
_ => "play-pause",
|
||||
}))
|
||||
}
|
||||
"toggle-display-mode" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::ToggleDisplayMode)
|
||||
}
|
||||
"toggle-launcher" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::ToggleLauncher)
|
||||
}
|
||||
"toggle-keybinding-guide" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::ToggleKeybindingGuide)
|
||||
@@ -345,19 +480,26 @@ pub(super) fn parse_direction(argument: &str) -> Result<Direction, ConfigError>
|
||||
"right" => Ok(Direction::Right),
|
||||
"up" => Ok(Direction::Up),
|
||||
"down" => Ok(Direction::Down),
|
||||
_ => Err(ConfigError::Binding(format!("invalid direction {argument:?}"))),
|
||||
_ => Err(ConfigError::Binding(format!(
|
||||
"invalid direction {argument:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_workspace(argument: &str) -> Result<u8, ConfigError> {
|
||||
match argument.parse() {
|
||||
Ok(workspace) if workspace > 0 => Ok(workspace),
|
||||
_ => Err(ConfigError::Binding(format!("invalid workspace {argument:?}"))),
|
||||
_ => Err(ConfigError::Binding(format!(
|
||||
"invalid workspace {argument:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn default_bindings() -> Vec<BindingConfig> {
|
||||
let mut bindings = vec![
|
||||
binding("Super+MouseLeft", "pointer-move", None),
|
||||
binding("Super+MouseRight", "pointer-resize", None),
|
||||
binding("Super+Space", "toggle-launcher", None),
|
||||
binding("Super+Enter", "spawn", Some("terminal")),
|
||||
binding("Super+Shift+S", "spawn", Some("screenshot")),
|
||||
binding("Print", "spawn", Some("screenshot-full")),
|
||||
@@ -373,8 +515,17 @@ pub(super) fn default_bindings() -> Vec<BindingConfig> {
|
||||
binding("Super+C", "center-column", None),
|
||||
binding("Super+[", "consume", None),
|
||||
binding("Super+]", "expel", None),
|
||||
binding("Super+Shift+T", "toggle-column-tabbed", None),
|
||||
binding("Super+Ctrl+[", "previous-tab", None),
|
||||
binding("Super+Ctrl+]", "next-tab", None),
|
||||
binding("Super+Shift+Space", "toggle-floating", None),
|
||||
binding("Super+V", "focus-floating", None),
|
||||
binding("Super+Tab", "toggle-overview", None),
|
||||
binding("XF86Display", "toggle-display-mode", None),
|
||||
binding("XF86AudioPlay", "media-play-pause", None),
|
||||
binding("XF86AudioPause", "media-play-pause", None),
|
||||
binding("XF86AudioNext", "media-next", None),
|
||||
binding("XF86AudioPrev", "media-previous", None),
|
||||
binding("Super+F1", "toggle-keybinding-guide", None),
|
||||
binding("Super+Escape", "workspace-back-and-forth", None),
|
||||
binding("Super+BackSpace", "focus-last-window", None),
|
||||
@@ -385,13 +536,25 @@ pub(super) fn default_bindings() -> Vec<BindingConfig> {
|
||||
|
||||
for (key, direction) in [("H", "left"), ("J", "down"), ("K", "up"), ("L", "right")] {
|
||||
bindings.push(binding("Super+".to_owned() + key, "focus", Some(direction)));
|
||||
bindings.push(binding("Super+Shift+".to_owned() + key, "move", Some(direction)));
|
||||
bindings.push(binding("Super+Ctrl+".to_owned() + key, "resize", Some(direction)));
|
||||
bindings.push(binding(
|
||||
"Super+Shift+".to_owned() + key,
|
||||
"move",
|
||||
Some(direction),
|
||||
));
|
||||
bindings.push(binding(
|
||||
"Super+Ctrl+".to_owned() + key,
|
||||
"resize",
|
||||
Some(direction),
|
||||
));
|
||||
}
|
||||
|
||||
for workspace in 1..=9 {
|
||||
let workspace = workspace.to_string();
|
||||
bindings.push(binding(format!("Super+{workspace}"), "workspace", Some(&workspace)));
|
||||
bindings.push(binding(
|
||||
format!("Super+{workspace}"),
|
||||
"workspace",
|
||||
Some(&workspace),
|
||||
));
|
||||
bindings.push(binding(
|
||||
format!("Super+Shift+{workspace}"),
|
||||
"move-to-workspace",
|
||||
@@ -402,7 +565,11 @@ pub(super) fn default_bindings() -> Vec<BindingConfig> {
|
||||
bindings
|
||||
}
|
||||
|
||||
pub(super) fn binding(keys: impl Into<String>, action: impl Into<String>, argument: Option<&str>) -> BindingConfig {
|
||||
pub(super) fn binding(
|
||||
keys: impl Into<String>,
|
||||
action: impl Into<String>,
|
||||
argument: Option<&str>,
|
||||
) -> BindingConfig {
|
||||
BindingConfig {
|
||||
keys: keys.into(),
|
||||
match_mode: BindingMatch::Keysym,
|
||||
@@ -423,6 +590,8 @@ pub struct Binding {
|
||||
pub(super) enum BindingTrigger {
|
||||
Keysym(u32),
|
||||
Physical(Keycode),
|
||||
Pointer(PointerTrigger),
|
||||
SuperTap,
|
||||
Swipe {
|
||||
fingers: u32,
|
||||
direction: crate::gestures::SwipeDirection,
|
||||
@@ -440,12 +609,16 @@ pub(super) struct BindingModifiers {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum BindingAction {
|
||||
None,
|
||||
Spawn(Vec<String>),
|
||||
BindingMode(String),
|
||||
Spawn(Arc<[String]>),
|
||||
Close,
|
||||
Exit,
|
||||
Focus(ferese_layout::Direction),
|
||||
FocusLastWindow,
|
||||
FocusFloating,
|
||||
FocusMru(bool),
|
||||
PointerMove,
|
||||
PointerResize,
|
||||
Move(ferese_layout::Direction),
|
||||
Resize(ferese_layout::Direction),
|
||||
SwitchWorkspace(u8),
|
||||
@@ -457,11 +630,18 @@ pub enum BindingAction {
|
||||
ToggleLayout,
|
||||
CycleColumnWidth,
|
||||
CenterColumn,
|
||||
ToggleColumnTabbed,
|
||||
JoinTab(ferese_layout::Direction),
|
||||
LeaveTab,
|
||||
CycleTab(bool),
|
||||
Consume,
|
||||
Expel,
|
||||
ToggleFloating,
|
||||
ToggleOverview,
|
||||
ToggleKeybindingGuide,
|
||||
ToggleLauncher,
|
||||
ToggleDisplayMode,
|
||||
Media(&'static str),
|
||||
}
|
||||
|
||||
impl Binding {
|
||||
@@ -487,7 +667,10 @@ impl Binding {
|
||||
let symbol = parse_keysym(&key)?;
|
||||
if (modifiers.ctrl
|
||||
&& modifiers.alt
|
||||
&& matches!(symbol, keysyms::KEY_Escape | keysyms::KEY_F1..=keysyms::KEY_F12))
|
||||
&& matches!(
|
||||
symbol,
|
||||
keysyms::KEY_Escape | keysyms::KEY_F1..=keysyms::KEY_F12
|
||||
))
|
||||
|| (keysyms::KEY_XF86Switch_VT_1..=keysyms::KEY_XF86Switch_VT_12).contains(&symbol)
|
||||
{
|
||||
return Err(ConfigError::Binding("Reserved system shortcut".into()));
|
||||
@@ -506,30 +689,53 @@ impl Binding {
|
||||
match (&self.trigger, &other.trigger) {
|
||||
(BindingTrigger::Keysym(left), BindingTrigger::Keysym(right)) => left == right,
|
||||
(BindingTrigger::Keysym(symbol), BindingTrigger::Physical(code))
|
||||
| (BindingTrigger::Physical(code), BindingTrigger::Keysym(symbol)) => (0..map.num_layouts_for_key(*code))
|
||||
.any(|layout| {
|
||||
| (BindingTrigger::Physical(code), BindingTrigger::Keysym(symbol)) => {
|
||||
(0..map.num_layouts_for_key(*code)).any(|layout| {
|
||||
map.key_get_syms_by_level(*code, layout, 0)
|
||||
.iter()
|
||||
.any(|key| key.raw() == *symbol)
|
||||
}),
|
||||
})
|
||||
}
|
||||
(BindingTrigger::Physical(left), BindingTrigger::Physical(right)) => left == right,
|
||||
(BindingTrigger::SuperTap, BindingTrigger::SuperTap) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches(&self, keycode: Keycode, keysyms: &[u32], logo: bool, ctrl: bool, alt: bool, shift: bool) -> bool {
|
||||
self.modifiers == BindingModifiers { logo, ctrl, alt, shift }
|
||||
pub fn matches(
|
||||
&self,
|
||||
keycode: Keycode,
|
||||
keysyms: &[u32],
|
||||
logo: bool,
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
shift: bool,
|
||||
) -> bool {
|
||||
self.modifiers
|
||||
== BindingModifiers {
|
||||
logo,
|
||||
ctrl,
|
||||
alt,
|
||||
shift,
|
||||
}
|
||||
&& match self.trigger {
|
||||
BindingTrigger::Keysym(expected) => keysyms.contains(&expected),
|
||||
BindingTrigger::Physical(expected) => expected == keycode,
|
||||
BindingTrigger::Swipe { .. } => false,
|
||||
BindingTrigger::Swipe { .. }
|
||||
| BindingTrigger::Pointer(_)
|
||||
| BindingTrigger::SuperTap => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn guide_entry(&self, keymap: Option<&xkb::Keymap>) -> Option<serde_json::Value> {
|
||||
let description = match &self.action {
|
||||
BindingAction::BindingMode(name) => format!("Switch to binding mode {name}"),
|
||||
BindingAction::None => return None,
|
||||
BindingAction::Spawn(argv) if argv.first().is_some_and(|program| program == "ferese-screenshot") => {
|
||||
BindingAction::Spawn(argv)
|
||||
if argv
|
||||
.first()
|
||||
.is_some_and(|program| program == "ferese-screenshot") =>
|
||||
{
|
||||
if argv.iter().any(|arg| arg == "--full") {
|
||||
"Capture whole screen"
|
||||
} else {
|
||||
@@ -548,29 +754,56 @@ impl Binding {
|
||||
BindingAction::Exit => "Log out (with confirmation)".into(),
|
||||
BindingAction::Focus(direction) => format!("Focus window {direction:?}"),
|
||||
BindingAction::FocusLastWindow => "Return to last focused window".into(),
|
||||
BindingAction::FocusFloating => {
|
||||
"Switch focus between tiled and floating windows".into()
|
||||
}
|
||||
BindingAction::FocusMru(reverse) => if *reverse {
|
||||
"Previous window in focus history"
|
||||
} else {
|
||||
"Next window in focus history"
|
||||
}
|
||||
.into(),
|
||||
BindingAction::PointerMove => "Drag window".into(),
|
||||
BindingAction::PointerResize => "Resize window with pointer".into(),
|
||||
BindingAction::Move(direction) => format!("Move window {direction:?}"),
|
||||
BindingAction::Resize(direction) => format!("Resize window {direction:?}"),
|
||||
BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"),
|
||||
BindingAction::WorkspaceBackAndForth => "Return to last workspace on this monitor".into(),
|
||||
BindingAction::MoveToWorkspace(index) => format!("Move window to workspace {index}"),
|
||||
BindingAction::SwitchRelativeWorkspace(next) => {
|
||||
if *next { "Next workspace" } else { "Previous workspace" }.into()
|
||||
BindingAction::WorkspaceBackAndForth => {
|
||||
"Return to last workspace on this monitor".into()
|
||||
}
|
||||
BindingAction::MoveToWorkspace(index) => format!("Move window to workspace {index}"),
|
||||
BindingAction::SwitchRelativeWorkspace(next) => if *next {
|
||||
"Next workspace"
|
||||
} else {
|
||||
"Previous workspace"
|
||||
}
|
||||
.into(),
|
||||
BindingAction::ToggleFullscreen => "Toggle fullscreen".into(),
|
||||
BindingAction::ToggleMaximized => "Toggle full-width window".into(),
|
||||
BindingAction::ToggleLayout => "Switch workspace layout".into(),
|
||||
BindingAction::CycleColumnWidth => "Cycle column width".into(),
|
||||
BindingAction::CenterColumn => "Center focused column".into(),
|
||||
BindingAction::Consume => "Join window into column".into(),
|
||||
BindingAction::Expel => "Move window out of column".into(),
|
||||
BindingAction::ToggleColumnTabbed => "Toggle stacked / tabbed column".into(),
|
||||
BindingAction::JoinTab(direction) => format!("Join tab group {direction:?}"),
|
||||
BindingAction::LeaveTab => "Leave tab group".into(),
|
||||
BindingAction::CycleTab(previous) => if *previous {
|
||||
"Previous tab"
|
||||
} else {
|
||||
"Next tab"
|
||||
}
|
||||
.into(),
|
||||
BindingAction::Consume => "Join window into column or tab group".into(),
|
||||
BindingAction::Expel => "Leave column or tab group".into(),
|
||||
BindingAction::ToggleFloating => "Toggle floating window".into(),
|
||||
BindingAction::Media(action) => match *action {
|
||||
"next" => "Next track",
|
||||
"previous" => "Previous track",
|
||||
_ => "Play or pause media",
|
||||
}
|
||||
.into(),
|
||||
BindingAction::ToggleOverview => "Open or close overview".into(),
|
||||
BindingAction::ToggleDisplayMode => "Open display mode chooser".into(),
|
||||
BindingAction::ToggleLauncher => "Open or close launcher".into(),
|
||||
BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(),
|
||||
};
|
||||
let mut parts = Vec::new();
|
||||
@@ -585,6 +818,8 @@ impl Binding {
|
||||
}
|
||||
}
|
||||
parts.push(match self.trigger {
|
||||
BindingTrigger::SuperTap => "Super (tap)".into(),
|
||||
BindingTrigger::Pointer(trigger) => trigger.label(),
|
||||
BindingTrigger::Keysym(symbol) => {
|
||||
let name = xkb::keysym_get_name(xkb::Keysym::new(symbol));
|
||||
match name.as_str() {
|
||||
@@ -610,21 +845,40 @@ impl Binding {
|
||||
matches!(self.trigger, BindingTrigger::Swipe { fingers, .. } if fingers == count)
|
||||
}
|
||||
|
||||
pub(crate) fn matches_swipe(&self, count: u32, target: crate::gestures::SwipeDirection) -> bool {
|
||||
pub(crate) fn matches_swipe(
|
||||
&self,
|
||||
count: u32,
|
||||
target: crate::gestures::SwipeDirection,
|
||||
) -> bool {
|
||||
matches!(self.trigger, BindingTrigger::Swipe { fingers, direction } if fingers == count && direction == target)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq)]
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Default,
|
||||
Deserialize,
|
||||
Eq,
|
||||
Hash,
|
||||
PartialEq,
|
||||
schemars::JsonSchema,
|
||||
serde::Serialize,
|
||||
)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(super) enum BindingMatch {
|
||||
#[default]
|
||||
Keysym,
|
||||
Physical,
|
||||
Swipe,
|
||||
#[serde(skip)]
|
||||
Pointer,
|
||||
#[serde(skip)]
|
||||
SuperTap,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
pub(super) struct BindingConfig {
|
||||
pub(super) keys: String,
|
||||
#[serde(default, rename = "match")]
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
#[cfg(test)]
|
||||
use crate::config::BindingAction;
|
||||
#[cfg(test)]
|
||||
use ferese_layout::Direction;
|
||||
#[cfg(test)]
|
||||
use smithay::input::keyboard::keysyms;
|
||||
|
||||
use super::{
|
||||
Binding, BindingIdentity, BindingMatch, BindingModifiers, HashMap, Keycode,
|
||||
binding_identity_for_runtime,
|
||||
};
|
||||
|
||||
/// Immutable bindings and their lookup index are replaced together on reload.
|
||||
/// The ordered slice remains available for gestures and the shortcut guide.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct BindingSet {
|
||||
ordered: Vec<Binding>,
|
||||
index: HashMap<BindingIdentity, usize>,
|
||||
}
|
||||
|
||||
impl From<Vec<Binding>> for BindingSet {
|
||||
fn from(ordered: Vec<Binding>) -> Self {
|
||||
let mut index = HashMap::with_capacity(ordered.len());
|
||||
|
||||
for (position, binding) in ordered.iter().enumerate() {
|
||||
let identity = binding_identity_for_runtime(binding);
|
||||
|
||||
if matches!(
|
||||
identity.match_mode,
|
||||
BindingMatch::Keysym
|
||||
| BindingMatch::Physical
|
||||
| BindingMatch::Pointer
|
||||
| BindingMatch::SuperTap
|
||||
) {
|
||||
index.entry(identity).or_insert(position);
|
||||
}
|
||||
}
|
||||
|
||||
Self { ordered, index }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for BindingSet {
|
||||
type Target = [Binding];
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.ordered
|
||||
}
|
||||
}
|
||||
|
||||
impl BindingSet {
|
||||
pub(crate) fn super_tap(&self) -> Option<&Binding> {
|
||||
self.index
|
||||
.get(&BindingIdentity {
|
||||
modifiers: BindingModifiers::default(),
|
||||
match_mode: BindingMatch::SuperTap,
|
||||
key: 0,
|
||||
})
|
||||
.map(|index| &self.ordered[*index])
|
||||
}
|
||||
|
||||
pub(crate) fn binding_for_pointer(
|
||||
&self,
|
||||
trigger: super::PointerTrigger,
|
||||
modifiers: smithay::input::keyboard::ModifiersState,
|
||||
) -> Option<&Binding> {
|
||||
let modifiers = BindingModifiers {
|
||||
logo: modifiers.logo,
|
||||
ctrl: modifiers.ctrl,
|
||||
alt: modifiers.alt,
|
||||
shift: modifiers.shift,
|
||||
};
|
||||
self.index
|
||||
.get(&BindingIdentity {
|
||||
modifiers,
|
||||
match_mode: BindingMatch::Pointer,
|
||||
key: trigger.key(),
|
||||
})
|
||||
.map(|index| &self.ordered[*index])
|
||||
}
|
||||
|
||||
pub(crate) fn binding_for_key(
|
||||
&self,
|
||||
keycode: Keycode,
|
||||
symbols: &[u32],
|
||||
logo: bool,
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
shift: bool,
|
||||
) -> Option<&Binding> {
|
||||
let modifiers = BindingModifiers {
|
||||
logo,
|
||||
ctrl,
|
||||
alt,
|
||||
shift,
|
||||
};
|
||||
let physical = self.index.get(&BindingIdentity {
|
||||
modifiers,
|
||||
match_mode: BindingMatch::Physical,
|
||||
key: keycode.raw(),
|
||||
});
|
||||
let symbolic = symbols.iter().filter_map(|&key| {
|
||||
self.index.get(&BindingIdentity {
|
||||
modifiers,
|
||||
match_mode: BindingMatch::Keysym,
|
||||
key,
|
||||
})
|
||||
});
|
||||
// Preserve list precedence across physical keys and all raw symbols;
|
||||
// neither hash iteration order nor symbol order determines the winner.
|
||||
physical
|
||||
.into_iter()
|
||||
.chain(symbolic)
|
||||
.min()
|
||||
.map(|&index| &self.ordered[index])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn action_for_key(
|
||||
&self,
|
||||
keycode: Keycode,
|
||||
symbols: &[u32],
|
||||
logo: bool,
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
shift: bool,
|
||||
) -> Option<&BindingAction> {
|
||||
self.binding_for_key(keycode, symbols, logo, ctrl, alt, shift)
|
||||
.map(|binding| &binding.action)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::config::bindings::BindingTrigger;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn binding_for(trigger: BindingTrigger, action: BindingAction) -> Binding {
|
||||
Binding {
|
||||
modifiers: BindingModifiers::default(),
|
||||
trigger,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_index_preserves_order_across_symbols_and_physical_keys() {
|
||||
let code = Keycode::new(38);
|
||||
let bindings = vec![
|
||||
binding_for(BindingTrigger::Keysym(keysyms::KEY_b), BindingAction::Close),
|
||||
binding_for(
|
||||
BindingTrigger::Physical(code),
|
||||
BindingAction::ToggleFloating,
|
||||
),
|
||||
binding_for(BindingTrigger::Keysym(keysyms::KEY_a), BindingAction::Exit),
|
||||
binding_for(BindingTrigger::Keysym(keysyms::KEY_b), BindingAction::None),
|
||||
];
|
||||
|
||||
for ordered in [bindings.clone(), bindings.into_iter().rev().collect()] {
|
||||
let index = BindingSet::from(ordered);
|
||||
|
||||
for symbols in [
|
||||
vec![keysyms::KEY_a, keysyms::KEY_b],
|
||||
vec![keysyms::KEY_b, keysyms::KEY_a],
|
||||
vec![],
|
||||
] {
|
||||
let expected = index
|
||||
.iter()
|
||||
.find(|binding| binding.matches(code, &symbols, false, false, false, false));
|
||||
assert_eq!(
|
||||
index.action_for_key(code, &symbols, false, false, false, false),
|
||||
expected.map(|binding| &binding.action)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_lookup_matches_linear_lookup_for_all_modifier_combinations() {
|
||||
let config = Config::default();
|
||||
let mut bindings = config.bindings(&config.input_settings().unwrap()).unwrap();
|
||||
bindings.extend([
|
||||
binding_for(
|
||||
BindingTrigger::Physical(Keycode::new(38)),
|
||||
BindingAction::None,
|
||||
),
|
||||
binding_for(BindingTrigger::Keysym(keysyms::KEY_a), BindingAction::Close),
|
||||
binding_for(
|
||||
BindingTrigger::Swipe {
|
||||
fingers: 3,
|
||||
direction: crate::gestures::SwipeDirection::Left,
|
||||
},
|
||||
BindingAction::Focus(Direction::Left),
|
||||
),
|
||||
]);
|
||||
let index = BindingSet::from(bindings);
|
||||
|
||||
for bits in 0..16 {
|
||||
let (logo, ctrl, alt, shift) =
|
||||
(bits & 1 != 0, bits & 2 != 0, bits & 4 != 0, bits & 8 != 0);
|
||||
|
||||
for code in 8..100 {
|
||||
for symbols in [
|
||||
vec![],
|
||||
vec![keysyms::KEY_Return],
|
||||
vec![keysyms::KEY_b, keysyms::KEY_a],
|
||||
vec![keysyms::KEY_a, keysyms::KEY_Tab],
|
||||
vec![keysyms::KEY_NoSymbol],
|
||||
] {
|
||||
let code = Keycode::new(code);
|
||||
let expected = index
|
||||
.iter()
|
||||
.find(|binding| binding.matches(code, &symbols, logo, ctrl, alt, shift));
|
||||
assert_eq!(
|
||||
index.action_for_key(code, &symbols, logo, ctrl, alt, shift),
|
||||
expected.map(|binding| &binding.action)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_bindings_discards_old_index_entries() {
|
||||
let code = Keycode::new(38);
|
||||
let mut bindings = BindingSet::from(vec![binding_for(
|
||||
BindingTrigger::Physical(code),
|
||||
BindingAction::Close,
|
||||
)]);
|
||||
assert_eq!(
|
||||
bindings.action_for_key(code, &[], false, false, false, false),
|
||||
Some(&BindingAction::Close)
|
||||
);
|
||||
bindings = BindingSet::from(vec![binding_for(
|
||||
BindingTrigger::Keysym(keysyms::KEY_b),
|
||||
BindingAction::None,
|
||||
)]);
|
||||
assert_eq!(
|
||||
bindings.action_for_key(code, &[], false, false, false, false),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
bindings.action_for_key(code, &[keysyms::KEY_b], false, false, false, false),
|
||||
Some(&BindingAction::None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloning_spawn_actions_shares_the_command_storage() {
|
||||
let action =
|
||||
BindingAction::Spawn(vec!["foot".into(), "--app-id".into(), "work".into()].into());
|
||||
let cloned = action.clone();
|
||||
let (BindingAction::Spawn(original), BindingAction::Spawn(cloned)) = (&action, cloned)
|
||||
else {
|
||||
unreachable!();
|
||||
};
|
||||
assert!(Arc::ptr_eq(original, &cloned));
|
||||
assert_eq!(original[0], "foot");
|
||||
assert_eq!(original.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use super::{
|
||||
Binding, BindingAction, BindingConfig, BindingMatch, BindingSet, Config, ConfigError,
|
||||
Deserialize, HashMap, HashSet, InputSettings, Keycode, binding_identity, keysyms,
|
||||
parse_binding, physical_keymap,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use ferese_layout::Direction;
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct BindingModeConfig {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
bindings: Vec<BindingConfig>,
|
||||
}
|
||||
|
||||
/// Named keyboard maps share the ordinary binding index. The default map and
|
||||
/// gestures remain owned by the compositor; a mode never mutates either.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct BindingModes {
|
||||
maps: HashMap<String, BindingSet>,
|
||||
active: Option<String>,
|
||||
}
|
||||
|
||||
impl BindingModes {
|
||||
pub(crate) fn active(&self) -> Option<&str> {
|
||||
self.active.as_deref()
|
||||
}
|
||||
|
||||
pub(crate) fn set(&mut self, name: &str) -> bool {
|
||||
let next = if name == "default" {
|
||||
None
|
||||
} else if self.maps.contains_key(name) {
|
||||
Some(name.to_owned())
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
if next == self.active {
|
||||
return false;
|
||||
}
|
||||
self.active = next;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn bindings<'a>(&'a self, default: &'a BindingSet) -> &'a BindingSet {
|
||||
self.active
|
||||
.as_ref()
|
||||
.and_then(|name| self.maps.get(name))
|
||||
.unwrap_or(default)
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub(crate) fn binding_modes(
|
||||
&self,
|
||||
input: &InputSettings,
|
||||
default: &[Binding],
|
||||
) -> Result<BindingModes, ConfigError> {
|
||||
let keymap = physical_keymap(input)?;
|
||||
let commands = self.binding_commands();
|
||||
let mut modes = BindingModes::default();
|
||||
for mode in &self.binding_modes {
|
||||
if mode.name.is_empty()
|
||||
|| mode.name.len() > 48
|
||||
|| mode.name == "default"
|
||||
|| !mode
|
||||
.name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
|| modes.maps.contains_key(&mode.name)
|
||||
{
|
||||
return Err(ConfigError::Binding(format!(
|
||||
"invalid or duplicate binding mode {:?}",
|
||||
mode.name
|
||||
)));
|
||||
}
|
||||
let mut identities = HashSet::new();
|
||||
let mut bindings = Vec::new();
|
||||
for configured in &mode.bindings {
|
||||
let identity = binding_identity(configured, &keymap)?;
|
||||
if configured.disabled
|
||||
|| identity.match_mode == BindingMatch::Swipe
|
||||
|| !identities.insert(identity.clone())
|
||||
|| (identity.match_mode == BindingMatch::Keysym
|
||||
&& identity.key == keysyms::KEY_Escape)
|
||||
|| (identity.match_mode == BindingMatch::Physical
|
||||
&& (0..keymap.num_layouts_for_key(Keycode::new(identity.key))).any(
|
||||
|layout| {
|
||||
(0..keymap.num_levels_for_key(Keycode::new(identity.key), layout))
|
||||
.any(|level| {
|
||||
keymap
|
||||
.key_get_syms_by_level(
|
||||
Keycode::new(identity.key),
|
||||
layout,
|
||||
level,
|
||||
)
|
||||
.iter()
|
||||
.any(|symbol| symbol.raw() == keysyms::KEY_Escape)
|
||||
})
|
||||
},
|
||||
))
|
||||
{
|
||||
return Err(ConfigError::Binding(format!(
|
||||
"invalid, duplicate or reserved mode binding {:?}",
|
||||
configured.keys
|
||||
)));
|
||||
}
|
||||
bindings.push(parse_binding(configured, &commands, &keymap)?);
|
||||
}
|
||||
modes.maps.insert(mode.name.clone(), bindings.into());
|
||||
}
|
||||
for binding in default
|
||||
.iter()
|
||||
.chain(modes.maps.values().flat_map(|map| map.iter()))
|
||||
{
|
||||
if let BindingAction::BindingMode(name) = &binding.action
|
||||
&& name != "default"
|
||||
&& !modes.maps.contains_key(name)
|
||||
{
|
||||
return Err(ConfigError::Binding(format!(
|
||||
"unknown binding mode {name:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(modes)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn modes_use_the_binding_index_without_inheriting_default_shortcuts() {
|
||||
let config = Config::parse_source(
|
||||
r#"
|
||||
binding "Super+r" binding-mode resize
|
||||
binding-mode resize {
|
||||
binding h resize left
|
||||
binding t binding-mode launch
|
||||
binding AD02 resize up match="physical"
|
||||
}
|
||||
binding-mode launch { binding Return spawn terminal; }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let runtime = config.runtime_config().unwrap();
|
||||
let default = BindingSet::from(runtime.bindings);
|
||||
let mut modes = runtime.binding_modes;
|
||||
assert!(modes.set("resize"));
|
||||
let selected = modes.bindings(&default);
|
||||
assert_eq!(
|
||||
selected.action_for_key(
|
||||
Keycode::new(43),
|
||||
&[keysyms::KEY_h],
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
),
|
||||
Some(&BindingAction::Resize(Direction::Left))
|
||||
);
|
||||
assert_eq!(
|
||||
selected.action_for_key(Keycode::new(25), &[], false, false, false, false),
|
||||
Some(&BindingAction::Resize(Direction::Up))
|
||||
);
|
||||
assert_eq!(
|
||||
selected.action_for_key(
|
||||
Keycode::new(43),
|
||||
&[keysyms::KEY_h],
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
selected.action_for_key(
|
||||
Keycode::new(36),
|
||||
&[keysyms::KEY_Return],
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
assert!(!modes.set("missing"));
|
||||
assert_eq!(modes.active(), Some("resize"));
|
||||
assert!(modes.set("default"));
|
||||
assert!(matches!(
|
||||
modes.bindings(&default).action_for_key(
|
||||
Keycode::new(36),
|
||||
&[keysyms::KEY_Return],
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
),
|
||||
Some(BindingAction::Spawn(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_modes_and_targets_are_rejected_before_runtime_publication() {
|
||||
for source in [
|
||||
"binding-mode default {}",
|
||||
"binding-mode \"\" {}",
|
||||
"binding-mode \"two words\" {}",
|
||||
"binding-mode resize {}\nbinding-mode resize {}",
|
||||
"binding x binding-mode missing",
|
||||
"binding-mode resize { binding x binding-mode missing; }",
|
||||
"binding-mode resize { binding x close; binding x none; }",
|
||||
"binding-mode resize { binding Escape none; }",
|
||||
"binding-mode resize { binding Super+Escape none; }",
|
||||
"binding-mode resize { binding ESC none match=\"physical\"; }",
|
||||
"input { xkb-options \"caps:escape\"; }\nbinding-mode resize { binding CAPS none match=\"physical\"; }",
|
||||
"binding-mode resize { binding Swipe3Left none; }",
|
||||
"binding-mode resize { binding x disabled=#true; }",
|
||||
"binding-mode resize fallback=#true {}",
|
||||
] {
|
||||
assert!(
|
||||
Config::parse_source(source)
|
||||
.and_then(|config| config.runtime_config())
|
||||
.is_err(),
|
||||
"{source}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use super::ConfigError;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u32)]
|
||||
pub(crate) enum ScrollDirection {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PointerTrigger {
|
||||
Button(u32),
|
||||
Wheel(ScrollDirection),
|
||||
Touchpad(ScrollDirection),
|
||||
}
|
||||
|
||||
impl PointerTrigger {
|
||||
pub(super) fn parse(name: &str) -> Result<Option<Self>, ConfigError> {
|
||||
let name = name.to_ascii_lowercase();
|
||||
let button = match name.as_str() {
|
||||
"mouseleft" => Some(272),
|
||||
"mouseright" => Some(273),
|
||||
"mousemiddle" => Some(274),
|
||||
"mouseside" | "mouseback" => Some(275),
|
||||
"mouseextra" | "mouseforward" => Some(276),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(button) = button {
|
||||
return Ok(Some(Self::Button(button)));
|
||||
}
|
||||
if let Some(code) = name.strip_prefix("mouse:") {
|
||||
let code = code
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|code| (0x110..=0x11f).contains(code))
|
||||
.ok_or_else(|| {
|
||||
ConfigError::Binding(
|
||||
"mouse button must be a Linux mouse button code (272–287)".into(),
|
||||
)
|
||||
})?;
|
||||
return Ok(Some(Self::Button(code)));
|
||||
}
|
||||
for (prefix, touchpad) in [
|
||||
("wheelscroll", false),
|
||||
("wheel", false),
|
||||
("touchpadscroll", true),
|
||||
] {
|
||||
if let Some(direction) = name.strip_prefix(prefix) {
|
||||
let direction = match direction {
|
||||
"left" => ScrollDirection::Left,
|
||||
"right" => ScrollDirection::Right,
|
||||
"up" => ScrollDirection::Up,
|
||||
"down" => ScrollDirection::Down,
|
||||
_ => {
|
||||
return Err(ConfigError::Binding(format!(
|
||||
"invalid scroll direction {direction:?}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
return Ok(Some(if touchpad {
|
||||
Self::Touchpad(direction)
|
||||
} else {
|
||||
Self::Wheel(direction)
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(super) fn key(self) -> u32 {
|
||||
match self {
|
||||
Self::Button(code) => code,
|
||||
Self::Wheel(direction) => 1024 + direction as u32,
|
||||
Self::Touchpad(direction) => 1028 + direction as u32,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn from_key(key: u32) -> Self {
|
||||
let directions = [
|
||||
ScrollDirection::Left,
|
||||
ScrollDirection::Right,
|
||||
ScrollDirection::Up,
|
||||
ScrollDirection::Down,
|
||||
];
|
||||
match key {
|
||||
1024..=1027 => Self::Wheel(directions[(key - 1024) as usize]),
|
||||
1028..=1031 => Self::Touchpad(directions[(key - 1028) as usize]),
|
||||
code => Self::Button(code),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn label(self) -> String {
|
||||
match self {
|
||||
Self::Button(272) => "Mouse Left".into(),
|
||||
Self::Button(273) => "Mouse Right".into(),
|
||||
Self::Button(274) => "Mouse Middle".into(),
|
||||
Self::Button(275) => "Mouse Back".into(),
|
||||
Self::Button(276) => "Mouse Forward".into(),
|
||||
Self::Button(code) => format!("Mouse {code}"),
|
||||
Self::Wheel(direction) => format!("Wheel {direction:?}"),
|
||||
Self::Touchpad(direction) => format!("Touchpad scroll {direction:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{BindingAction, Config};
|
||||
|
||||
#[test]
|
||||
fn pointer_names_round_trip_without_colliding_with_keyboard_keys() {
|
||||
for name in [
|
||||
"MouseLeft",
|
||||
"MouseBack",
|
||||
"MouseForward",
|
||||
"mouse:287",
|
||||
"WheelUp",
|
||||
"WheelScrollLeft",
|
||||
"TouchpadScrollDown",
|
||||
] {
|
||||
let trigger = PointerTrigger::parse(name).unwrap().unwrap();
|
||||
assert_eq!(PointerTrigger::from_key(trigger.key()), trigger);
|
||||
}
|
||||
for name in ["mouse:12", "mouse:288", "mouse:foo", "WheelSideways"] {
|
||||
assert!(PointerTrigger::parse(name).is_err(), "{name}");
|
||||
}
|
||||
assert_eq!(PointerTrigger::parse("Return").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_defaults_can_be_overridden_and_disabled() {
|
||||
let config = Config::parse_source(
|
||||
r#"
|
||||
binding "Super+MouseLeft" action="close"
|
||||
binding "Super+MouseRight" disabled=#true
|
||||
binding "Alt+MouseMiddle" action="pointer-resize"
|
||||
binding "Super+WheelDown" action="workspace-next"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let bindings = config.bindings(&config.input_settings().unwrap()).unwrap();
|
||||
assert!(
|
||||
!bindings
|
||||
.iter()
|
||||
.any(|binding| binding.action == BindingAction::PointerMove)
|
||||
);
|
||||
assert_eq!(
|
||||
bindings
|
||||
.iter()
|
||||
.filter(|binding| binding.action == BindingAction::PointerResize)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let config =
|
||||
Config::parse_source(r#"binding "Super+WheelUp" action="pointer-move""#).unwrap();
|
||||
assert!(config.bindings(&config.input_settings().unwrap()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Config, ConfigError, Deserialize, SpringConfig, default_repeat_delay, default_repeat_rate,
|
||||
enabled_by_default,
|
||||
};
|
||||
|
||||
impl Config {
|
||||
pub fn input_settings(&self) -> Result<InputSettings, ConfigError> {
|
||||
@@ -27,7 +30,44 @@ impl Config {
|
||||
value: self.input.touchpad.swipe_threshold.to_string(),
|
||||
});
|
||||
}
|
||||
let mouse = self.input.mouse;
|
||||
for (field, value, valid) in [
|
||||
(
|
||||
"touchpad.accel-speed",
|
||||
self.input.touchpad.accel_speed,
|
||||
(-1.0..=1.0).contains(&self.input.touchpad.accel_speed),
|
||||
),
|
||||
(
|
||||
"mouse.accel-speed",
|
||||
mouse.accel_speed,
|
||||
(-1.0..=1.0).contains(&mouse.accel_speed),
|
||||
),
|
||||
(
|
||||
"mouse.scroll-factor",
|
||||
mouse.scroll_factor,
|
||||
(0.01..=100.0).contains(&mouse.scroll_factor),
|
||||
),
|
||||
(
|
||||
"mouse.drag-threshold",
|
||||
mouse.drag_threshold,
|
||||
(0.0..=100.0).contains(&mouse.drag_threshold),
|
||||
),
|
||||
] {
|
||||
if !value.is_finite() || !valid {
|
||||
return Err(ConfigError::InputValue {
|
||||
field,
|
||||
value: value.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if mouse.scroll_cooldown_ms > 5000 {
|
||||
return Err(ConfigError::InputValue {
|
||||
field: "mouse.scroll-cooldown-ms",
|
||||
value: mouse.scroll_cooldown_ms.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(InputSettings {
|
||||
mouse,
|
||||
focus_follows_mouse: self.input.focus_follows_mouse,
|
||||
xkb_layout: self.input.xkb_layout.clone(),
|
||||
xkb_variant: self.input.xkb_variant.clone(),
|
||||
@@ -36,6 +76,8 @@ impl Config {
|
||||
repeat_delay_ms: self.input.repeat_delay_ms,
|
||||
touchpad: TouchpadSettings {
|
||||
tap: self.input.touchpad.tap,
|
||||
accel_speed: self.input.touchpad.accel_speed,
|
||||
disable_while_mouse_connected: self.input.touchpad.disable_while_mouse_connected,
|
||||
natural_scroll: self.input.touchpad.natural_scroll,
|
||||
disable_while_typing: self.input.touchpad.disable_while_typing,
|
||||
swipe_threshold: self.input.touchpad.swipe_threshold,
|
||||
@@ -44,55 +86,28 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn animations_enabled(&self) -> bool {
|
||||
self.animations.enabled && !self.animations.reduced_motion
|
||||
self.animations.motion_enabled()
|
||||
}
|
||||
|
||||
pub fn animation_speed(&self) -> Result<f64, ConfigError> {
|
||||
positive_animation_value(self.animations.speed, "speed")
|
||||
self.animations
|
||||
.validate_speed()
|
||||
.map_err(|_| ConfigError::AnimationValue {
|
||||
field: "speed",
|
||||
value: self.animations.speed,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn spring_config(&self) -> Result<SpringConfig, ConfigError> {
|
||||
let mass = positive_animation_value(self.animations.spring.mass, "spring.mass")?;
|
||||
let stiffness = positive_animation_value(self.animations.spring.stiffness, "spring.stiffness")?;
|
||||
let damping = self.animations.spring.damping;
|
||||
if !damping.is_finite() || damping < 0.0 {
|
||||
return Err(ConfigError::AnimationValue {
|
||||
field: "spring.damping",
|
||||
value: damping,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SpringConfig {
|
||||
mass,
|
||||
stiffness,
|
||||
damping,
|
||||
..SpringConfig::default()
|
||||
})
|
||||
self.animations
|
||||
.spring_config()
|
||||
.map_err(ConfigError::Binding)
|
||||
}
|
||||
|
||||
pub fn viewport_spring_config(&self) -> Result<SpringConfig, ConfigError> {
|
||||
let mass = positive_animation_value(self.animations.viewport_spring.mass, "viewport_spring.mass")?;
|
||||
let stiffness =
|
||||
positive_animation_value(self.animations.viewport_spring.stiffness, "viewport_spring.stiffness")?;
|
||||
let damping_ratio = positive_animation_value(
|
||||
self.animations.viewport_spring.damping_ratio,
|
||||
"viewport_spring.damping_ratio",
|
||||
)?;
|
||||
|
||||
Ok(SpringConfig {
|
||||
mass,
|
||||
stiffness,
|
||||
damping: 2.0 * damping_ratio * (stiffness * mass).sqrt(),
|
||||
..SpringConfig::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn positive_animation_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
|
||||
if value.is_finite() && value > 0.0 {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(ConfigError::AnimationValue { field, value })
|
||||
self.animations
|
||||
.viewport_config()
|
||||
.map_err(ConfigError::Binding)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,11 +124,14 @@ pub struct InputSettings {
|
||||
pub repeat_rate: i32,
|
||||
pub repeat_delay_ms: i32,
|
||||
pub touchpad: TouchpadSettings,
|
||||
pub mouse: MouseSettings,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TouchpadSettings {
|
||||
pub tap: bool,
|
||||
pub accel_speed: f64,
|
||||
pub disable_while_mouse_connected: bool,
|
||||
pub natural_scroll: bool,
|
||||
pub disable_while_typing: bool,
|
||||
pub swipe_threshold: u16,
|
||||
@@ -123,7 +141,7 @@ fn default_swipe_threshold() -> u16 {
|
||||
80
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
pub(super) struct InputConfig {
|
||||
#[serde(default)]
|
||||
pub(super) focus_follows_mouse: bool,
|
||||
@@ -139,6 +157,8 @@ pub(super) struct InputConfig {
|
||||
pub(super) repeat_delay_ms: i32,
|
||||
#[serde(default)]
|
||||
pub(super) touchpad: TouchpadConfig,
|
||||
#[serde(default)]
|
||||
pub(super) mouse: MouseSettings,
|
||||
}
|
||||
|
||||
impl Default for InputConfig {
|
||||
@@ -151,14 +171,19 @@ impl Default for InputConfig {
|
||||
repeat_rate: default_repeat_rate(),
|
||||
repeat_delay_ms: default_repeat_delay(),
|
||||
touchpad: TouchpadConfig::default(),
|
||||
mouse: MouseSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
pub(super) struct TouchpadConfig {
|
||||
#[serde(default)]
|
||||
pub(super) accel_speed: f64,
|
||||
#[serde(default = "enabled_by_default")]
|
||||
pub(super) tap: bool,
|
||||
#[serde(default)]
|
||||
pub(super) disable_while_mouse_connected: bool,
|
||||
#[serde(default = "enabled_by_default")]
|
||||
pub(super) natural_scroll: bool,
|
||||
#[serde(default = "enabled_by_default")]
|
||||
@@ -171,6 +196,8 @@ impl Default for TouchpadConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tap: true,
|
||||
accel_speed: 0.0,
|
||||
disable_while_mouse_connected: false,
|
||||
natural_scroll: true,
|
||||
disable_while_typing: true,
|
||||
swipe_threshold: default_swipe_threshold(),
|
||||
@@ -178,68 +205,91 @@ impl Default for TouchpadConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct AnimationsConfig {
|
||||
#[serde(default = "enabled_by_default")]
|
||||
pub(super) enabled: bool,
|
||||
#[serde(default)]
|
||||
pub(super) reduced_motion: bool,
|
||||
#[serde(default = "default_animation_speed")]
|
||||
pub(super) speed: f64,
|
||||
#[serde(default)]
|
||||
pub(super) spring: SpringSettings,
|
||||
#[serde(default)]
|
||||
pub(super) viewport_spring: ViewportSpringSettings,
|
||||
}
|
||||
pub(super) type AnimationsConfig = ferese_animation::MotionSettings;
|
||||
|
||||
impl Default for AnimationsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
reduced_motion: false,
|
||||
speed: 1.0,
|
||||
spring: SpringSettings::default(),
|
||||
viewport_spring: ViewportSpringSettings::default(),
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mouse_settings_are_separate_from_touchpad_and_validate_ranges() {
|
||||
let defaults = Config::parse_source("").unwrap().input_settings().unwrap();
|
||||
assert!(!defaults.mouse.natural_scroll);
|
||||
assert!(defaults.touchpad.natural_scroll);
|
||||
assert!(!defaults.touchpad.disable_while_mouse_connected);
|
||||
let config = Config::parse_source(r#"
|
||||
input {
|
||||
mouse { accel-speed -0.4; accel-profile "flat"; scroll-factor 0.5; left-handed #true; }
|
||||
touchpad { disable-while-mouse-connected #true; accel-speed 0.35; }
|
||||
}
|
||||
"#).unwrap().input_settings().unwrap();
|
||||
assert!(config.touchpad.disable_while_mouse_connected);
|
||||
assert_eq!(config.touchpad.accel_speed, 0.35);
|
||||
assert_eq!(config.mouse.accel_profile, Some(AccelerationProfile::Flat));
|
||||
assert_eq!(config.mouse.accel_speed, -0.4);
|
||||
assert_eq!(config.mouse.scroll_factor, 0.5);
|
||||
assert!(config.mouse.left_handed);
|
||||
for speed in [-1.01, 1.01] {
|
||||
let config =
|
||||
Config::parse_source(&format!("input {{ touchpad {{ accel-speed {speed}; }} }}"))
|
||||
.unwrap();
|
||||
assert!(config.input_settings().is_err());
|
||||
}
|
||||
for (key, value) in [
|
||||
("accel-speed", "1.1"),
|
||||
("scroll-factor", "0"),
|
||||
("drag-threshold", "-1"),
|
||||
("scroll-cooldown-ms", "5001"),
|
||||
] {
|
||||
let config =
|
||||
Config::parse_source(&format!("input {{ mouse {{ {key} {value}; }} }}")).unwrap();
|
||||
assert!(config.input_settings().is_err(), "{key}={value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compositor_speed_uses_shared_motion_bounds() {
|
||||
for speed in [1e-100, 0.009, 101.0, 1e100] {
|
||||
let config =
|
||||
Config::parse_source(&format!("animations {{ speed {speed:e}; }}")).unwrap();
|
||||
assert!(config.animation_speed().is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct ViewportSpringSettings {
|
||||
#[serde(default = "default_viewport_mass")]
|
||||
pub(super) mass: f64,
|
||||
#[serde(default = "default_viewport_stiffness")]
|
||||
pub(super) stiffness: f64,
|
||||
#[serde(default = "default_viewport_damping_ratio")]
|
||||
pub(super) damping_ratio: f64,
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
|
||||
)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AccelerationProfile {
|
||||
Adaptive,
|
||||
Flat,
|
||||
}
|
||||
|
||||
impl Default for ViewportSpringSettings {
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, schemars::JsonSchema, serde::Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct MouseSettings {
|
||||
pub natural_scroll: bool,
|
||||
pub accel_speed: f64,
|
||||
pub accel_profile: Option<AccelerationProfile>,
|
||||
pub left_handed: bool,
|
||||
pub middle_emulation: bool,
|
||||
pub scroll_factor: f64,
|
||||
pub drag_threshold: f64,
|
||||
pub scroll_cooldown_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for MouseSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mass: default_viewport_mass(),
|
||||
stiffness: default_viewport_stiffness(),
|
||||
damping_ratio: default_viewport_damping_ratio(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct SpringSettings {
|
||||
#[serde(default = "default_spring_mass")]
|
||||
pub(super) mass: f64,
|
||||
#[serde(default = "default_spring_stiffness")]
|
||||
pub(super) stiffness: f64,
|
||||
#[serde(default = "default_spring_damping")]
|
||||
pub(super) damping: f64,
|
||||
}
|
||||
|
||||
impl Default for SpringSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mass: default_spring_mass(),
|
||||
stiffness: default_spring_stiffness(),
|
||||
damping: default_spring_damping(),
|
||||
natural_scroll: false,
|
||||
accel_speed: 0.0,
|
||||
accel_profile: None,
|
||||
left_handed: false,
|
||||
middle_emulation: false,
|
||||
scroll_factor: 1.0,
|
||||
drag_threshold: 4.0,
|
||||
scroll_cooldown_ms: 150,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
ColumnDisplayValue, ColumnWidth, ColumnWidthValue, Config, ConfigError, FocusStrategyValue,
|
||||
GapConfig, LayoutMode, LayoutModeValue, ViewportFocusStrategy,
|
||||
};
|
||||
|
||||
impl Config {
|
||||
pub fn default_column_display(&self) -> ferese_layout::ColumnDisplay {
|
||||
match self
|
||||
.scrolling
|
||||
.default_column_display
|
||||
.unwrap_or(ColumnDisplayValue::Stacked)
|
||||
{
|
||||
ColumnDisplayValue::Stacked => ferese_layout::ColumnDisplay::Stacked,
|
||||
ColumnDisplayValue::Tabbed => ferese_layout::ColumnDisplay::Tabbed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn layout_mode(&self) -> LayoutMode {
|
||||
match self.layout.mode.unwrap_or(LayoutModeValue::Scrolling) {
|
||||
LayoutModeValue::Scrolling => LayoutMode::Scrolling,
|
||||
@@ -47,7 +61,11 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn scrolling_focus_strategy(&self) -> ViewportFocusStrategy {
|
||||
match self.scrolling.focus_strategy.unwrap_or(FocusStrategyValue::Minimal) {
|
||||
match self
|
||||
.scrolling
|
||||
.focus_strategy
|
||||
.unwrap_or(FocusStrategyValue::Minimal)
|
||||
{
|
||||
FocusStrategyValue::Minimal => ViewportFocusStrategy::Minimal,
|
||||
FocusStrategyValue::CenterOnFocus => ViewportFocusStrategy::Center,
|
||||
FocusStrategyValue::Paged => ViewportFocusStrategy::Paged,
|
||||
@@ -55,7 +73,10 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn nonnegative_layout_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
|
||||
pub(super) fn nonnegative_layout_value(
|
||||
value: f64,
|
||||
field: &'static str,
|
||||
) -> Result<f64, ConfigError> {
|
||||
if value.is_finite() && value >= 0.0 {
|
||||
Ok(value)
|
||||
} else {
|
||||
@@ -63,10 +84,17 @@ pub(super) fn nonnegative_layout_value(value: f64, field: &'static str) -> Resul
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_column_width(value: &ColumnWidthValue, field: &'static str) -> Result<ColumnWidth, ConfigError> {
|
||||
pub(super) fn parse_column_width(
|
||||
value: &ColumnWidthValue,
|
||||
field: &'static str,
|
||||
) -> Result<ColumnWidth, ConfigError> {
|
||||
match value {
|
||||
ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => Ok(ColumnWidth::Proportion(*value)),
|
||||
ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => Ok(ColumnWidth::Full),
|
||||
ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => {
|
||||
Ok(ColumnWidth::Proportion(*value))
|
||||
}
|
||||
ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => {
|
||||
Ok(ColumnWidth::Full)
|
||||
}
|
||||
value => Err(ConfigError::ColumnWidth {
|
||||
field,
|
||||
value: match value {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
use std::io::{self, Read};
|
||||
use std::path::Path;
|
||||
|
||||
use ferese_config::Document;
|
||||
use ferese_config::theme::Candidate;
|
||||
|
||||
use super::Config;
|
||||
use crate::RuntimeConfig;
|
||||
|
||||
pub(crate) fn read_source(path: &Path) -> Result<String, String> {
|
||||
let mut source = String::new();
|
||||
std::fs::File::open(path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?
|
||||
.take(60 * 1024 + 1)
|
||||
.read_to_string(&mut source)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if source.len() > 60 * 1024 {
|
||||
return Err(format!("{} exceeds 60 KiB", path.display()));
|
||||
}
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(
|
||||
source: &str,
|
||||
directory: &Path,
|
||||
) -> Result<(Config, RuntimeConfig, Candidate), String> {
|
||||
let document = Document::parse(source).map_err(|e| e.to_string())?;
|
||||
prepare_document(&document, directory)
|
||||
}
|
||||
|
||||
pub(crate) struct StartupConfig {
|
||||
pub(crate) source: Option<String>,
|
||||
pub(crate) runtime: RuntimeConfig,
|
||||
pub(crate) theme: Candidate,
|
||||
pub(crate) error: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn startup(path: Option<&Path>) -> Result<StartupConfig, String> {
|
||||
let directory = path.and_then(Path::parent).unwrap_or(Path::new("."));
|
||||
let source = path.map_or(Ok(None), |path| match std::fs::symlink_metadata(path) {
|
||||
Ok(_) => read_source(path).map(Some),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(format!("{}: {error}", path.display())),
|
||||
});
|
||||
let prepared = source.and_then(|source| {
|
||||
let (_, runtime, theme) = prepare(source.as_deref().unwrap_or(""), directory)?;
|
||||
Ok(StartupConfig {
|
||||
source,
|
||||
runtime,
|
||||
theme,
|
||||
error: None,
|
||||
})
|
||||
});
|
||||
match prepared {
|
||||
Ok(config) => Ok(config),
|
||||
Err(error) => {
|
||||
let (_, runtime, theme) = prepare("", directory)?;
|
||||
Ok(StartupConfig {
|
||||
source: Some(String::new()),
|
||||
runtime,
|
||||
theme,
|
||||
error: Some(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_document(
|
||||
document: &Document,
|
||||
directory: &Path,
|
||||
) -> Result<(Config, RuntimeConfig, Candidate), String> {
|
||||
let candidate = crate::theme::resolve_candidate(document, directory)?;
|
||||
let config: Config =
|
||||
serde_json::from_value(document.with_theme(&candidate.theme).value().clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut runtime = config.runtime_config().map_err(|e| e.to_string())?;
|
||||
runtime.theme_settings =
|
||||
Config::resolved_theme_settings(&candidate.theme).map_err(|e| e.to_string())?;
|
||||
Ok((config, runtime, candidate))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn invalid_startup_uses_defaults_and_keeps_the_authored_file() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("config.kdl");
|
||||
let (_, defaults, default_theme) = prepare("", directory.path()).unwrap();
|
||||
for source in [
|
||||
"panel main { surface solid; group-surface none; }",
|
||||
"panel main { geometry { height 1; }; }",
|
||||
"animations { speed 0; }",
|
||||
"theme { file \"missing-theme.kdl\"; }",
|
||||
"panel main {",
|
||||
] {
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let config = startup(Some(&path)).unwrap();
|
||||
assert!(config.error.is_some(), "{source}");
|
||||
assert_eq!(config.source.as_deref(), Some(""));
|
||||
assert_eq!(config.runtime.panel_style, defaults.panel_style);
|
||||
assert_eq!(config.runtime.layout_mode, defaults.layout_mode);
|
||||
assert_eq!(config.theme.theme, default_theme.theme);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), source);
|
||||
assert!(
|
||||
prepare(source, directory.path()).is_err(),
|
||||
"strict validation must still reject {source}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_distinguishes_missing_valid_and_unreadable_files() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("config.kdl");
|
||||
let missing = startup(Some(&path)).unwrap();
|
||||
assert!(missing.error.is_none());
|
||||
assert!(missing.source.is_none());
|
||||
assert!(!path.exists());
|
||||
|
||||
let source = "// keep me\npanel main { style floating; geometry { height 42; }; }";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let valid = startup(Some(&path)).unwrap();
|
||||
assert!(valid.error.is_none());
|
||||
assert_eq!(valid.source.as_deref(), Some(source));
|
||||
assert_eq!(
|
||||
valid.runtime.panel_style,
|
||||
ferese_panel::PanelStyle::Floating
|
||||
);
|
||||
assert_eq!(
|
||||
Document::parse(valid.source.as_deref().unwrap())
|
||||
.unwrap()
|
||||
.get("panels.0.geometry.height"),
|
||||
Some(&serde_json::json!(42))
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), source);
|
||||
|
||||
std::fs::write(&path, [0xff]).unwrap();
|
||||
assert!(startup(Some(&path)).unwrap().error.is_some());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), [0xff]);
|
||||
assert!(startup(Some(directory.path())).unwrap().error.is_some());
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::os::unix::fs::symlink(directory.path().join("missing"), &path).unwrap();
|
||||
assert!(startup(Some(&path)).unwrap().error.is_some());
|
||||
assert!(path.is_symlink());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Config, ConfigError, Deserialize, HashSet, default_true};
|
||||
|
||||
impl Config {
|
||||
pub fn output_profiles(&self) -> Result<Vec<OutputProfile>, ConfigError> {
|
||||
@@ -9,13 +9,19 @@ impl Config {
|
||||
.map(|profile| {
|
||||
let name = profile.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(ConfigError::OutputProfile("profile names cannot be empty".to_owned()));
|
||||
return Err(ConfigError::OutputProfile(
|
||||
"profile names cannot be empty".to_owned(),
|
||||
));
|
||||
}
|
||||
if !names.insert(name.to_owned()) {
|
||||
return Err(ConfigError::OutputProfile(format!("duplicate profile name {name:?}")));
|
||||
return Err(ConfigError::OutputProfile(format!(
|
||||
"duplicate profile name {name:?}"
|
||||
)));
|
||||
}
|
||||
if profile.outputs.is_empty() {
|
||||
return Err(ConfigError::OutputProfile(format!("profile {name:?} has no outputs")));
|
||||
return Err(ConfigError::OutputProfile(format!(
|
||||
"profile {name:?} has no outputs"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut matchers = HashSet::new();
|
||||
@@ -43,6 +49,7 @@ impl Config {
|
||||
|
||||
Ok(OutputSettings {
|
||||
auto_refresh: output.auto_refresh,
|
||||
required: output.required,
|
||||
matcher: matcher.to_owned(),
|
||||
enabled: output.enabled,
|
||||
mode: output
|
||||
@@ -58,9 +65,41 @@ impl Config {
|
||||
})
|
||||
.collect::<Result<Vec<_>, ConfigError>>()?;
|
||||
|
||||
if !outputs.iter().any(|output| output.enabled) {
|
||||
return Err(ConfigError::OutputProfile(format!(
|
||||
"profile {name:?} disables every output"
|
||||
)));
|
||||
}
|
||||
if profile.layout != OutputLayout::Mirror && profile.mirror_source.is_some() {
|
||||
return Err(ConfigError::OutputProfile(format!(
|
||||
"profile {name:?}: mirror-source requires layout=\"mirror\""
|
||||
)));
|
||||
}
|
||||
if let Some(source) = &profile.mirror_source
|
||||
&& !outputs
|
||||
.iter()
|
||||
.any(|output| &output.matcher == source && output.enabled)
|
||||
{
|
||||
return Err(ConfigError::OutputProfile(format!(
|
||||
"profile {name:?}: mirror source must name an enabled output"
|
||||
)));
|
||||
}
|
||||
if profile.layout == OutputLayout::Mirror
|
||||
&& outputs.iter().filter(|output| output.enabled).count() < 2
|
||||
{
|
||||
return Err(ConfigError::OutputProfile(format!(
|
||||
"profile {name:?}: mirror needs at least two enabled outputs"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(OutputProfile {
|
||||
name: name.to_owned(),
|
||||
outputs,
|
||||
layout: profile.layout,
|
||||
confirm_timeout: profile.confirm_timeout,
|
||||
lid_policy: profile.lid_policy,
|
||||
lid_closed: profile.lid_closed,
|
||||
mirror_source: profile.mirror_source.clone(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -71,7 +110,9 @@ pub(super) fn parse_output_mode(value: &str) -> Result<OutputModeRequest, String
|
||||
let (size, refresh) = value
|
||||
.trim()
|
||||
.split_once('@')
|
||||
.map_or((value.trim(), None), |(size, refresh)| (size, Some(refresh)));
|
||||
.map_or((value.trim(), None), |(size, refresh)| {
|
||||
(size, Some(refresh))
|
||||
});
|
||||
let (width, height) = size
|
||||
.split_once('x')
|
||||
.ok_or_else(|| format!("mode {value:?} must use WIDTHxHEIGHT or WIDTHxHEIGHT@REFRESH"))?;
|
||||
@@ -112,11 +153,17 @@ pub(super) const fn default_output_scale() -> f64 {
|
||||
pub struct OutputProfile {
|
||||
pub name: String,
|
||||
pub outputs: Vec<OutputSettings>,
|
||||
pub layout: OutputLayout,
|
||||
pub confirm_timeout: u32,
|
||||
pub lid_policy: LidPolicy,
|
||||
pub lid_closed: Option<bool>,
|
||||
pub mirror_source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OutputSettings {
|
||||
pub auto_refresh: bool,
|
||||
pub required: bool,
|
||||
pub matcher: String,
|
||||
pub enabled: bool,
|
||||
pub mode: Option<OutputModeRequest>,
|
||||
@@ -125,6 +172,18 @@ pub struct OutputSettings {
|
||||
pub position: Option<[i32; 2]>,
|
||||
}
|
||||
|
||||
impl OutputSettings {
|
||||
/// Selectors and requiredness affect matching, not the applied output state.
|
||||
pub(crate) fn same_configuration(&self, other: &Self) -> bool {
|
||||
self.enabled == other.enabled
|
||||
&& self.mode == other.mode
|
||||
&& self.auto_refresh == other.auto_refresh
|
||||
&& self.scale == other.scale
|
||||
&& self.transform == other.transform
|
||||
&& self.position == other.position
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct OutputModeRequest {
|
||||
pub width: u16,
|
||||
@@ -132,7 +191,9 @@ pub struct OutputModeRequest {
|
||||
pub refresh_millihertz: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, schemars::JsonSchema, serde::Serialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OutputTransform {
|
||||
#[default]
|
||||
@@ -152,17 +213,53 @@ pub enum OutputTransform {
|
||||
Flipped270,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq, schemars::JsonSchema,
|
||||
)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum LidPolicy {
|
||||
#[default]
|
||||
DockOrSuspend,
|
||||
Ignore,
|
||||
}
|
||||
|
||||
const fn default_confirm_timeout() -> u32 {
|
||||
15
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq, schemars::JsonSchema,
|
||||
)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum OutputLayout {
|
||||
InternalOnly,
|
||||
ExternalOnly,
|
||||
#[default]
|
||||
Extend,
|
||||
Mirror,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
pub(super) struct OutputProfileConfig {
|
||||
pub(super) name: String,
|
||||
#[serde(default)]
|
||||
pub(super) layout: OutputLayout,
|
||||
#[serde(default = "default_confirm_timeout")]
|
||||
pub(super) confirm_timeout: u32,
|
||||
#[serde(default)]
|
||||
pub(super) lid_policy: LidPolicy,
|
||||
pub(super) lid_closed: Option<bool>,
|
||||
pub(super) mirror_source: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) outputs: Vec<OutputConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
pub(super) struct OutputConfig {
|
||||
#[serde(default)]
|
||||
pub(super) auto_refresh: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub(super) required: bool,
|
||||
#[serde(rename = "match")]
|
||||
pub(super) matcher: String,
|
||||
#[serde(default = "default_true")]
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
use super::{Config, ConfigError, Deserialize, parse_color};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub(super) struct TabIndicatorConfig {
|
||||
width: f64,
|
||||
segment_height: f64,
|
||||
segment_gap: f64,
|
||||
hit_width: f64,
|
||||
gap_threshold: f64,
|
||||
list_width: f64,
|
||||
row_height: f64,
|
||||
padding: f64,
|
||||
font_size: f64,
|
||||
outline_width: f64,
|
||||
outline_color: Option<String>,
|
||||
flash_duration_ms: u64,
|
||||
active_color: Option<String>,
|
||||
inactive_color: Option<String>,
|
||||
background_color: Option<String>,
|
||||
text_color: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for TabIndicatorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: 4.0,
|
||||
segment_height: 24.0,
|
||||
segment_gap: 4.0,
|
||||
hit_width: 12.0,
|
||||
gap_threshold: 8.0,
|
||||
list_width: 220.0,
|
||||
row_height: 28.0,
|
||||
padding: 4.0,
|
||||
font_size: 12.0,
|
||||
outline_width: 0.5,
|
||||
outline_color: None,
|
||||
flash_duration_ms: 1000,
|
||||
active_color: None,
|
||||
inactive_color: None,
|
||||
background_color: None,
|
||||
text_color: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct TabIndicatorSettings {
|
||||
pub width: f64,
|
||||
pub segment_height: f64,
|
||||
pub segment_gap: f64,
|
||||
pub hit_width: f64,
|
||||
pub gap_threshold: f64,
|
||||
pub list_width: f64,
|
||||
pub row_height: f64,
|
||||
pub padding: f64,
|
||||
pub font_size: f64,
|
||||
pub outline_width: f64,
|
||||
pub outline_color: Option<[f32; 4]>,
|
||||
pub flash_duration: std::time::Duration,
|
||||
pub active_color: Option<[f32; 4]>,
|
||||
pub inactive_color: Option<[f32; 4]>,
|
||||
pub background_color: Option<[f32; 4]>,
|
||||
pub text_color: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub(crate) fn tab_indicator_settings(&self) -> Result<TabIndicatorSettings, ConfigError> {
|
||||
let c = &self.scrolling.tab_indicator;
|
||||
let range = |value: f64, min: f64, max: f64, field: &'static str| {
|
||||
if value.is_finite() && (min..=max).contains(&value) {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(ConfigError::LayoutValue { field, value })
|
||||
}
|
||||
};
|
||||
let color = |value: &Option<String>, field| {
|
||||
value
|
||||
.as_ref()
|
||||
.map(|s| parse_color(s, field).map(|c| c.0))
|
||||
.transpose()
|
||||
};
|
||||
let width = range(c.width, 1.0, 16.0, "tab_indicator.width")?;
|
||||
let hit_width = range(c.hit_width, width, 64.0, "tab_indicator.hit_width")?;
|
||||
let font_size = range(c.font_size, 8.0, 32.0, "tab_indicator.font_size")?;
|
||||
Ok(TabIndicatorSettings {
|
||||
width,
|
||||
hit_width,
|
||||
font_size,
|
||||
outline_width: range(c.outline_width, 0.0, 8.0, "tab_indicator.outline_width")?,
|
||||
outline_color: color(&c.outline_color, "tab_indicator.outline_color")?,
|
||||
segment_height: range(
|
||||
c.segment_height,
|
||||
width,
|
||||
128.0,
|
||||
"tab_indicator.segment_height",
|
||||
)?,
|
||||
segment_gap: range(c.segment_gap, 0.0, 64.0, "tab_indicator.segment_gap")?,
|
||||
gap_threshold: range(c.gap_threshold, width, 128.0, "tab_indicator.gap_threshold")?,
|
||||
list_width: range(c.list_width, 80.0, 640.0, "tab_indicator.list_width")?,
|
||||
row_height: range(
|
||||
c.row_height,
|
||||
font_size + 6.0,
|
||||
80.0,
|
||||
"tab_indicator.row_height",
|
||||
)?,
|
||||
padding: range(c.padding, 0.0, 24.0, "tab_indicator.padding")?,
|
||||
flash_duration: std::time::Duration::from_millis(range(
|
||||
c.flash_duration_ms as f64,
|
||||
0.0,
|
||||
60_000.0,
|
||||
"tab_indicator.flash_duration_ms",
|
||||
)? as u64),
|
||||
active_color: color(&c.active_color, "tab_indicator.active_color")?,
|
||||
inactive_color: color(&c.inactive_color, "tab_indicator.inactive_color")?,
|
||||
background_color: color(&c.background_color, "tab_indicator.background_color")?,
|
||||
text_color: color(&c.text_color, "tab_indicator.text_color")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn indicator_defaults_overrides_and_validation() {
|
||||
let c = Config::parse_source("")
|
||||
.unwrap()
|
||||
.tab_indicator_settings()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
c.width,
|
||||
c.segment_height,
|
||||
c.segment_gap,
|
||||
c.hit_width,
|
||||
c.gap_threshold
|
||||
),
|
||||
(4., 24., 4., 12., 8.)
|
||||
);
|
||||
assert_eq!(c.flash_duration.as_millis(), 1000);
|
||||
assert!(c.active_color.is_none());
|
||||
let config = Config::parse_source(
|
||||
"scrolling { tab-indicator { width 3; hit-width 14; flash-duration-ms 750; active-color \"#ff0000\"; }; }",
|
||||
)
|
||||
.unwrap();
|
||||
let c = config.tab_indicator_settings().unwrap();
|
||||
assert_eq!(
|
||||
(c.width, c.hit_width, c.flash_duration.as_millis()),
|
||||
(3., 14., 750)
|
||||
);
|
||||
assert_eq!(c.active_color, Some([1., 0., 0., 1.]));
|
||||
for source in [
|
||||
"width 0",
|
||||
"segment-gap -1",
|
||||
"hit-width 2",
|
||||
"row-height 8",
|
||||
"flash-duration-ms 60001",
|
||||
"active-color \"invalid\"",
|
||||
] {
|
||||
let config =
|
||||
Config::parse_source(&format!("scrolling {{ tab-indicator {{ {source}; }}; }}"))
|
||||
.unwrap();
|
||||
assert!(config.tab_indicator_settings().is_err(), "{source}");
|
||||
}
|
||||
assert!(Config::parse_source("scrolling { tab-indicator { typo 1; }; }").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
|
||||
)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub(crate) enum XwaylandStartup {
|
||||
#[default]
|
||||
OnDemand,
|
||||
Eager,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub(crate) struct XwaylandConfig {
|
||||
pub enabled: bool,
|
||||
pub startup: XwaylandStartup,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for XwaylandConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
startup: XwaylandStartup::OnDemand,
|
||||
path: PathBuf::from("xwayland-satellite"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl XwaylandConfig {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.enabled && self.path.as_os_str().is_empty() {
|
||||
return Err("xwayland.path must name an executable".into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_enable_on_demand_with_satellite_on_path() {
|
||||
let config = XwaylandConfig::default();
|
||||
|
||||
assert!(config.enabled);
|
||||
assert_eq!(config.startup, XwaylandStartup::OnDemand);
|
||||
assert_eq!(config.path, PathBuf::from("xwayland-satellite"));
|
||||
assert_eq!(config.validate(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_config_does_not_require_a_path() {
|
||||
let config = XwaylandConfig {
|
||||
enabled: false,
|
||||
path: PathBuf::new(),
|
||||
..XwaylandConfig::default()
|
||||
};
|
||||
|
||||
assert_eq!(config.validate(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_config_rejects_an_empty_path() {
|
||||
let config = XwaylandConfig {
|
||||
enabled: true,
|
||||
path: PathBuf::new(),
|
||||
..XwaylandConfig::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
config.validate(),
|
||||
Err("xwayland.path must name an executable".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_mode_accepts_kebab_case() {
|
||||
#[derive(Deserialize, schemars::JsonSchema, serde::Serialize)]
|
||||
struct Wrapper {
|
||||
startup: XwaylandStartup,
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Wrapper>(r#"{"startup":"eager"}"#)
|
||||
.unwrap()
|
||||
.startup,
|
||||
XwaylandStartup::Eager
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Wrapper>(r#"{"startup":"on-demand"}"#)
|
||||
.unwrap()
|
||||
.startup,
|
||||
XwaylandStartup::OnDemand
|
||||
);
|
||||
assert!(serde_json::from_str::<Wrapper>(r#"{"startup":"eagerly"}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,380 @@
|
||||
use std::fs;
|
||||
use std::sync::OnceLock;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::sync::{Arc, Condvar, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use smithay::backend::allocator::Fourcc;
|
||||
use smithay::backend::renderer::element::memory::MemoryRenderBuffer;
|
||||
use smithay::input::pointer::CursorIcon;
|
||||
use smithay::utils::{Buffer, Point, Size, Transform};
|
||||
use smithay::input::pointer::{CursorIcon, CursorImageStatus};
|
||||
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
|
||||
use smithay::reexports::calloop::{LoopSignal, RegistrationToken};
|
||||
use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Size, Transform};
|
||||
use xcursor::CursorTheme;
|
||||
use xcursor::parser::Image;
|
||||
|
||||
const DEFAULT_CURSOR_SIZE: u32 = 24;
|
||||
const MAX_FILE_BYTES: usize = 8 * 1024 * 1024;
|
||||
const MAX_FRAMES: usize = 256;
|
||||
const MAX_PIXELS: usize = 2 * 1024 * 1024;
|
||||
const MAX_CACHE_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct NamedCursor {
|
||||
pub(crate) struct CursorFrame {
|
||||
pub buffer: MemoryRenderBuffer,
|
||||
pub hotspot: Point<i32, Buffer>,
|
||||
pub size: Size<i32, Buffer>,
|
||||
pub buffer_scale: i32,
|
||||
delay: Duration,
|
||||
}
|
||||
|
||||
pub(crate) fn cursor_theme() -> CursorTheme {
|
||||
let name = std::env::var("XCURSOR_THEME").unwrap_or_else(|_| "default".into());
|
||||
impl CursorFrame {
|
||||
pub(crate) fn logical_rect(&self, pointer: Point<f64, Logical>) -> Rectangle<f64, Logical> {
|
||||
Rectangle::new(
|
||||
pointer
|
||||
- Point::from((
|
||||
f64::from(self.hotspot.x) / f64::from(self.buffer_scale),
|
||||
f64::from(self.hotspot.y) / f64::from(self.buffer_scale),
|
||||
)),
|
||||
(
|
||||
f64::from(self.size.w) / f64::from(self.buffer_scale),
|
||||
f64::from(self.size.h) / f64::from(self.buffer_scale),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
CursorTheme::load(&name)
|
||||
}
|
||||
|
||||
pub(crate) fn load_named_cursor(theme: &CursorTheme, icon: CursorIcon) -> NamedCursor {
|
||||
let requested_size = cursor_size();
|
||||
let image = cursor_names(icon)
|
||||
.iter()
|
||||
.find_map(|name| load_image(theme, name, requested_size));
|
||||
|
||||
match image {
|
||||
Some(image) => cursor_from_image(image),
|
||||
None => fallback_cursor(),
|
||||
pub(crate) fn physical_location(
|
||||
&self,
|
||||
pointer: Point<f64, Logical>,
|
||||
scale: f64,
|
||||
) -> Point<f64, Physical> {
|
||||
self.logical_rect(pointer).loc.to_physical(scale)
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_size() -> u32 {
|
||||
static SIZE: OnceLock<u32> = OnceLock::new();
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct NamedCursor {
|
||||
frames: Arc<[CursorFrame]>,
|
||||
bytes: usize,
|
||||
requested_scale: i32,
|
||||
}
|
||||
|
||||
*SIZE.get_or_init(|| {
|
||||
std::env::var("XCURSOR_SIZE")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.filter(|size| *size > 0)
|
||||
.unwrap_or(DEFAULT_CURSOR_SIZE)
|
||||
impl NamedCursor {
|
||||
fn sample(&self, elapsed: Duration) -> (usize, Option<Duration>) {
|
||||
if self.frames.len() <= 1 {
|
||||
return (0, None);
|
||||
}
|
||||
|
||||
let period: Duration = self.frames.iter().map(|frame| frame.delay).sum();
|
||||
let mut offset = elapsed.as_nanos() % period.as_nanos();
|
||||
for (index, frame) in self.frames.iter().enumerate() {
|
||||
if offset < frame.delay.as_nanos() {
|
||||
return (
|
||||
index,
|
||||
Some(frame.delay - Duration::from_nanos(offset as u64)),
|
||||
);
|
||||
}
|
||||
offset -= frame.delay.as_nanos();
|
||||
}
|
||||
unreachable!("cursor phase is within the bounded, nonzero period")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Requests {
|
||||
pending: Option<(CursorIcon, i32)>,
|
||||
in_flight: Option<(CursorIcon, i32)>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
struct Loaded {
|
||||
icon: CursorIcon,
|
||||
scale: i32,
|
||||
cursor: Option<NamedCursor>,
|
||||
}
|
||||
|
||||
pub(crate) struct Loader {
|
||||
shared: Arc<(Mutex<Requests>, Condvar)>,
|
||||
results: mpsc::Receiver<Loaded>,
|
||||
}
|
||||
|
||||
impl Loader {
|
||||
pub(crate) fn new(signal: LoopSignal) -> std::io::Result<Self> {
|
||||
Self::with_loader(signal, {
|
||||
// Theme discovery and inherited-theme reads belong to the worker too.
|
||||
let mut theme = None;
|
||||
move |(icon, scale)| {
|
||||
let theme = theme.get_or_insert_with(|| {
|
||||
CursorTheme::load(
|
||||
&std::env::var("XCURSOR_THEME").unwrap_or_else(|_| "default".into()),
|
||||
)
|
||||
});
|
||||
load_named_cursor(theme, icon, scale)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn with_loader(
|
||||
signal: LoopSignal,
|
||||
mut load: impl FnMut((CursorIcon, i32)) -> Option<NamedCursor> + Send + 'static,
|
||||
) -> std::io::Result<Self> {
|
||||
let shared = Arc::new((
|
||||
Mutex::new(Requests {
|
||||
pending: Some((CursorIcon::Default, 1)),
|
||||
..Requests::default()
|
||||
}),
|
||||
Condvar::new(),
|
||||
));
|
||||
let worker = shared.clone();
|
||||
let (sender, results) = mpsc::sync_channel(1);
|
||||
std::thread::Builder::new()
|
||||
.name("ferese-cursors".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let (requests, ready) = &*worker;
|
||||
let mut requests = ready
|
||||
.wait_while(
|
||||
requests.lock().expect(
|
||||
"cursor decode queue must remain consistent across workers",
|
||||
),
|
||||
|requests| !requests.closed && requests.pending.is_none(),
|
||||
)
|
||||
.expect("cursor decode queue must remain consistent across workers");
|
||||
if requests.closed {
|
||||
break;
|
||||
}
|
||||
let request = requests
|
||||
.pending
|
||||
.take()
|
||||
.expect("the decode wait completed with a pending cursor request");
|
||||
requests.in_flight = Some(request);
|
||||
drop(requests);
|
||||
let cursor = load(request);
|
||||
let (icon, scale) = request;
|
||||
if sender
|
||||
.send(Loaded {
|
||||
icon,
|
||||
scale,
|
||||
cursor,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
signal.wakeup();
|
||||
worker
|
||||
.0
|
||||
.lock()
|
||||
.expect("cursor decode queue must remain consistent across workers")
|
||||
.in_flight = None;
|
||||
}
|
||||
})?;
|
||||
Ok(Self { shared, results })
|
||||
}
|
||||
|
||||
pub(crate) fn request(&self, icon: CursorIcon, scale: i32) {
|
||||
let request = (icon, scale);
|
||||
let mut requests = self
|
||||
.shared
|
||||
.0
|
||||
.lock()
|
||||
.expect("cursor decode queue must remain consistent across workers");
|
||||
if requests.in_flight == Some(request) || requests.pending == Some(request) {
|
||||
return;
|
||||
}
|
||||
requests.pending = Some(request);
|
||||
self.shared.1.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Loader {
|
||||
fn drop(&mut self) {
|
||||
let mut requests = self
|
||||
.shared
|
||||
.0
|
||||
.lock()
|
||||
.expect("cursor decode queue must remain consistent across workers");
|
||||
requests.closed = true;
|
||||
requests.pending = None;
|
||||
self.shared.1.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Animation {
|
||||
icon: Option<CursorIcon>,
|
||||
epoch: Instant,
|
||||
frame: usize,
|
||||
timer: Option<RegistrationToken>,
|
||||
pub(crate) reset: bool,
|
||||
pub(crate) buffer_scale: i32,
|
||||
}
|
||||
|
||||
impl Animation {
|
||||
pub(crate) fn capture_revision(&self) -> (Option<CursorIcon>, Instant, usize) {
|
||||
(self.icon, self.epoch, self.frame)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Animation {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
icon: None,
|
||||
epoch: Instant::now(),
|
||||
frame: 0,
|
||||
timer: None,
|
||||
reset: false,
|
||||
buffer_scale: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Ferese {
|
||||
pub(crate) fn named_cursor_frame(&self) -> Option<&CursorFrame> {
|
||||
let CursorImageStatus::Named(icon) = self.cursor_status else {
|
||||
return None;
|
||||
};
|
||||
let cursor = self
|
||||
.named_cursors
|
||||
.get(&icon)
|
||||
.or_else(|| self.named_cursors.get(&CursorIcon::Default))?;
|
||||
let frame = if self.cursor_animation.icon == Some(icon) {
|
||||
self.cursor_animation.frame
|
||||
} else {
|
||||
0
|
||||
};
|
||||
cursor.frames.get(frame).or_else(|| cursor.frames.first())
|
||||
}
|
||||
|
||||
pub(crate) fn update_named_cursor(&mut self, now: Instant) {
|
||||
self.cursor_animation.buffer_scale = if let Some(backend) = self.direct_backend.as_ref() {
|
||||
backend
|
||||
.physical_outputs()
|
||||
.map(|output| output.current_scale().fractional_scale().ceil() as i32)
|
||||
.max()
|
||||
} else {
|
||||
self.space
|
||||
.outputs()
|
||||
.map(|output| output.current_scale().fractional_scale().ceil() as i32)
|
||||
.max()
|
||||
}
|
||||
.unwrap_or(1)
|
||||
.clamp(1, 8);
|
||||
while let Ok(Loaded {
|
||||
icon,
|
||||
scale,
|
||||
cursor,
|
||||
}) = self.cursor_loader.results.try_recv()
|
||||
{
|
||||
let mut cursor = cursor.unwrap_or_else(fallback_cursor);
|
||||
cursor.requested_scale = scale;
|
||||
let active = match self.cursor_status {
|
||||
CursorImageStatus::Named(icon) => Some(icon),
|
||||
_ => None,
|
||||
};
|
||||
let affects_current = active == Some(icon)
|
||||
|| (icon == CursorIcon::Default
|
||||
&& active.is_some_and(|active| !self.named_cursors.contains_key(&active)));
|
||||
let mut bytes: usize = self.named_cursors.values().map(|cursor| cursor.bytes).sum();
|
||||
while bytes + cursor.bytes > MAX_CACHE_BYTES {
|
||||
let Some(evict) = self.named_cursors.keys().copied().find(|key| {
|
||||
*key != CursorIcon::Default && *key != icon && Some(*key) != active
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
bytes -= self
|
||||
.named_cursors
|
||||
.remove(&evict)
|
||||
.expect("cursor selection retains the requested or default cursor")
|
||||
.bytes;
|
||||
}
|
||||
self.named_cursors.insert(icon, cursor);
|
||||
if affects_current {
|
||||
self.cursor_animation.reset = true;
|
||||
self.cursor_redraw_pending |=
|
||||
crate::backends::direct::cursor_animation_visible(self);
|
||||
}
|
||||
}
|
||||
|
||||
let icon = match self.cursor_status {
|
||||
CursorImageStatus::Named(icon) => Some(icon),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(icon) = icon
|
||||
&& self
|
||||
.named_cursors
|
||||
.get(&icon)
|
||||
.is_none_or(|cursor| cursor.requested_scale != self.cursor_animation.buffer_scale)
|
||||
{
|
||||
self.cursor_loader
|
||||
.request(icon, self.cursor_animation.buffer_scale);
|
||||
}
|
||||
let animated = icon
|
||||
.and_then(|icon| {
|
||||
self.named_cursors
|
||||
.get(&icon)
|
||||
.or_else(|| self.named_cursors.get(&CursorIcon::Default))
|
||||
})
|
||||
.is_some_and(|cursor| cursor.frames.len() > 1);
|
||||
let visible = animated && crate::backends::direct::cursor_animation_visible(self);
|
||||
if !visible || self.cursor_animation.icon != icon || self.cursor_animation.reset {
|
||||
if let Some(token) = self.cursor_animation.timer.take() {
|
||||
self.loop_handle.remove(token);
|
||||
}
|
||||
self.cursor_animation.icon = icon.filter(|_| visible);
|
||||
self.cursor_animation.epoch = now;
|
||||
self.cursor_animation.frame = 0;
|
||||
self.cursor_animation.reset = false;
|
||||
}
|
||||
if !visible {
|
||||
return;
|
||||
}
|
||||
let icon = icon.expect("the hidden-cursor branch returned before image selection");
|
||||
let cursor = self
|
||||
.named_cursors
|
||||
.get(&icon)
|
||||
.or_else(|| self.named_cursors.get(&CursorIcon::Default))
|
||||
.expect("cursor selection retains the requested or default cursor");
|
||||
let (frame, remaining) =
|
||||
cursor.sample(now.saturating_duration_since(self.cursor_animation.epoch));
|
||||
if self.cursor_animation.frame != frame {
|
||||
self.cursor_animation.frame = frame;
|
||||
self.cursor_redraw_pending = true;
|
||||
}
|
||||
if let Some(remaining) = remaining
|
||||
&& self.cursor_animation.timer.is_none()
|
||||
{
|
||||
match self.loop_handle.insert_source(
|
||||
Timer::from_deadline(now + remaining),
|
||||
|_, _, state| {
|
||||
state.cursor_animation.timer = None;
|
||||
state.cursor_redraw_pending = true;
|
||||
TimeoutAction::Drop
|
||||
},
|
||||
) {
|
||||
Ok(token) => self.cursor_animation.timer = Some(token),
|
||||
Err(error) => tracing::warn!(%error, "could not schedule animated cursor"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_named_cursor(theme: &CursorTheme, icon: CursorIcon, scale: i32) -> Option<NamedCursor> {
|
||||
let requested = std::env::var("XCURSOR_SIZE")
|
||||
.ok()
|
||||
.and_then(|size| size.parse::<u32>().ok())
|
||||
.filter(|size| *size > 0 && *size <= 1024)
|
||||
.unwrap_or(DEFAULT_CURSOR_SIZE);
|
||||
cursor_names(icon).iter().find_map(|name| {
|
||||
let path = theme.load_icon(name)?;
|
||||
let file = File::open(path).ok()?;
|
||||
let metadata = file.metadata().ok()?;
|
||||
if !metadata.is_file() || metadata.len() > MAX_FILE_BYTES as u64 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take((MAX_FILE_BYTES + 1) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
.ok()?;
|
||||
parse_cursor(&bytes, requested, scale)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,36 +387,142 @@ fn cursor_names(icon: CursorIcon) -> [&'static str; 3] {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_image(theme: &CursorTheme, name: &str, requested_size: u32) -> Option<Image> {
|
||||
let path = theme.load_icon(name)?;
|
||||
let bytes = fs::read(path).ok()?;
|
||||
|
||||
xcursor::parser::parse_xcursor(&bytes)?.into_iter().min_by_key(|image| {
|
||||
(
|
||||
image.size.abs_diff(requested_size),
|
||||
image.width.abs_diff(requested_size),
|
||||
)
|
||||
// Inspect every image before allocating buffers, then decode only the selected
|
||||
// nominal size. TOC aliases count toward the budget just like separate frames.
|
||||
fn parse_cursor(bytes: &[u8], requested: u32, scale: i32) -> Option<NamedCursor> {
|
||||
fn word(bytes: &[u8], offset: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(
|
||||
bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?,
|
||||
))
|
||||
}
|
||||
if bytes.len() > MAX_FILE_BYTES || bytes.get(..4)? != b"Xcur" {
|
||||
return None;
|
||||
}
|
||||
let header = word(bytes, 4)? as usize;
|
||||
let count = word(bytes, 12)? as usize;
|
||||
if header < 16
|
||||
|| count > MAX_FRAMES
|
||||
|| header.checked_add(count.checked_mul(12)?)? > bytes.len()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut images = Vec::new();
|
||||
let mut total_pixels = 0usize;
|
||||
for i in 0..count {
|
||||
let toc = header + i * 12;
|
||||
if word(bytes, toc)? != 0xfffd0002 {
|
||||
continue;
|
||||
}
|
||||
let nominal = word(bytes, toc + 4)?;
|
||||
let pos = word(bytes, toc + 8)? as usize;
|
||||
if word(bytes, pos)? != 36
|
||||
|| word(bytes, pos.checked_add(4)?)? != 0xfffd0002
|
||||
|| word(bytes, pos.checked_add(8)?)? != nominal
|
||||
|| word(bytes, pos.checked_add(12)?)? != 1
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let width = word(bytes, pos + 16)?;
|
||||
let height = word(bytes, pos + 20)?;
|
||||
let xhot = word(bytes, pos + 24)?;
|
||||
let yhot = word(bytes, pos + 28)?;
|
||||
if nominal == 0
|
||||
|| width == 0
|
||||
|| height == 0
|
||||
|| width > 1024
|
||||
|| height > 1024
|
||||
|| xhot > width
|
||||
|| yhot > height
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let pixels = (width as usize).checked_mul(height as usize)?;
|
||||
total_pixels = total_pixels.checked_add(pixels)?;
|
||||
if total_pixels > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let start = pos.checked_add(36)?;
|
||||
let end = start.checked_add(pixels.checked_mul(4)?)?;
|
||||
let rgba = bytes.get(start..end)?;
|
||||
images.push((
|
||||
nominal,
|
||||
width,
|
||||
height,
|
||||
xhot,
|
||||
yhot,
|
||||
word(bytes, pos + 32)?,
|
||||
rgba,
|
||||
));
|
||||
}
|
||||
let base = images
|
||||
.iter()
|
||||
.map(|image| image.0)
|
||||
.min_by_key(|size| (size.abs_diff(requested), *size))?;
|
||||
let scale = scale.clamp(1, 8);
|
||||
let desired = base.checked_mul(scale as u32)?;
|
||||
let nominal = images
|
||||
.iter()
|
||||
.map(|image| image.0)
|
||||
.min_by_key(|size| (size.abs_diff(desired), *size))?;
|
||||
let mut frames = Vec::new();
|
||||
let mut asset_bytes = 0usize;
|
||||
for (_, width, height, xhot, yhot, delay, rgba) in
|
||||
images.into_iter().filter(|image| image.0 == nominal)
|
||||
{
|
||||
let ratio = f64::from(desired) / f64::from(nominal);
|
||||
let w = (f64::from(width) * ratio).round().max(1.0) as u32;
|
||||
let h = (f64::from(height) * ratio).round().max(1.0) as u32;
|
||||
if w > 1024 || h > 1024 {
|
||||
return None;
|
||||
}
|
||||
let size: Size<i32, Buffer> = (w as i32, h as i32).into();
|
||||
asset_bytes =
|
||||
asset_bytes.checked_add((w as usize).checked_mul(h as usize)?.checked_mul(4)?)?;
|
||||
if asset_bytes > MAX_PIXELS * 4 {
|
||||
return None;
|
||||
}
|
||||
let resized;
|
||||
let pixels = if w != width || h != height {
|
||||
let original = image::RgbaImage::from_raw(width, height, rgba.to_vec())?;
|
||||
resized =
|
||||
image::imageops::resize(&original, w, h, image::imageops::FilterType::Triangle);
|
||||
resized.as_raw().as_slice()
|
||||
} else {
|
||||
rgba
|
||||
};
|
||||
frames.push(CursorFrame {
|
||||
buffer: MemoryRenderBuffer::from_slice(
|
||||
pixels,
|
||||
Fourcc::Abgr8888,
|
||||
size,
|
||||
scale,
|
||||
Transform::Normal,
|
||||
None,
|
||||
),
|
||||
hotspot: (
|
||||
(f64::from(xhot) * ratio).round() as i32,
|
||||
(f64::from(yhot) * ratio).round() as i32,
|
||||
)
|
||||
.into(),
|
||||
size,
|
||||
buffer_scale: scale,
|
||||
// Zero-delay themes need a finite cadence rather than a busy loop.
|
||||
delay: Duration::from_millis(if delay == 0 { 16 } else { u64::from(delay) }),
|
||||
});
|
||||
}
|
||||
Some(NamedCursor {
|
||||
frames: frames.into(),
|
||||
bytes: asset_bytes,
|
||||
requested_scale: scale,
|
||||
})
|
||||
}
|
||||
|
||||
fn cursor_from_image(image: Image) -> NamedCursor {
|
||||
let size = Size::<i32, Buffer>::from((image.width as i32, image.height as i32));
|
||||
let buffer = MemoryRenderBuffer::from_slice(&image.pixels_rgba, Fourcc::Abgr8888, size, 1, Transform::Normal, None);
|
||||
|
||||
NamedCursor {
|
||||
buffer,
|
||||
hotspot: Point::from((image.xhot as i32, image.yhot as i32)),
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_cursor() -> NamedCursor {
|
||||
pub(crate) fn fallback_cursor() -> NamedCursor {
|
||||
const WIDTH: usize = 12;
|
||||
const HEIGHT: usize = 18;
|
||||
let mut pixels = vec![0_u8; WIDTH * HEIGHT * 4];
|
||||
|
||||
for y in 0..HEIGHT {
|
||||
let body_width = (y / 2 + 1).min(8);
|
||||
|
||||
for x in 0..body_width {
|
||||
let edge = x == 0 || x + 1 == body_width || y == 0;
|
||||
let offset = (y * WIDTH + x) * 4;
|
||||
@@ -92,38 +530,26 @@ fn fallback_cursor() -> NamedCursor {
|
||||
pixels[offset..offset + 4].copy_from_slice(&[color, color, color, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
NamedCursor {
|
||||
buffer: MemoryRenderBuffer::from_slice(
|
||||
&pixels,
|
||||
Fourcc::Abgr8888,
|
||||
Size::<i32, Buffer>::from((WIDTH as i32, HEIGHT as i32)),
|
||||
1,
|
||||
Transform::Normal,
|
||||
None,
|
||||
),
|
||||
hotspot: Point::default(),
|
||||
frames: vec![CursorFrame {
|
||||
buffer: MemoryRenderBuffer::from_slice(
|
||||
&pixels,
|
||||
Fourcc::Abgr8888,
|
||||
(WIDTH as i32, HEIGHT as i32),
|
||||
1,
|
||||
Transform::Normal,
|
||||
None,
|
||||
),
|
||||
hotspot: Point::default(),
|
||||
size: (WIDTH as i32, HEIGHT as i32).into(),
|
||||
buffer_scale: 1,
|
||||
delay: Duration::from_millis(16),
|
||||
}]
|
||||
.into(),
|
||||
bytes: pixels.len(),
|
||||
requested_scale: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use smithay::input::pointer::CursorIcon;
|
||||
|
||||
use super::cursor_names;
|
||||
|
||||
#[test]
|
||||
fn supplies_legacy_fallback_names_for_common_cursors() {
|
||||
assert_eq!(cursor_names(CursorIcon::Default), ["default", "left_ptr", "arrow"]);
|
||||
assert_eq!(cursor_names(CursorIcon::Pointer), ["pointer", "hand2", "left_ptr"]);
|
||||
assert_eq!(cursor_names(CursorIcon::Text), ["text", "xterm", "left_ptr"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_default_for_other_named_cursors() {
|
||||
assert_eq!(
|
||||
cursor_names(CursorIcon::Crosshair),
|
||||
["crosshair", "default", "left_ptr"]
|
||||
);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,259 @@
|
||||
use super::*;
|
||||
use smithay::input::SeatHandler;
|
||||
use smithay::input::pointer::MotionEvent;
|
||||
use smithay::output::{Mode, Output, PhysicalProperties, Subpixel};
|
||||
use smithay::reexports::calloop::EventLoop;
|
||||
use smithay::utils::SERIAL_COUNTER;
|
||||
|
||||
fn file(frames: &[(u32, u32, u32, u32)]) -> Vec<u8> {
|
||||
let mut bytes = b"Xcur".to_vec();
|
||||
bytes.extend(
|
||||
[16u32, 1, frames.len() as u32]
|
||||
.into_iter()
|
||||
.flat_map(u32::to_le_bytes),
|
||||
);
|
||||
let mut pos = 16 + frames.len() * 12;
|
||||
for &(size, width, _, _) in frames {
|
||||
bytes.extend(
|
||||
[0xfffd0002, size, pos as u32]
|
||||
.into_iter()
|
||||
.flat_map(u32::to_le_bytes),
|
||||
);
|
||||
pos += 36 + width as usize * width as usize * 4;
|
||||
}
|
||||
for &(size, width, hotspot, delay) in frames {
|
||||
bytes.extend(
|
||||
[
|
||||
36, 0xfffd0002, size, 1, width, width, hotspot, hotspot, delay,
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(u32::to_le_bytes),
|
||||
);
|
||||
bytes.extend([255, 255, 255, 255].repeat(width as usize * width as usize));
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotspot_and_footprint_use_the_images_scale() {
|
||||
let bytes = file(&[(24, 24, 12, 50), (48, 48, 24, 50)]);
|
||||
for scale in [1.0f64, 1.25, 2.0] {
|
||||
let asset = parse_cursor(&bytes, 24, scale.ceil() as i32).unwrap();
|
||||
let frame = &asset.frames[0];
|
||||
let pointer: Point<f64, Logical> = (100.0, 120.0).into();
|
||||
let location = frame.physical_location(pointer, scale);
|
||||
let hotspot = f64::from(frame.hotspot.x) * scale / f64::from(frame.buffer_scale);
|
||||
assert_eq!(location.x + hotspot, pointer.x * scale);
|
||||
assert_eq!(location.y + hotspot, pointer.y * scale);
|
||||
assert_eq!(frame.logical_rect(pointer).size, (24.0, 24.0).into());
|
||||
if scale > 1.0 {
|
||||
assert_eq!(frame.size.w, 48);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_hidpi_size_preserves_logical_size_and_hotspot() {
|
||||
let asset = parse_cursor(&file(&[(32, 32, 16, 50)]), 24, 2).unwrap();
|
||||
let frame = &asset.frames[0];
|
||||
assert_eq!(frame.buffer_scale, 2);
|
||||
assert_eq!(frame.size.w, 64);
|
||||
assert_eq!(frame.hotspot.x, 32);
|
||||
assert_eq!(
|
||||
frame.logical_rect((100.0, 100.0).into()).size,
|
||||
(32.0, 32.0).into()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_a_nominal_sequence_and_preserves_frame_delays() {
|
||||
let asset = parse_cursor(
|
||||
&file(&[(48, 48, 24, 70), (24, 24, 12, 30), (24, 24, 12, 80)]),
|
||||
24,
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(asset.frames.len(), 2);
|
||||
for (time, index, remaining) in [
|
||||
(0, 0, 30),
|
||||
(29, 0, 1),
|
||||
(30, 1, 80),
|
||||
(109, 1, 1),
|
||||
(110, 0, 30),
|
||||
(400, 1, 40),
|
||||
] {
|
||||
assert_eq!(
|
||||
asset.sample(Duration::from_millis(time)),
|
||||
(index, Some(Duration::from_millis(remaining)))
|
||||
);
|
||||
}
|
||||
let still = parse_cursor(&file(&[(24, 24, 12, 0)]), 24, 1).unwrap();
|
||||
assert_eq!(still.sample(Duration::from_secs(300)), (0, None));
|
||||
let zero = parse_cursor(&file(&[(24, 24, 12, 0), (24, 24, 12, 0)]), 24, 1).unwrap();
|
||||
assert_eq!(zero.sample(Duration::from_millis(16)).0, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_and_malformed_assets_before_buffer_allocation() {
|
||||
assert!(parse_cursor(&vec![0; MAX_FILE_BYTES + 1], 24, 1).is_none());
|
||||
let mut bytes = file(&[(24, 24, 12, 30)]);
|
||||
bytes[12..16].copy_from_slice(&((MAX_FRAMES + 1) as u32).to_le_bytes());
|
||||
assert!(parse_cursor(&bytes, 24, 1).is_none());
|
||||
let mut bytes = file(&[(24, 24, 12, 30)]);
|
||||
bytes[16 + 8..16 + 12].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(parse_cursor(&bytes, 24, 1).is_none());
|
||||
let mut bytes = file(&[(24, 24, 12, 30)]);
|
||||
bytes[28 + 16..28 + 20].copy_from_slice(&2048u32.to_le_bytes());
|
||||
assert!(parse_cursor(&bytes, 24, 1).is_none());
|
||||
// Repeated TOC references must not bypass the aggregate pixel limit.
|
||||
let mut bytes = file(&[(1024, 1024, 0, 30)]);
|
||||
let frame = bytes.split_off(28);
|
||||
bytes.truncate(16);
|
||||
bytes[12..16].copy_from_slice(&3u32.to_le_bytes());
|
||||
for _ in 0..3 {
|
||||
bytes.extend(
|
||||
[0xfffd0002, 1024, 52]
|
||||
.into_iter()
|
||||
.flat_map(u32::to_le_bytes),
|
||||
);
|
||||
}
|
||||
bytes.extend(frame);
|
||||
assert!(parse_cursor(&bytes, 24, 1).is_none());
|
||||
assert!(parse_cursor(&file(&[(24, 24, 12, 30)])[..40], 24, 1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_loading_does_not_block_cursor_callbacks_or_pointer_dispatch() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"cursor::tests::delayed_loading_does_not_block_cursor_callbacks_or_pointer_dispatch",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let mut events = EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
let (started, calls) = mpsc::channel();
|
||||
let (release, held) = mpsc::channel();
|
||||
state.cursor_loader = Loader::with_loader(events.get_signal(), move |request| {
|
||||
started.send(request).unwrap();
|
||||
if request.0 == CursorIcon::Default {
|
||||
held.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
}
|
||||
None
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
calls.recv_timeout(Duration::from_secs(2)).unwrap().0,
|
||||
CursorIcon::Default
|
||||
);
|
||||
let seat = state.seat.clone();
|
||||
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Pointer));
|
||||
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Text));
|
||||
let pointer = state.seat.get_pointer().unwrap();
|
||||
pointer.motion(
|
||||
&mut state,
|
||||
None,
|
||||
&MotionEvent {
|
||||
location: (10.0, 20.0).into(),
|
||||
serial: SERIAL_COUNTER.next_serial(),
|
||||
time: smithay::backend::input::InputTime::from_millis(1),
|
||||
},
|
||||
);
|
||||
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Text));
|
||||
crate::after_dispatch_with_redraw(&mut state, |_, _| {});
|
||||
assert_eq!(pointer.current_location(), (10.0, 20.0).into());
|
||||
assert!(
|
||||
state.named_cursor_frame().is_some(),
|
||||
"cached fallback must remain visible"
|
||||
);
|
||||
assert!(
|
||||
calls.try_recv().is_err(),
|
||||
"decoder is still deliberately blocked"
|
||||
);
|
||||
release.send(()).unwrap();
|
||||
assert_eq!(
|
||||
calls.recv_timeout(Duration::from_secs(2)).unwrap().0,
|
||||
CursorIcon::Text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_animation_advances_and_cancels_on_hide_and_shape_change() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"cursor::tests::cursor_animation_advances_and_cancels_on_hide_and_shape_change",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let mut events = EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
// No background result can replace the synthetic fixture.
|
||||
state.cursor_loader = Loader::with_loader(events.get_signal(), |_| None).unwrap();
|
||||
let output = Output::new(
|
||||
"cursor-test".into(),
|
||||
PhysicalProperties {
|
||||
serial_number: String::new(),
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
output.change_current_state(
|
||||
Some(Mode {
|
||||
size: (800, 600).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
state.space.map_output(&output, (0, 0));
|
||||
let asset = parse_cursor(&file(&[(24, 24, 12, 30), (24, 24, 12, 80)]), 24, 1).unwrap();
|
||||
state.named_cursors.insert(CursorIcon::Wait, asset);
|
||||
state.cursor_status = CursorImageStatus::Named(CursorIcon::Wait);
|
||||
let now = Instant::now();
|
||||
state.update_named_cursor(now);
|
||||
assert_eq!(state.cursor_animation.frame, 0);
|
||||
assert!(state.cursor_animation.timer.is_some());
|
||||
// Invoke the same deadline callback by dispatching the real timer.
|
||||
while Instant::now() < now + Duration::from_millis(35) {
|
||||
events
|
||||
.dispatch(Duration::from_millis(5), &mut state)
|
||||
.unwrap();
|
||||
}
|
||||
state.update_named_cursor(now + Duration::from_millis(30));
|
||||
assert_eq!(state.cursor_animation.frame, 1);
|
||||
assert!(state.cursor_animation.timer.is_some());
|
||||
let seat = state.seat.clone();
|
||||
state.cursor_image(&seat, CursorImageStatus::Hidden);
|
||||
state.update_named_cursor(now + Duration::from_millis(31));
|
||||
assert!(state.cursor_animation.timer.is_none());
|
||||
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Wait));
|
||||
state.update_named_cursor(now + Duration::from_millis(32));
|
||||
assert!(state.cursor_animation.timer.is_some());
|
||||
state
|
||||
.named_cursors
|
||||
.insert(CursorIcon::Text, fallback_cursor());
|
||||
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Text));
|
||||
state.update_named_cursor(now + Duration::from_millis(33));
|
||||
assert!(state.cursor_animation.timer.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supplies_legacy_fallback_names_for_common_cursors() {
|
||||
assert_eq!(
|
||||
cursor_names(CursorIcon::Default),
|
||||
["default", "left_ptr", "arrow"]
|
||||
);
|
||||
assert_eq!(
|
||||
cursor_names(CursorIcon::Pointer),
|
||||
["pointer", "hand2", "left_ptr"]
|
||||
);
|
||||
assert_eq!(
|
||||
cursor_names(CursorIcon::Text),
|
||||
["text", "xterm", "left_ptr"]
|
||||
);
|
||||
assert_eq!(
|
||||
cursor_names(CursorIcon::Crosshair),
|
||||
["crosshair", "default", "left_ptr"]
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
//! Session-owned services. Never grant shell/effects privileges to daemons.
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::process::Child;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::Ferese;
|
||||
use smithay::reexports::calloop::generic::Generic;
|
||||
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
|
||||
use smithay::reexports::calloop::{Interest, Mode, PostAction, RegistrationToken};
|
||||
|
||||
use crate::config::DaemonConfig;
|
||||
use crate::private_client::ClientCapabilities;
|
||||
use crate::process::{pidfd, spawn_client, terminate_child};
|
||||
use crate::{Ferese, SessionPolicy};
|
||||
|
||||
struct Service {
|
||||
config: DaemonConfig,
|
||||
@@ -13,13 +20,111 @@ struct Service {
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct Runner(Vec<Service>);
|
||||
pub(crate) struct Runner(
|
||||
Vec<Service>,
|
||||
HashMap<u32, RegistrationToken>,
|
||||
Option<(RegistrationToken, Instant)>,
|
||||
);
|
||||
|
||||
impl Runner {
|
||||
pub fn reconcile(&mut self, configs: &[DaemonConfig], nested: bool) {
|
||||
pub fn start(state: &mut Ferese) -> Rc<RefCell<Self>> {
|
||||
let runner = Rc::new(RefCell::new(Self::new(
|
||||
&state.autostart,
|
||||
state.session_environment.policy,
|
||||
)));
|
||||
Self::refresh(&runner, state);
|
||||
runner
|
||||
}
|
||||
|
||||
pub fn refresh(owner: &Rc<RefCell<Self>>, state: &mut Ferese) {
|
||||
let mut runner = owner.borrow_mut();
|
||||
runner.tick(state);
|
||||
let pids = runner
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|service| service.child.as_ref().map(Child::id))
|
||||
.collect::<Vec<_>>();
|
||||
runner.1.retain(|pid, token| {
|
||||
if pids.contains(pid) {
|
||||
true
|
||||
} else {
|
||||
state.loop_handle.remove(*token);
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
let mut watch_retry = None;
|
||||
for pid in pids {
|
||||
if runner.1.contains_key(&pid) {
|
||||
continue;
|
||||
}
|
||||
let weak = Rc::downgrade(owner);
|
||||
let source = pidfd(pid).map(|fd| Generic::new(fd, Interest::READ, Mode::Level));
|
||||
let result = source.and_then(|source| {
|
||||
state
|
||||
.loop_handle
|
||||
.insert_source(source, move |_, _, state| {
|
||||
if let Some(owner) = weak.upgrade() {
|
||||
owner.borrow_mut().1.remove(&pid);
|
||||
Self::refresh(&owner, state);
|
||||
}
|
||||
Ok(PostAction::Remove)
|
||||
})
|
||||
.map_err(std::io::Error::other)
|
||||
});
|
||||
match result {
|
||||
Ok(token) => {
|
||||
runner.1.insert(pid, token);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, pid, "could not watch daemon exit; retrying registration");
|
||||
watch_retry = Some(Instant::now() + Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let deadline = runner
|
||||
.0
|
||||
.iter()
|
||||
.filter(|service| service.child.is_none() && !service.finished)
|
||||
.map(|service| service.next_start)
|
||||
.chain(watch_retry)
|
||||
.min();
|
||||
if runner.2.as_ref().map(|(_, deadline)| *deadline) == deadline {
|
||||
return;
|
||||
}
|
||||
if let Some((token, _)) = runner.2.take() {
|
||||
state.loop_handle.remove(token);
|
||||
}
|
||||
if let Some(deadline) = deadline {
|
||||
let weak = Rc::downgrade(owner);
|
||||
match state.loop_handle.insert_source(
|
||||
Timer::from_deadline(deadline),
|
||||
move |_, _, state| {
|
||||
if let Some(owner) = weak.upgrade() {
|
||||
owner.borrow_mut().2 = None;
|
||||
Self::refresh(&owner, state);
|
||||
}
|
||||
TimeoutAction::Drop
|
||||
},
|
||||
) {
|
||||
Ok(token) => runner.2 = Some((token, deadline)),
|
||||
Err(error) => tracing::warn!(%error, "could not arm daemon restart deadline"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reconcile(&mut self, configs: &[DaemonConfig], policy: SessionPolicy) {
|
||||
let mut previous = std::mem::take(&mut self.0);
|
||||
for config in configs.iter().filter(|c| c.enabled && (!nested || c.nested)) {
|
||||
if self.0.iter().any(|service| service.config.command == config.command) {
|
||||
for config in configs
|
||||
.iter()
|
||||
.filter(|c| c.enabled && policy.allows_autostart(c.nested))
|
||||
{
|
||||
if self
|
||||
.0
|
||||
.iter()
|
||||
.any(|service| service.config.command == config.command)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(index) = previous
|
||||
@@ -45,16 +150,16 @@ impl Runner {
|
||||
// TERM/grace/reaping must not pause the compositor's event loop.
|
||||
for mut removed in previous {
|
||||
if let Some(mut child) = removed.child.take() {
|
||||
std::thread::spawn(move || crate::terminate_child(&mut child));
|
||||
std::thread::spawn(move || terminate_child(&mut child));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(configs: &[DaemonConfig], nested: bool) -> Self {
|
||||
pub fn new(configs: &[DaemonConfig], policy: SessionPolicy) -> Self {
|
||||
Self(
|
||||
configs
|
||||
.iter()
|
||||
.filter(|config| config.enabled && (!nested || config.nested))
|
||||
.filter(|config| config.enabled && policy.allows_autostart(config.nested))
|
||||
.map(|config| Service {
|
||||
config: config.clone(),
|
||||
child: None,
|
||||
@@ -62,11 +167,13 @@ impl Runner {
|
||||
finished: false,
|
||||
})
|
||||
.collect(),
|
||||
HashMap::new(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, state: &mut Ferese) {
|
||||
self.reconcile(&state.autostart, state.direct_backend.is_none());
|
||||
self.reconcile(&state.autostart, state.session_environment.policy);
|
||||
let now = Instant::now();
|
||||
for service in &mut self.0 {
|
||||
if let Some(child) = &mut service.child {
|
||||
@@ -85,9 +192,9 @@ impl Runner {
|
||||
}
|
||||
}
|
||||
if !service.finished && now >= service.next_start {
|
||||
service.child = crate::spawn_client(
|
||||
service.child = spawn_client(
|
||||
state,
|
||||
service.config.command.iter().map(Into::into).collect(),
|
||||
service.config.command.iter(),
|
||||
ClientCapabilities::default(),
|
||||
);
|
||||
service.next_start = now + Duration::from_secs(5);
|
||||
@@ -101,7 +208,7 @@ impl Runner {
|
||||
pub fn stop(&mut self) {
|
||||
for service in &mut self.0 {
|
||||
if let Some(mut child) = service.child.take() {
|
||||
crate::terminate_child(&mut child);
|
||||
terminate_child(&mut child);
|
||||
}
|
||||
service.finished = true;
|
||||
}
|
||||
@@ -117,6 +224,34 @@ impl Drop for Runner {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn pidfd_wakes_the_loop_once_and_the_child_is_reaped() {
|
||||
let mut child = std::process::Command::new("/bin/sh")
|
||||
.args(["-c", "exit 7"])
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let fd = pidfd(child.id()).unwrap();
|
||||
let mut events = smithay::reexports::calloop::EventLoop::<usize>::try_new().unwrap();
|
||||
events
|
||||
.handle()
|
||||
.insert_source(
|
||||
Generic::new(fd, Interest::READ, Mode::Level),
|
||||
move |_, _, count| {
|
||||
assert_eq!(child.wait().unwrap().code(), Some(7));
|
||||
*count += 1;
|
||||
Ok(PostAction::Remove)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut count = 0;
|
||||
events
|
||||
.dispatch(Some(Duration::from_secs(2)), &mut count)
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
events.dispatch(Some(Duration::ZERO), &mut count).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_reconcile_keeps_existing_process_and_changes_policy_without_restart() {
|
||||
let mut config = DaemonConfig {
|
||||
@@ -125,21 +260,30 @@ mod tests {
|
||||
restart: false,
|
||||
nested: false,
|
||||
};
|
||||
let mut runner = Runner::new(&[config.clone()], false);
|
||||
runner.0[0].child = Some(std::process::Command::new("/bin/sleep").arg("30").spawn().unwrap());
|
||||
let mut runner = Runner::new(&[config.clone()], SessionPolicy::Desktop);
|
||||
runner.0[0].child = Some(
|
||||
std::process::Command::new("/bin/sleep")
|
||||
.arg("30")
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let pid = runner.0[0].child.as_ref().unwrap().id();
|
||||
config.restart = true;
|
||||
runner.reconcile(&[config.clone()], false);
|
||||
runner.reconcile(&[config.clone()], SessionPolicy::Desktop);
|
||||
assert_eq!(runner.0[0].child.as_ref().unwrap().id(), pid);
|
||||
assert!(runner.0[0].config.restart);
|
||||
runner.reconcile(&[config.clone(), config.clone()], false);
|
||||
runner.reconcile(&[config.clone(), config.clone()], SessionPolicy::Desktop);
|
||||
assert_eq!(runner.0.len(), 1);
|
||||
config.enabled = false;
|
||||
runner.reconcile(&[config], false);
|
||||
runner.reconcile(&[config], SessionPolicy::Desktop);
|
||||
assert!(runner.0.is_empty());
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
// SAFETY: kill with signal zero only probes an integer PID and has no pointer arguments.
|
||||
while unsafe { libc::kill(pid as i32, 0) } == 0 {
|
||||
assert!(Instant::now() < deadline, "removed service was not stopped/reaped");
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"removed service was not stopped/reaped"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
@@ -152,22 +296,72 @@ mod tests {
|
||||
restart: true,
|
||||
nested: false,
|
||||
}];
|
||||
assert!(Runner::new(&configs, true).0.is_empty());
|
||||
let mut direct = Runner::new(&configs, false);
|
||||
assert!(Runner::new(&configs, SessionPolicy::Embedded).0.is_empty());
|
||||
let mut direct = Runner::new(&configs, SessionPolicy::Desktop);
|
||||
assert_eq!(direct.0.len(), 1);
|
||||
direct.stop();
|
||||
assert!(direct.0[0].finished);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_login_items_never_start_on_either_backend() {
|
||||
fn disabled_login_items_never_start_under_either_session_policy() {
|
||||
let configs = vec![DaemonConfig {
|
||||
command: vec!["not-executed".into()],
|
||||
enabled: false,
|
||||
restart: true,
|
||||
nested: true,
|
||||
}];
|
||||
assert!(Runner::new(&configs, true).0.is_empty());
|
||||
assert!(Runner::new(&configs, false).0.is_empty());
|
||||
assert!(Runner::new(&configs, SessionPolicy::Embedded).0.is_empty());
|
||||
assert!(Runner::new(&configs, SessionPolicy::Desktop).0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_autostart_requires_opt_in_on_start_and_reload() {
|
||||
let regular = DaemonConfig {
|
||||
command: vec!["regular".into()],
|
||||
enabled: true,
|
||||
restart: false,
|
||||
nested: false,
|
||||
};
|
||||
let embedded = DaemonConfig {
|
||||
command: vec!["embedded".into()],
|
||||
nested: true,
|
||||
..regular.clone()
|
||||
};
|
||||
let configs = [regular, embedded.clone()];
|
||||
let mut runner = Runner::new(&configs, SessionPolicy::Embedded);
|
||||
assert_eq!(runner.0.len(), 1);
|
||||
assert_eq!(runner.0[0].config, embedded);
|
||||
runner.reconcile(&configs, SessionPolicy::Desktop);
|
||||
assert_eq!(runner.0.len(), 2);
|
||||
runner.reconcile(&configs, SessionPolicy::Embedded);
|
||||
assert_eq!(runner.0.len(), 1);
|
||||
assert_eq!(runner.0[0].config, embedded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_autostart_does_not_depend_on_a_live_drm_backend() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"daemon::tests::desktop_autostart_does_not_depend_on_a_live_drm_backend",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
assert!(state.direct_backend.is_none());
|
||||
assert_eq!(state.session_environment.policy, SessionPolicy::Desktop);
|
||||
state.autostart = vec![DaemonConfig {
|
||||
command: vec!["/bin/sleep".into(), "30".into()],
|
||||
enabled: true,
|
||||
restart: false,
|
||||
nested: false,
|
||||
}];
|
||||
let runner = Runner::start(&mut state);
|
||||
assert_eq!(runner.borrow().0.len(), 1);
|
||||
assert!(runner.borrow().0[0].child.is_some());
|
||||
runner.borrow_mut().stop();
|
||||
state.session_environment.policy = SessionPolicy::Embedded;
|
||||
Runner::refresh(&runner, &mut state);
|
||||
assert!(runner.borrow().0.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use ferese_layout::WindowId;
|
||||
|
||||
use crate::config::InactiveDimSettings;
|
||||
|
||||
pub(crate) fn target(
|
||||
settings: InactiveDimSettings,
|
||||
focused: Option<WindowId>,
|
||||
window: WindowId,
|
||||
overview: bool,
|
||||
) -> f64 {
|
||||
if settings.enabled && !overview && focused.is_some() && focused != Some(window) {
|
||||
settings.amount
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded, interruption-safe opacity transition; it never overshoots.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct DimAnimation {
|
||||
pub current: f64,
|
||||
start: f64,
|
||||
target: f64,
|
||||
elapsed: f64,
|
||||
}
|
||||
|
||||
impl DimAnimation {
|
||||
pub(crate) fn is_animating(&self) -> bool {
|
||||
self.current != self.target
|
||||
}
|
||||
|
||||
pub(crate) fn predict(&mut self, delta: Duration, duration_ms: f64) {
|
||||
self.advance(self.target, delta, duration_ms);
|
||||
}
|
||||
|
||||
pub fn new(value: f64) -> Self {
|
||||
Self {
|
||||
current: value,
|
||||
start: value,
|
||||
target: value,
|
||||
elapsed: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn advance(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool {
|
||||
if self.target != target {
|
||||
self.start = self.current;
|
||||
self.target = target;
|
||||
self.elapsed = 0.0;
|
||||
}
|
||||
|
||||
if duration_ms == 0.0 {
|
||||
self.current = target;
|
||||
return false;
|
||||
}
|
||||
|
||||
self.elapsed += delta.as_secs_f64() * 1000.0;
|
||||
let progress = (self.elapsed / duration_ms).min(1.0);
|
||||
if progress == 1.0 {
|
||||
self.current = target;
|
||||
return false;
|
||||
}
|
||||
|
||||
let eased = progress * progress * (3.0 - 2.0 * progress);
|
||||
self.current = self.start + (target - self.start) * eased;
|
||||
self.current != target
|
||||
}
|
||||
|
||||
/// The target changed at this tick, not at the beginning of a potentially
|
||||
/// long idle interval. Do not spend that old elapsed time on a new fade.
|
||||
pub fn advance_visual(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool {
|
||||
self.advance(
|
||||
target,
|
||||
if self.target != target { Duration::ZERO } else { delta },
|
||||
duration_ms,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn visual_focus_and_dimming_start_together_after_idle_and_reverse() {
|
||||
let mut focus = DimAnimation::new(0.0);
|
||||
let mut dim = DimAnimation::new(0.15);
|
||||
assert!(focus.advance_visual(1.0, Duration::from_secs(30), 150.0));
|
||||
assert!(dim.advance_visual(0.0, Duration::from_secs(30), 150.0));
|
||||
assert_eq!(focus.current, 0.0);
|
||||
assert_eq!(dim.current, 0.15);
|
||||
focus.advance_visual(1.0, Duration::from_millis(75), 150.0);
|
||||
dim.advance_visual(0.0, Duration::from_millis(75), 150.0);
|
||||
assert!((focus.current + dim.current / 0.15 - 1.0).abs() < 1e-9);
|
||||
let before = focus.current;
|
||||
focus.advance_visual(0.0, Duration::from_millis(16), 150.0);
|
||||
assert_eq!(focus.current, before);
|
||||
assert!(!focus.advance_visual(0.0, Duration::from_millis(150), 150.0));
|
||||
assert!(!focus.advance_visual(1.0, Duration::from_secs(30), 0.0));
|
||||
assert_eq!(focus.current, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_unfocused_windows_dim_and_overview_disables_it() {
|
||||
let settings = InactiveDimSettings {
|
||||
enabled: true,
|
||||
amount: 0.15,
|
||||
duration_ms: 150.0,
|
||||
};
|
||||
let left = WindowId(1);
|
||||
let right = WindowId(2);
|
||||
assert_eq!(target(settings, Some(right), left, false), 0.15);
|
||||
assert_eq!(target(settings, Some(right), right, false), 0.0);
|
||||
assert_eq!(target(settings, Some(right), left, true), 0.0);
|
||||
assert_eq!(target(settings, None, left, false), 0.0);
|
||||
assert_eq!(
|
||||
target(
|
||||
InactiveDimSettings {
|
||||
enabled: false,
|
||||
..settings
|
||||
},
|
||||
Some(right),
|
||||
left,
|
||||
false
|
||||
),
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
target(
|
||||
InactiveDimSettings {
|
||||
amount: 0.0,
|
||||
..settings
|
||||
},
|
||||
Some(right),
|
||||
left,
|
||||
false
|
||||
),
|
||||
0.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_transition_is_smooth_bounded_and_settles() {
|
||||
let mut dim = DimAnimation::new(0.0);
|
||||
assert!(dim.advance(0.15, Duration::from_millis(75), 150.0));
|
||||
assert!((dim.current - 0.075).abs() < 1e-9);
|
||||
assert!(!dim.advance(0.15, Duration::from_millis(75), 150.0));
|
||||
assert_eq!(dim.current, 0.15);
|
||||
assert!(!dim.advance(0.15, Duration::from_secs(10), 150.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversing_focus_starts_from_current_opacity() {
|
||||
let mut dim = DimAnimation::new(0.0);
|
||||
dim.advance(0.15, Duration::from_millis(75), 150.0);
|
||||
let before = dim.current;
|
||||
assert!(dim.advance(0.0, Duration::ZERO, 150.0));
|
||||
assert_eq!(dim.current, before);
|
||||
assert!(!dim.advance(0.0, Duration::from_millis(150), 150.0));
|
||||
assert_eq!(dim.current, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_duration_snaps_for_reduced_motion() {
|
||||
let mut dim = DimAnimation::new(0.15);
|
||||
assert!(!dim.advance(0.0, Duration::ZERO, 0.0));
|
||||
assert_eq!(dim.current, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Visibility of client content on displayed outputs, independent of capture.
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use smithay::backend::renderer::element::{
|
||||
Id, RenderElementPresentationState, RenderElementState, RenderElementStates,
|
||||
};
|
||||
use smithay::output::Output;
|
||||
|
||||
#[derive(Default)]
|
||||
struct OutputPresentation {
|
||||
displayed: HashSet<Id>,
|
||||
queued: Option<HashSet<Id>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct DisplayPresentation {
|
||||
outputs: HashMap<Output, OutputPresentation>,
|
||||
}
|
||||
|
||||
pub(crate) fn is_visible(state: RenderElementState) -> bool {
|
||||
state.visible_area > 0 && state.presentation_state != RenderElementPresentationState::Skipped
|
||||
}
|
||||
|
||||
fn visible(states: &RenderElementStates) -> HashSet<Id> {
|
||||
states
|
||||
.states
|
||||
.iter()
|
||||
.filter(|&(_id, state)| is_visible(*state))
|
||||
.map(|(id, _state)| id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl DisplayPresentation {
|
||||
// Call only after a successful submission, never from an offscreen capture.
|
||||
pub fn queued(&mut self, output: &Output, states: &RenderElementStates) {
|
||||
self.outputs.entry(output.clone()).or_default().queued = Some(visible(states));
|
||||
}
|
||||
|
||||
pub fn presented(&mut self, output: &Output) {
|
||||
if let Some(record) = self.outputs.get_mut(output)
|
||||
&& let Some(queued) = record.queued.take()
|
||||
{
|
||||
record.displayed = queued;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn visible(&self, surface: &Id) -> bool {
|
||||
self.outputs
|
||||
.values()
|
||||
.any(|record| record.displayed.contains(surface))
|
||||
}
|
||||
|
||||
pub fn outputs_for<'a>(&'a self, surface: &'a Id) -> impl Iterator<Item = &'a Output> {
|
||||
self.outputs
|
||||
.iter()
|
||||
.filter(|(_, record)| record.displayed.contains(surface))
|
||||
.map(|(output, _)| output)
|
||||
}
|
||||
|
||||
pub fn remove_output(&mut self, output: &Output) {
|
||||
self.outputs.remove(output);
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.outputs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use smithay::backend::renderer::element::RenderElementState;
|
||||
use smithay::output::{PhysicalProperties, Subpixel};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn output(name: &str) -> Output {
|
||||
Output::new(
|
||||
name.into(),
|
||||
PhysicalProperties {
|
||||
serial_number: String::new(),
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn scene(id: &Id) -> RenderElementStates {
|
||||
let mut states = RenderElementStates::default();
|
||||
states.states.insert(
|
||||
id.clone(),
|
||||
RenderElementState {
|
||||
needs_capture: false,
|
||||
visible_area: 20,
|
||||
presentation_state: RenderElementPresentationState::ZeroCopy,
|
||||
},
|
||||
);
|
||||
states
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_changes_only_when_a_submission_is_presented() {
|
||||
let mut record = DisplayPresentation::default();
|
||||
let output = output("a");
|
||||
let id = Id::new();
|
||||
record.queued(&output, &scene(&id));
|
||||
assert!(!record.visible(&id));
|
||||
record.presented(&output);
|
||||
assert!(record.visible(&id));
|
||||
record.presented(&output); // No new submission keeps the last displayed scene.
|
||||
assert!(record.visible(&id));
|
||||
|
||||
record.queued(&output, &RenderElementStates::default());
|
||||
assert!(record.visible(&id));
|
||||
record.presented(&output);
|
||||
assert!(!record.visible(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outputs_are_independent_and_queued_history_survives_until_presentation() {
|
||||
let mut record = DisplayPresentation::default();
|
||||
let a = output("a");
|
||||
let b = output("b");
|
||||
let id = Id::new();
|
||||
for output in [&a, &b] {
|
||||
record.queued(output, &scene(&id));
|
||||
record.presented(output);
|
||||
}
|
||||
record.remove_output(&a);
|
||||
assert!(record.visible(&id));
|
||||
record.queued(&b, &scene(&id));
|
||||
assert!(record.visible(&id));
|
||||
record.presented(&b);
|
||||
assert!(record.visible(&id));
|
||||
record.clear();
|
||||
assert!(!record.visible(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occluded_and_skipped_content_do_not_count_as_visible() {
|
||||
let id = Id::new();
|
||||
let mut states = scene(&id);
|
||||
states.states.get_mut(&id).unwrap().visible_area = 0;
|
||||
assert!(visible(&states).is_empty());
|
||||
states.states.get_mut(&id).unwrap().visible_area = 20;
|
||||
states.states.get_mut(&id).unwrap().presentation_state =
|
||||
RenderElementPresentationState::Skipped;
|
||||
assert!(visible(&states).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Allocation hints retain the import global's renderer as a composition fallback.
|
||||
use std::collections::HashMap;
|
||||
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
use smithay::wayland::compositor::with_states;
|
||||
use smithay::wayland::dmabuf::{DmabufFeedback, DmabufGlobal, SurfaceDmabufFeedbackState};
|
||||
|
||||
struct Surface {
|
||||
global: DmabufGlobal,
|
||||
fallback: DmabufFeedback,
|
||||
current: DmabufFeedback,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct FeedbackManager {
|
||||
globals: HashMap<DmabufGlobal, DmabufFeedback>,
|
||||
surfaces: HashMap<WlSurface, Surface>,
|
||||
}
|
||||
|
||||
impl FeedbackManager {
|
||||
pub fn register(&mut self, global: DmabufGlobal, render: DmabufFeedback) {
|
||||
self.globals.insert(global, render);
|
||||
}
|
||||
|
||||
pub fn new_surface(
|
||||
&mut self,
|
||||
surface: &WlSurface,
|
||||
global: DmabufGlobal,
|
||||
) -> Option<DmabufFeedback> {
|
||||
let fallback = self.globals.get(&global)?.clone();
|
||||
self.surfaces.insert(
|
||||
surface.clone(),
|
||||
Surface {
|
||||
global,
|
||||
fallback: fallback.clone(),
|
||||
current: fallback.clone(),
|
||||
},
|
||||
);
|
||||
Some(fallback)
|
||||
}
|
||||
|
||||
pub fn requested(&self) -> impl Iterator<Item = (&WlSurface, DmabufGlobal)> {
|
||||
self.surfaces
|
||||
.iter()
|
||||
.map(|(surface, record)| (surface, record.global))
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self, preferred: &HashMap<WlSurface, DmabufFeedback>) {
|
||||
for (surface, record) in &mut self.surfaces {
|
||||
let feedback = if self.globals.contains_key(&record.global) {
|
||||
preferred.get(surface).unwrap_or(&record.fallback)
|
||||
} else {
|
||||
&record.fallback
|
||||
};
|
||||
if &record.current == feedback {
|
||||
continue;
|
||||
}
|
||||
// Avoid locking surface state on every dispatch when its allocation
|
||||
// hints are unchanged. Smithay still owns subscriptions and messages.
|
||||
with_states(surface, |states| {
|
||||
if let Some(state) = SurfaceDmabufFeedbackState::from_states(states) {
|
||||
state.set_feedback(feedback);
|
||||
record.current = feedback.clone();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, global: DmabufGlobal) {
|
||||
self.globals.remove(&global);
|
||||
for (surface, record) in self
|
||||
.surfaces
|
||||
.iter_mut()
|
||||
.filter(|(_, record)| record.global == global)
|
||||
{
|
||||
// V4 requires a main-device tranche. Retain last-known render
|
||||
// capabilities, withdraw scanout hints and retire the global. Bound
|
||||
// resources cannot import through the retired global or another GPU.
|
||||
with_states(surface, |states| {
|
||||
if let Some(state) = SurfaceDmabufFeedbackState::from_states(states) {
|
||||
state.set_feedback(&record.fallback);
|
||||
record.current = record.fallback.clone();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn destroyed(&mut self, surface: &WlSurface) {
|
||||
self.surfaces.remove(surface);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Real v4 feedback subscriptions over a private Wayland socket; no DRM/EGL.
|
||||
use std::io::Read;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
use smithay::backend::allocator::{Format, Fourcc, Modifier};
|
||||
use smithay::output::{Mode, Output, PhysicalProperties, Subpixel};
|
||||
use smithay::reexports::calloop::EventLoop;
|
||||
use smithay::reexports::wayland_protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_feedback_v1::TrancheFlags;
|
||||
use smithay::reexports::wayland_server::Display;
|
||||
use smithay::wayland::dmabuf::DmabufFeedbackBuilder;
|
||||
|
||||
use super::*;
|
||||
use crate::Ferese;
|
||||
use crate::state::{dispatch_test_client as dispatch, test_client_request as request, test_window};
|
||||
|
||||
fn messages(wire: &mut UnixStream) -> Vec<(u32, u32, Vec<u32>)> {
|
||||
wire.set_nonblocking(true).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
let _ = wire.read_to_end(&mut bytes);
|
||||
wire.set_nonblocking(false).unwrap();
|
||||
let mut offset = 0;
|
||||
let mut result = Vec::new();
|
||||
while offset < bytes.len() {
|
||||
let object = u32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap());
|
||||
let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap());
|
||||
let size = (header >> 16) as usize;
|
||||
result.push((
|
||||
object,
|
||||
header & 0xffff,
|
||||
bytes[offset + 8..offset + size]
|
||||
.chunks_exact(4)
|
||||
.map(|word| u32::from_ne_bytes(word.try_into().unwrap()))
|
||||
.collect(),
|
||||
));
|
||||
offset += size;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn flags(events: &[(u32, u32, Vec<u32>)], object: u32) -> Vec<u32> {
|
||||
events
|
||||
.iter()
|
||||
.filter(|(id, opcode, _)| *id == object && *opcode == 6)
|
||||
.map(|(_, _, args)| args[0])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn done(events: &[(u32, u32, Vec<u32>)], object: u32) -> usize {
|
||||
events
|
||||
.iter()
|
||||
.filter(|(id, opcode, _)| *id == object && *opcode == 0)
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscriptions_update_deduplicate_and_retire_device_preferences() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"dmabuf_feedback::tests::subscriptions_update_deduplicate_and_retire_device_preferences",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let mut events = EventLoop::try_new().unwrap();
|
||||
let mut config = crate::config::Config::default().runtime_config().unwrap();
|
||||
config.wallpaper.path = None;
|
||||
let mut state = Ferese::new(&mut events, Display::new().unwrap(), config).unwrap();
|
||||
let output = Output::new(
|
||||
"feedback-test".into(),
|
||||
PhysicalProperties {
|
||||
serial_number: String::new(),
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
output.change_current_state(
|
||||
Some(Mode {
|
||||
size: (100, 100).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
state.space.map_output(&output, (0, 0));
|
||||
state.register_output(&output, "feedback-test".into());
|
||||
let f = Format {
|
||||
code: Fourcc::Argb8888,
|
||||
modifier: Modifier::Linear,
|
||||
};
|
||||
let fallback = DmabufFeedbackBuilder::new(10, [f]).build().unwrap();
|
||||
let preferred = DmabufFeedbackBuilder::new(10, [f])
|
||||
.add_preference_tranche(20, TrancheFlags::Scanout, [f], 4..=6)
|
||||
.build()
|
||||
.unwrap();
|
||||
let global = state
|
||||
.dmabuf_state
|
||||
.create_global_with_default_feedback::<Ferese>(&state.display_handle, &fallback);
|
||||
state.dmabuf_feedback.register(global, fallback.clone());
|
||||
let (window, mut wire) = test_window(&mut state, &mut events, 0xffabcdef);
|
||||
let surface = window.toplevel().unwrap().wl_surface().clone();
|
||||
request(&mut wire, 1, 1, &[7], None); // wl_display.get_registry
|
||||
dispatch(&mut events, &mut state);
|
||||
let registry = messages(&mut wire);
|
||||
let dmabuf_name = registry
|
||||
.iter()
|
||||
.find_map(|(object, opcode, args)| {
|
||||
if *object != 7 || *opcode != 0 {
|
||||
return None;
|
||||
}
|
||||
let bytes = args[2..]
|
||||
.iter()
|
||||
.flat_map(|word| word.to_ne_bytes())
|
||||
.collect::<Vec<_>>();
|
||||
bytes
|
||||
.starts_with(b"zwp_linux_dmabuf_v1\0")
|
||||
.then_some(args[0])
|
||||
})
|
||||
.expect("DMA-BUF v4 global");
|
||||
let name = b"zwp_linux_dmabuf_v1\0";
|
||||
let mut padded = name.to_vec();
|
||||
padded.resize(name.len().next_multiple_of(4), 0);
|
||||
let bind = [dmabuf_name, name.len() as u32]
|
||||
.into_iter()
|
||||
.chain(
|
||||
padded
|
||||
.chunks_exact(4)
|
||||
.map(|word| u32::from_ne_bytes(word.try_into().unwrap())),
|
||||
)
|
||||
.chain([4, 8])
|
||||
.collect::<Vec<_>>();
|
||||
request(&mut wire, 7, 0, &bind, None);
|
||||
request(&mut wire, 8, 3, &[9, 2], None); // get_surface_feedback
|
||||
dispatch(&mut events, &mut state);
|
||||
let initial = messages(&mut wire);
|
||||
assert_eq!(flags(&initial, 9), [0]);
|
||||
assert_eq!(done(&initial, 9), 1);
|
||||
assert_eq!(state.dmabuf_feedback.requested().count(), 1);
|
||||
let main = initial
|
||||
.iter()
|
||||
.find(|(id, opcode, _)| *id == 9 && *opcode == 2)
|
||||
.unwrap();
|
||||
assert_eq!(main.2[0] as usize, std::mem::size_of::<libc::dev_t>());
|
||||
assert_eq!(main.2[1], 10, "main device remains the renderer");
|
||||
|
||||
let selected = HashMap::from([(surface.clone(), preferred.clone())]);
|
||||
state.dmabuf_feedback.refresh(&selected);
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
let changed = messages(&mut wire);
|
||||
assert_eq!(flags(&changed, 9), [TrancheFlags::Scanout.bits(), 0]);
|
||||
assert_eq!(done(&changed, 9), 1);
|
||||
let target = changed
|
||||
.iter()
|
||||
.find(|(id, opcode, _)| *id == 9 && *opcode == 4)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
target.2[1], 20,
|
||||
"scanout tranche targets the primary DRM device"
|
||||
);
|
||||
state.dmabuf_feedback.refresh(&selected);
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
assert!(
|
||||
messages(&mut wire).is_empty(),
|
||||
"unchanged feedback must not be published again"
|
||||
);
|
||||
|
||||
request(&mut wire, 8, 3, &[10, 2], None);
|
||||
dispatch(&mut events, &mut state);
|
||||
let second = messages(&mut wire);
|
||||
assert_eq!(flags(&second, 10), [TrancheFlags::Scanout.bits(), 0]);
|
||||
state.dmabuf_feedback.refresh(&HashMap::new()); // migration/ineligibility fallback
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
let fallback_events = messages(&mut wire);
|
||||
assert_eq!(flags(&fallback_events, 9), [0]);
|
||||
assert_eq!(flags(&fallback_events, 10), [0]);
|
||||
assert_eq!(done(&fallback_events, 9), 1);
|
||||
assert_eq!(done(&fallback_events, 10), 1);
|
||||
|
||||
request(&mut wire, 9, 0, &[], None);
|
||||
request(&mut wire, 10, 0, &[], None);
|
||||
dispatch(&mut events, &mut state);
|
||||
messages(&mut wire);
|
||||
request(&mut wire, 8, 3, &[11, 2], None);
|
||||
request(&mut wire, 8, 2, &[12], None); // get_default_feedback
|
||||
dispatch(&mut events, &mut state);
|
||||
let renewed = messages(&mut wire);
|
||||
assert_eq!(
|
||||
flags(&renewed, 11),
|
||||
[0],
|
||||
"renewal starts with safe render feedback"
|
||||
);
|
||||
assert_eq!(flags(&renewed, 12), [0]);
|
||||
state.dmabuf_feedback.refresh(&selected);
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
let promoted = messages(&mut wire);
|
||||
assert_eq!(flags(&promoted, 11), [TrancheFlags::Scanout.bits(), 0]);
|
||||
state.dmabuf_feedback.remove(global);
|
||||
state
|
||||
.dmabuf_state
|
||||
.disable_global::<Ferese>(&state.display_handle, &global);
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
let retired = messages(&mut wire);
|
||||
assert_eq!(done(&retired, 11), 1);
|
||||
assert_eq!(
|
||||
flags(&retired, 11),
|
||||
[0],
|
||||
"retirement keeps a valid main-device render tranche"
|
||||
);
|
||||
assert_eq!(
|
||||
done(&retired, 12),
|
||||
0,
|
||||
"default renderer capabilities remain unchanged on existing bindings"
|
||||
);
|
||||
assert!(
|
||||
retired
|
||||
.iter()
|
||||
.any(|(id, opcode, args)| *id == 7 && *opcode == 1 && args[0] == dmabuf_name)
|
||||
);
|
||||
state.dmabuf_feedback.refresh(&selected); // A stale choice cannot resurrect a retired preference.
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
assert!(messages(&mut wire).is_empty());
|
||||
assert!(
|
||||
state
|
||||
.dmabuf_feedback
|
||||
.new_surface(&surface, global)
|
||||
.is_none()
|
||||
);
|
||||
drop(wire);
|
||||
dispatch(&mut events, &mut state);
|
||||
assert_eq!(
|
||||
state.dmabuf_feedback.requested().count(),
|
||||
0,
|
||||
"surface destruction must remove subscriptions without a refresh scan"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use smithay::backend::allocator::dmabuf::Dmabuf;
|
||||
use smithay::backend::drm::DrmNode;
|
||||
use smithay::backend::renderer::ImportDma;
|
||||
use smithay::reexports::calloop::RegistrationToken;
|
||||
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
|
||||
use smithay::reexports::wayland_server::backend::ClientId;
|
||||
use smithay::wayland::dmabuf::{DmabufGlobal, ImportNotifier};
|
||||
|
||||
use crate::Ferese;
|
||||
|
||||
const MAX_PENDING: usize = 64;
|
||||
const MAX_PER_CLIENT: usize = 16;
|
||||
const MAX_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
struct Pending<K, C, T> {
|
||||
owner: K,
|
||||
client: C,
|
||||
deadline: Instant,
|
||||
value: T,
|
||||
}
|
||||
|
||||
struct Queue<K, C, T> {
|
||||
pending: VecDeque<Pending<K, C, T>>,
|
||||
}
|
||||
|
||||
impl<K, C, T> Default for Queue<K, C, T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pending: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Eq, C: Eq, T> Queue<K, C, T> {
|
||||
fn enqueue(&mut self, owner: K, client: C, value: T, now: Instant) -> Result<(), T> {
|
||||
if self.pending.len() >= MAX_PENDING
|
||||
|| self
|
||||
.pending
|
||||
.iter()
|
||||
.filter(|entry| entry.client == client)
|
||||
.count()
|
||||
>= MAX_PER_CLIENT
|
||||
{
|
||||
return Err(value);
|
||||
}
|
||||
|
||||
self.pending.push_back(Pending {
|
||||
owner,
|
||||
client,
|
||||
value,
|
||||
deadline: now + MAX_WAIT,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process(&mut self, owner: &K, mut complete: impl FnMut(T)) {
|
||||
self.remove_if(|entry| &entry.owner == owner, |entry| complete(entry.value));
|
||||
}
|
||||
|
||||
fn remove_if(
|
||||
&mut self,
|
||||
mut remove: impl FnMut(&Pending<K, C, T>) -> bool,
|
||||
mut complete: impl FnMut(Pending<K, C, T>),
|
||||
) {
|
||||
for _ in 0..self.pending.len() {
|
||||
let entry = self
|
||||
.pending
|
||||
.pop_front()
|
||||
.expect("the bounded drain removes at most the original queue length");
|
||||
if remove(&entry) {
|
||||
complete(entry);
|
||||
} else {
|
||||
self.pending.push_back(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn deadline(&self) -> Option<Instant> {
|
||||
self.pending.front().map(|entry| entry.deadline)
|
||||
}
|
||||
}
|
||||
|
||||
struct Request {
|
||||
buffer: Dmabuf,
|
||||
notifier: ImportNotifier,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ImportManager {
|
||||
globals: HashSet<DmabufGlobal>,
|
||||
queue: Queue<DmabufGlobal, ClientId, Request>,
|
||||
timer: Option<RegistrationToken>,
|
||||
}
|
||||
|
||||
impl Drop for ImportManager {
|
||||
fn drop(&mut self) {
|
||||
self.queue
|
||||
.remove_if(|_| true, |entry| entry.value.notifier.failed());
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportManager {
|
||||
pub(crate) fn register(&mut self, global: DmabufGlobal) {
|
||||
self.globals.insert(global);
|
||||
}
|
||||
|
||||
pub(crate) fn remove(&mut self, global: DmabufGlobal) {
|
||||
self.globals.remove(&global);
|
||||
self.queue
|
||||
.process(&global, |request| request.notifier.failed());
|
||||
}
|
||||
|
||||
pub(crate) fn enqueue(
|
||||
&mut self,
|
||||
global: DmabufGlobal,
|
||||
buffer: Dmabuf,
|
||||
notifier: ImportNotifier,
|
||||
now: Instant,
|
||||
) {
|
||||
let Some(client) = notifier.client() else {
|
||||
notifier.failed();
|
||||
return;
|
||||
};
|
||||
|
||||
if !self.globals.contains(&global) {
|
||||
notifier.failed();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(request) =
|
||||
self.queue
|
||||
.enqueue(global, client.id(), Request { buffer, notifier }, now)
|
||||
{
|
||||
request.notifier.failed();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn process<R: ImportDma>(
|
||||
&mut self,
|
||||
global: DmabufGlobal,
|
||||
renderer: &mut R,
|
||||
node: Option<DrmNode>,
|
||||
) {
|
||||
self.process_with(global, node, |buffer| {
|
||||
renderer.import_dmabuf(buffer, None).is_ok()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn process_with(
|
||||
&mut self,
|
||||
global: DmabufGlobal,
|
||||
node: Option<DrmNode>,
|
||||
mut import: impl FnMut(&Dmabuf) -> bool,
|
||||
) {
|
||||
self.queue.process(&global, |request| {
|
||||
if request.notifier.client().is_none() {
|
||||
request.notifier.failed();
|
||||
return;
|
||||
}
|
||||
|
||||
if !import(&request.buffer) {
|
||||
request.notifier.failed();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(node) = node {
|
||||
request.buffer.set_node(node);
|
||||
}
|
||||
|
||||
if let Err(error) = request.notifier.successful::<Ferese>() {
|
||||
tracing::debug!(?error, "dma-buf client disappeared before import completed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn expire(&mut self, now: Instant) {
|
||||
self.queue.remove_if(
|
||||
|entry| entry.deadline <= now || entry.value.notifier.client().is_none(),
|
||||
|entry| entry.value.notifier.failed(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
pub(crate) fn process_pending_dmabuf_imports(&mut self) {
|
||||
self.dmabuf_imports.expire(Instant::now());
|
||||
if self.dmabuf_imports.queue.deadline().is_some() {
|
||||
if let Some(backend) = self.direct_backend.as_mut() {
|
||||
backend.process_dmabuf_imports(&mut self.dmabuf_imports);
|
||||
} else if let Some(backend) = self.nested_backend.as_ref() {
|
||||
let mut backend = backend.borrow_mut();
|
||||
// Nested Ferese publishes exactly one DMA-BUF global.
|
||||
if let Some(global) = self.dmabuf_imports.globals.iter().next().copied() {
|
||||
self.dmabuf_imports
|
||||
.process(global, backend.renderer(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(deadline) = self.dmabuf_imports.queue.deadline() {
|
||||
if self.dmabuf_imports.timer.is_none() {
|
||||
match self.loop_handle.insert_source(
|
||||
Timer::from_deadline(deadline),
|
||||
|_, _, state| {
|
||||
state.dmabuf_imports.expire(Instant::now());
|
||||
if let Some(deadline) = state.dmabuf_imports.queue.deadline() {
|
||||
TimeoutAction::ToInstant(deadline)
|
||||
} else {
|
||||
state.dmabuf_imports.timer = None;
|
||||
TimeoutAction::Drop
|
||||
}
|
||||
},
|
||||
) {
|
||||
Ok(token) => self.dmabuf_imports.timer = Some(token),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "could not schedule DMA-BUF import deadline");
|
||||
self.dmabuf_imports
|
||||
.queue
|
||||
.remove_if(|_| true, |entry| entry.value.notifier.failed());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(token) = self.dmabuf_imports.timer.take() {
|
||||
self.loop_handle.remove(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
#[test]
|
||||
fn renderer_order_cannot_consume_another_globals_requests() {
|
||||
for order in [[1, 2], [2, 1]] {
|
||||
let now = Instant::now();
|
||||
let mut queue = Queue::default();
|
||||
queue.enqueue(2, 1, "B-only", now).unwrap();
|
||||
let mut results = Vec::new();
|
||||
for owner in order {
|
||||
queue.process(&owner, |buffer| {
|
||||
assert_eq!(owner, 2);
|
||||
assert_eq!(buffer, "B-only");
|
||||
results.push(true);
|
||||
});
|
||||
}
|
||||
|
||||
assert_eq!(results, [true]);
|
||||
assert!(queue.pending.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_imports_are_bounded_and_recover_before_the_deadline() {
|
||||
let now = Instant::now();
|
||||
let mut queue = Queue::default();
|
||||
for index in 0..MAX_PENDING {
|
||||
queue
|
||||
.enqueue(1, index / MAX_PER_CLIENT, index, now)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(queue.enqueue(1, 99, 100, now), Err(100));
|
||||
let mut completed = Vec::new();
|
||||
queue.remove_if(
|
||||
|entry| entry.deadline <= now + MAX_WAIT / 2,
|
||||
|_| panic!("expired early"),
|
||||
);
|
||||
queue.process(&1, |value| completed.push(value));
|
||||
assert_eq!(completed, (0..MAX_PENDING).collect::<Vec<_>>());
|
||||
assert!(queue.deadline().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_client_admission_leaves_room_for_other_clients() {
|
||||
let now = Instant::now();
|
||||
let mut queue = Queue::default();
|
||||
for index in 0..MAX_PER_CLIENT {
|
||||
queue.enqueue(1, 1, index, now).unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(queue.enqueue(2, 1, 100, now), Err(100));
|
||||
queue.enqueue(2, 2, 101, now).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_and_device_removal_release_resources_once_without_touching_other_owners() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"dmabuf_imports::tests::expiry_and_device_removal_release_resources_once_without_touching_other_owners",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let mut queue = Queue::default();
|
||||
let removed = tempfile::tempfile().unwrap();
|
||||
let expired = tempfile::tempfile().unwrap();
|
||||
let fd_removed = removed.as_raw_fd();
|
||||
let fd_expired = expired.as_raw_fd();
|
||||
queue.enqueue(1, 1, removed, now).unwrap();
|
||||
queue
|
||||
.enqueue(2, 1, expired, now + Duration::from_secs(1))
|
||||
.unwrap();
|
||||
let mut failures = 0;
|
||||
queue.process(&1, |_| failures += 1);
|
||||
// SAFETY: F_GETFD observes descriptor validity without modifying it.
|
||||
assert_eq!(unsafe { libc::fcntl(fd_removed, libc::F_GETFD) }, -1);
|
||||
// SAFETY: F_GETFD observes an integer descriptor without modifying it or dereferencing
|
||||
// caller memory.
|
||||
assert_ne!(unsafe { libc::fcntl(fd_expired, libc::F_GETFD) }, -1);
|
||||
queue.remove_if(
|
||||
|entry| entry.deadline <= now + MAX_WAIT,
|
||||
|_| panic!("expired early"),
|
||||
);
|
||||
queue.remove_if(
|
||||
|entry| entry.deadline <= now + MAX_WAIT + Duration::from_secs(1),
|
||||
|_| failures += 1,
|
||||
);
|
||||
// SAFETY: F_GETFD observes an integer descriptor without modifying it or dereferencing
|
||||
// caller memory.
|
||||
assert_eq!(unsafe { libc::fcntl(fd_expired, libc::F_GETFD) }, -1);
|
||||
assert_eq!(failures, 2);
|
||||
assert!(queue.deadline().is_none());
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ use ferese_protocols::effects::v1::server::ferese_surface_effects_v1::FereseSurf
|
||||
use ferese_protocols::effects::v1::server::{ferese_effects_manager_v1, ferese_surface_effects_v1};
|
||||
use ferese_protocols::material::v1::server::ferese_material_manager_v1::FereseMaterialManagerV1;
|
||||
use ferese_protocols::material::v1::server::ferese_surface_material_v1::FereseSurfaceMaterialV1;
|
||||
use ferese_protocols::material::v1::server::{ferese_material_manager_v1, ferese_surface_material_v1};
|
||||
use ferese_protocols::material::v1::server::{
|
||||
ferese_material_manager_v1, ferese_surface_material_v1,
|
||||
};
|
||||
use smithay::reexports::wayland_server::backend::ClientId;
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
use smithay::reexports::wayland_server::{
|
||||
@@ -37,10 +39,16 @@ pub(crate) struct ResolvedMaterial {
|
||||
pub shadow: [f64; 3],
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle, opacity: f32) -> ResolvedMaterial {
|
||||
pub(crate) fn resolve_material(
|
||||
role: SemanticRole,
|
||||
style: MaterialStyle,
|
||||
opacity: f32,
|
||||
) -> ResolvedMaterial {
|
||||
let shadow = match role {
|
||||
SemanticRole::Panel => [1.0, 5.0, 0.04],
|
||||
SemanticRole::PanelElevated | SemanticRole::Popover | SemanticRole::Menu => [2.0, 8.0, 0.07],
|
||||
SemanticRole::PanelElevated | SemanticRole::Popover | SemanticRole::Menu => {
|
||||
[2.0, 8.0, 0.07]
|
||||
}
|
||||
SemanticRole::Hud => [1.0, 4.0, 0.04],
|
||||
SemanticRole::Notification | SemanticRole::Modal => [3.0, 10.0, 0.09],
|
||||
};
|
||||
@@ -48,7 +56,26 @@ pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle, opacity
|
||||
MaterialStyle::Solid => 1.0,
|
||||
MaterialStyle::Translucent => opacity.clamp(0.0, 1.0),
|
||||
};
|
||||
ResolvedMaterial { style, opacity, shadow }
|
||||
ResolvedMaterial {
|
||||
style,
|
||||
opacity,
|
||||
shadow,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct PanelPresentation {
|
||||
pub shapes: std::sync::Arc<[ferese_panel::scene::Primitive]>,
|
||||
pub fusion: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PendingPresentation {
|
||||
panel: Option<PanelPresentation>,
|
||||
role: SemanticRole,
|
||||
regions: Vec<[f64; 5]>,
|
||||
opacity: u32,
|
||||
region_opacities: Vec<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -58,6 +85,9 @@ struct SurfaceEffectsState {
|
||||
role: Mutex<Option<SemanticRole>>,
|
||||
regions: Mutex<Option<Vec<[f64; 5]>>>,
|
||||
opacity: Mutex<u32>,
|
||||
pending: Mutex<Option<PendingPresentation>>,
|
||||
panel: Mutex<Option<PanelPresentation>>,
|
||||
region_opacities: Mutex<Vec<u32>>,
|
||||
presentation_supported: AtomicBool,
|
||||
dismissing: AtomicBool,
|
||||
}
|
||||
@@ -70,13 +100,61 @@ impl SurfaceEffectsState {
|
||||
role: Mutex::new(None),
|
||||
regions: Mutex::new(None),
|
||||
opacity: Mutex::new(1000),
|
||||
pending: Mutex::new(None),
|
||||
panel: Mutex::new(None),
|
||||
region_opacities: Mutex::new(Vec::new()),
|
||||
presentation_supported: AtomicBool::new(false),
|
||||
dismissing: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_presentation(&self) {
|
||||
let Some(pending) = self
|
||||
.pending
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics")
|
||||
.take()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
*self
|
||||
.role
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics") =
|
||||
Some(pending.role);
|
||||
*self
|
||||
.panel
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics") = pending.panel;
|
||||
*self
|
||||
.regions
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics") =
|
||||
Some(pending.regions);
|
||||
*self
|
||||
.region_opacities
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics") =
|
||||
pending.region_opacities;
|
||||
if !self.dismissing.load(Ordering::Acquire) {
|
||||
*self
|
||||
.opacity
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics") =
|
||||
pending.opacity;
|
||||
}
|
||||
self.generation.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
|
||||
fn set_role(&self, role: Option<SemanticRole>) -> bool {
|
||||
let mut current = self.role.lock().unwrap();
|
||||
self.pending
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics")
|
||||
.take();
|
||||
let mut current = self
|
||||
.role
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics");
|
||||
if *current != role {
|
||||
*current = role;
|
||||
self.generation.fetch_add(1, Ordering::Release);
|
||||
@@ -96,12 +174,16 @@ impl SurfaceEffectsUserData {
|
||||
}
|
||||
|
||||
fn surface(&self) -> Option<WlSurface> {
|
||||
self.0.lock().unwrap().upgrade().ok()
|
||||
self.0
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics")
|
||||
.upgrade()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn init_global(display: &DisplayHandle) {
|
||||
display.create_global::<Ferese, FereseEffectsManagerV1, _>(3, ());
|
||||
display.create_global::<Ferese, FereseEffectsManagerV1, _>(6, ());
|
||||
display.create_global::<Ferese, FereseMaterialManagerV1, _>(1, ());
|
||||
}
|
||||
|
||||
@@ -131,8 +213,13 @@ impl Dispatch<FereseMaterialManagerV1, ()> for Ferese {
|
||||
match request {
|
||||
ferese_material_manager_v1::Request::GetSurfaceMaterial { id, surface } => {
|
||||
let attached = with_states(&surface, |states| {
|
||||
states.data_map.insert_if_missing_threadsafe(SurfaceEffectsState::new);
|
||||
let effects = states.data_map.get::<SurfaceEffectsState>().unwrap();
|
||||
states
|
||||
.data_map
|
||||
.insert_if_missing_threadsafe(SurfaceEffectsState::new);
|
||||
let effects = states
|
||||
.data_map
|
||||
.get::<SurfaceEffectsState>()
|
||||
.expect("effects state was inserted before this access");
|
||||
effects.attached.swap(true, Ordering::AcqRel)
|
||||
});
|
||||
if attached {
|
||||
@@ -145,7 +232,7 @@ impl Dispatch<FereseMaterialManagerV1, ()> for Ferese {
|
||||
let material = data_init.init(id, SurfaceEffectsUserData::new(surface.clone()));
|
||||
set_surface_role(&surface, Some(SemanticRole::Modal));
|
||||
material.ready();
|
||||
crate::backends::direct::render_all(state);
|
||||
crate::backends::direct::render_surface(state, &surface);
|
||||
}
|
||||
ferese_material_manager_v1::Request::Destroy => {}
|
||||
_ => unreachable!(),
|
||||
@@ -167,7 +254,7 @@ impl Dispatch<FereseSurfaceMaterialV1, SurfaceEffectsUserData> for Ferese {
|
||||
&& let Some(surface) = data.surface()
|
||||
&& detach(&surface)
|
||||
{
|
||||
crate::backends::direct::render_all(state);
|
||||
crate::backends::direct::render_surface(state, &surface);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +270,14 @@ impl Dispatch<FereseSurfaceMaterialV1, SurfaceEffectsUserData> for Ferese {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn commit_presentation(surface: &WlSurface) {
|
||||
with_states(surface, |states| {
|
||||
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
|
||||
effects.commit_presentation();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[f64; 5]>> {
|
||||
with_states(surface, |states| {
|
||||
states
|
||||
@@ -190,13 +285,86 @@ pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[f64; 5]>> {
|
||||
.get::<SurfaceEffectsState>()?
|
||||
.regions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.expect("surface effect state must not be used after a mutator panics")
|
||||
.clone()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn surface_panel_presentation(surface: &WlSurface) -> Option<PanelPresentation> {
|
||||
with_states(surface, |states| {
|
||||
states
|
||||
.data_map
|
||||
.get::<SurfaceEffectsState>()?
|
||||
.panel
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics")
|
||||
.clone()
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_panel_shapes(bytes: &[u8], fusion: f64) -> Option<PanelPresentation> {
|
||||
if !bytes.len().is_multiple_of(40)
|
||||
|| bytes.len() > ferese_panel::scene::MAX_PRIMITIVES * 40
|
||||
|| !fusion.is_finite()
|
||||
|| !(0. ..=256.).contains(&fusion)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let shapes: Vec<_> = bytes
|
||||
.as_chunks::<40>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|tuple| {
|
||||
let values = std::array::from_fn(|i| f32::from_ne_bytes(tuple.as_chunks::<4>().0[i]));
|
||||
ferese_panel::scene::Primitive::from_packet(values).ok()
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
if shapes.iter().filter(|p| p.operation == 1).count() > 1 {
|
||||
return None;
|
||||
}
|
||||
Some(PanelPresentation {
|
||||
shapes: shapes.into(),
|
||||
fusion,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn surface_region_opacities(surface: &WlSurface) -> Vec<u32> {
|
||||
with_states(surface, |states| {
|
||||
states
|
||||
.data_map
|
||||
.get::<SurfaceEffectsState>()
|
||||
.map(|effects| {
|
||||
effects
|
||||
.region_opacities
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics")
|
||||
.clone()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_region_opacities(bytes: &[u8]) -> Option<Vec<u32>> {
|
||||
if !bytes.len().is_multiple_of(4)
|
||||
|| bytes.len() > ferese_protocols::effects::v1::MAX_REGIONS * 4
|
||||
{
|
||||
return None;
|
||||
}
|
||||
bytes
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let value = u32::from_ne_bytes(*v);
|
||||
(value <= 1000).then_some(value)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn decode_regions(bytes: &[u8]) -> Option<Vec<[f64; 5]>> {
|
||||
if !bytes.len().is_multiple_of(20) || bytes.len() > 32 * 20 {
|
||||
if !bytes.len().is_multiple_of(20)
|
||||
|| bytes.len() > ferese_protocols::effects::v1::MAX_REGIONS * 20
|
||||
{
|
||||
return None;
|
||||
}
|
||||
bytes
|
||||
@@ -204,7 +372,8 @@ fn decode_regions(bytes: &[u8]) -> Option<Vec<[f64; 5]>> {
|
||||
.0
|
||||
.iter()
|
||||
.map(|tuple| {
|
||||
let values: [f64; 5] = std::array::from_fn(|i| f64::from(f32::from_ne_bytes(tuple.as_chunks::<4>().0[i])));
|
||||
let values: [f64; 5] =
|
||||
std::array::from_fn(|i| f64::from(f32::from_ne_bytes(tuple.as_chunks::<4>().0[i])));
|
||||
(values[2] > 0.0
|
||||
&& values[3] > 0.0
|
||||
&& values[4] >= 0.0
|
||||
@@ -219,7 +388,10 @@ fn decode_regions(bytes: &[u8]) -> Option<Vec<[f64; 5]>> {
|
||||
pub(crate) fn surface_role(surface: &WlSurface) -> Option<(SemanticRole, u64)> {
|
||||
with_states(surface, |states| {
|
||||
let effects = states.data_map.get::<SurfaceEffectsState>()?;
|
||||
let role = *effects.role.lock().unwrap();
|
||||
let role = *effects
|
||||
.role
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics");
|
||||
role.map(|role| (role, effects.generation.load(Ordering::Acquire)))
|
||||
})
|
||||
}
|
||||
@@ -256,11 +428,13 @@ impl Dispatch<FereseEffectsManagerV1, ()> for Ferese {
|
||||
match request {
|
||||
ferese_effects_manager_v1::Request::GetSurfaceEffects { id, surface } => {
|
||||
let already_attached = with_states(&surface, |states| {
|
||||
states.data_map.insert_if_missing_threadsafe(SurfaceEffectsState::new);
|
||||
states
|
||||
.data_map
|
||||
.insert_if_missing_threadsafe(SurfaceEffectsState::new);
|
||||
states
|
||||
.data_map
|
||||
.get::<SurfaceEffectsState>()
|
||||
.unwrap()
|
||||
.expect("effects state was inserted before this access")
|
||||
.attached
|
||||
.swap(true, Ordering::AcqRel)
|
||||
});
|
||||
@@ -276,7 +450,7 @@ impl Dispatch<FereseEffectsManagerV1, ()> for Ferese {
|
||||
states
|
||||
.data_map
|
||||
.get::<SurfaceEffectsState>()
|
||||
.unwrap()
|
||||
.expect("effects state was inserted before this access")
|
||||
.presentation_supported
|
||||
.store(effects.version() >= 3, Ordering::Release)
|
||||
});
|
||||
@@ -303,6 +477,62 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
|
||||
};
|
||||
|
||||
match request {
|
||||
ferese_surface_effects_v1::Request::SetPanelPresentation {
|
||||
regions,
|
||||
shapes,
|
||||
fusion,
|
||||
opacity,
|
||||
} => {
|
||||
let (Some(regions), Some(panel)) = (
|
||||
decode_regions(®ions),
|
||||
decode_panel_shapes(&shapes, fusion),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
if panel.shapes.is_empty() != regions.is_empty() {
|
||||
return;
|
||||
}
|
||||
with_states(&surface, |states| {
|
||||
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
|
||||
*effects.pending.lock().expect(
|
||||
"surface effect state must not be used after a mutator panics",
|
||||
) = Some(PendingPresentation {
|
||||
panel: Some(panel),
|
||||
role: SemanticRole::Panel,
|
||||
region_opacities: vec![1000; regions.len()],
|
||||
regions,
|
||||
opacity: opacity.min(1000),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
ferese_surface_effects_v1::Request::SetPresentation {
|
||||
role,
|
||||
regions,
|
||||
opacity,
|
||||
region_opacities,
|
||||
} => {
|
||||
let (Some(role), Some(regions), Some(region_opacities)) = (
|
||||
decode_role(role),
|
||||
decode_regions(®ions),
|
||||
decode_region_opacities(®ion_opacities),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
with_states(&surface, |states| {
|
||||
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
|
||||
*effects.pending.lock().expect(
|
||||
"surface effect state must not be used after a mutator panics",
|
||||
) = Some(PendingPresentation {
|
||||
panel: None,
|
||||
role,
|
||||
regions,
|
||||
opacity: opacity.min(1000),
|
||||
region_opacities,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
ferese_surface_effects_v1::Request::SetOpacity { opacity } => {
|
||||
let changed = with_states(&surface, |states| {
|
||||
let Some(effects) = states.data_map.get::<SurfaceEffectsState>() else {
|
||||
@@ -311,7 +541,10 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
|
||||
if effects.dismissing.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
let mut current = effects.opacity.lock().unwrap();
|
||||
let mut current = effects
|
||||
.opacity
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics");
|
||||
let opacity = opacity.min(1000);
|
||||
if *current == opacity {
|
||||
return false;
|
||||
@@ -321,22 +554,38 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
|
||||
true
|
||||
});
|
||||
if changed {
|
||||
crate::backends::direct::render_all(state);
|
||||
crate::backends::direct::render_surface(state, &surface);
|
||||
}
|
||||
}
|
||||
ferese_surface_effects_v1::Request::SetRole { role } => {
|
||||
let role = match role {
|
||||
WEnum::Value(ferese_surface_effects_v1::Role::Panel) => SemanticRole::Panel,
|
||||
WEnum::Value(ferese_surface_effects_v1::Role::PanelElevated) => SemanticRole::PanelElevated,
|
||||
WEnum::Value(ferese_surface_effects_v1::Role::Popover) => SemanticRole::Popover,
|
||||
WEnum::Value(ferese_surface_effects_v1::Role::Menu) => SemanticRole::Menu,
|
||||
WEnum::Value(ferese_surface_effects_v1::Role::Notification) => SemanticRole::Notification,
|
||||
WEnum::Value(ferese_surface_effects_v1::Role::Hud) => SemanticRole::Hud,
|
||||
WEnum::Value(ferese_surface_effects_v1::Role::Modal) => SemanticRole::Modal,
|
||||
WEnum::Unknown(_) | WEnum::Value(_) => return,
|
||||
let Some(role) = decode_role(role) else {
|
||||
return;
|
||||
};
|
||||
if set_surface_role(&surface, Some(role)) {
|
||||
crate::backends::direct::render_all(state);
|
||||
crate::backends::direct::render_surface(state, &surface);
|
||||
}
|
||||
}
|
||||
ferese_surface_effects_v1::Request::SetRegionOpacities { opacities } => {
|
||||
let Some(opacities) = decode_region_opacities(&opacities) else {
|
||||
return;
|
||||
};
|
||||
let changed = with_states(&surface, |states| {
|
||||
let Some(effects) = states.data_map.get::<SurfaceEffectsState>() else {
|
||||
return false;
|
||||
};
|
||||
let mut old = effects
|
||||
.region_opacities
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics");
|
||||
if *old == opacities {
|
||||
return false;
|
||||
}
|
||||
*old = opacities;
|
||||
effects.generation.fetch_add(1, Ordering::Release);
|
||||
true
|
||||
});
|
||||
if changed {
|
||||
crate::backends::direct::render_surface(state, &surface);
|
||||
}
|
||||
}
|
||||
ferese_surface_effects_v1::Request::SetRegions { regions } => {
|
||||
@@ -347,7 +596,10 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
|
||||
let Some(effects) = states.data_map.get::<SurfaceEffectsState>() else {
|
||||
return false;
|
||||
};
|
||||
let mut old = effects.regions.lock().unwrap();
|
||||
let mut old = effects
|
||||
.regions
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics");
|
||||
if old.as_ref() == Some(®ions) {
|
||||
return false;
|
||||
}
|
||||
@@ -356,17 +608,17 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
|
||||
true
|
||||
});
|
||||
if changed {
|
||||
crate::backends::direct::render_all(state);
|
||||
crate::backends::direct::render_surface(state, &surface);
|
||||
}
|
||||
}
|
||||
ferese_surface_effects_v1::Request::ClearRole => {
|
||||
if set_surface_role(&surface, None) {
|
||||
crate::backends::direct::render_all(state);
|
||||
crate::backends::direct::render_surface(state, &surface);
|
||||
}
|
||||
}
|
||||
ferese_surface_effects_v1::Request::Destroy => {
|
||||
if detach(&surface) {
|
||||
crate::backends::direct::render_all(state);
|
||||
crate::backends::direct::render_surface(state, &surface);
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
@@ -385,6 +637,19 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_role(role: WEnum<ferese_surface_effects_v1::Role>) -> Option<SemanticRole> {
|
||||
Some(match role {
|
||||
WEnum::Value(ferese_surface_effects_v1::Role::Panel) => SemanticRole::Panel,
|
||||
WEnum::Value(ferese_surface_effects_v1::Role::PanelElevated) => SemanticRole::PanelElevated,
|
||||
WEnum::Value(ferese_surface_effects_v1::Role::Popover) => SemanticRole::Popover,
|
||||
WEnum::Value(ferese_surface_effects_v1::Role::Menu) => SemanticRole::Menu,
|
||||
WEnum::Value(ferese_surface_effects_v1::Role::Notification) => SemanticRole::Notification,
|
||||
WEnum::Value(ferese_surface_effects_v1::Role::Hud) => SemanticRole::Hud,
|
||||
WEnum::Value(ferese_surface_effects_v1::Role::Modal) => SemanticRole::Modal,
|
||||
WEnum::Unknown(_) | WEnum::Value(_) => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_surface_role(surface: &WlSurface, role: Option<SemanticRole>) -> bool {
|
||||
with_states(surface, |states| {
|
||||
states
|
||||
@@ -400,8 +665,26 @@ fn detach(surface: &WlSurface) -> bool {
|
||||
return false;
|
||||
};
|
||||
let role_changed = effects.set_role(None);
|
||||
effects
|
||||
.region_opacities
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics")
|
||||
.clear();
|
||||
effects
|
||||
.pending
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics")
|
||||
.take();
|
||||
effects
|
||||
.panel
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics")
|
||||
.take();
|
||||
effects.dismissing.store(false, Ordering::Release);
|
||||
let mut opacity = effects.opacity.lock().unwrap();
|
||||
let mut opacity = effects
|
||||
.opacity
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics");
|
||||
let opacity_changed = *opacity != 1000;
|
||||
*opacity = 1000;
|
||||
let was_attached = effects.attached.swap(false, Ordering::AcqRel);
|
||||
@@ -414,24 +697,38 @@ pub(crate) fn surface_opacity(surface: &WlSurface) -> f32 {
|
||||
states
|
||||
.data_map
|
||||
.get::<SurfaceEffectsState>()
|
||||
.map_or(1.0, |effects| *effects.opacity.lock().unwrap() as f32 / 1000.0)
|
||||
.map_or(1.0, |effects| {
|
||||
*effects
|
||||
.opacity
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics")
|
||||
as f32
|
||||
/ 1000.0
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn begin_surface_dismiss(surface: &WlSurface) -> bool {
|
||||
with_states(surface, |states| {
|
||||
states.data_map.get::<SurfaceEffectsState>().is_some_and(|effects| {
|
||||
effects.presentation_supported.load(Ordering::Acquire)
|
||||
&& effects.attached.load(Ordering::Acquire)
|
||||
&& !effects.dismissing.swap(true, Ordering::AcqRel)
|
||||
})
|
||||
states
|
||||
.data_map
|
||||
.get::<SurfaceEffectsState>()
|
||||
.is_some_and(|effects| {
|
||||
effects.presentation_supported.load(Ordering::Acquire)
|
||||
&& effects.attached.load(Ordering::Acquire)
|
||||
&& !effects.dismissing.swap(true, Ordering::AcqRel)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) {
|
||||
with_states(surface, |states| {
|
||||
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
|
||||
*effects.opacity.lock().unwrap() = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
||||
*effects
|
||||
.opacity
|
||||
.lock()
|
||||
.expect("surface effect state must not be used after a mutator panics") =
|
||||
(opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
||||
effects.generation.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
});
|
||||
@@ -441,6 +738,123 @@ pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn shape_bytes(operation: f32) -> Vec<u8> {
|
||||
[1.25f32, 2.5, 100., 36., 0., 0., 18., 18., operation, 0.]
|
||||
.into_iter()
|
||||
.flat_map(f32::to_ne_bytes)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_shapes_reject_malformed_packets_and_duplicate_cutouts() {
|
||||
let bytes = shape_bytes(0.);
|
||||
let decoded = decode_panel_shapes(&bytes, 12.).unwrap();
|
||||
assert_eq!(decoded.shapes.len(), 1);
|
||||
assert_eq!(decoded.shapes[0].bounds.x, 1.25);
|
||||
assert_eq!(decoded.fusion, 12.);
|
||||
assert!(decode_panel_shapes(&[], 0.).unwrap().shapes.is_empty());
|
||||
assert!(decode_panel_shapes(&bytes[..39], 0.).is_none());
|
||||
assert!(decode_panel_shapes(&bytes.repeat(33), 0.).is_none());
|
||||
assert!(decode_panel_shapes(&shape_bytes(1.).repeat(2), 0.).is_none());
|
||||
for invalid in [f64::NAN, f64::INFINITY, -1., 257.] {
|
||||
assert!(decode_panel_shapes(&bytes, invalid).is_none());
|
||||
}
|
||||
for invalid in [f32::NAN, 0.5, 6.] {
|
||||
assert!(decode_panel_shapes(&shape_bytes(invalid), 0.).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_shape_and_capture_regions_commit_together_and_clear_for_popovers() {
|
||||
let state = SurfaceEffectsState::new();
|
||||
*state.pending.lock().unwrap() = Some(PendingPresentation {
|
||||
panel: decode_panel_shapes(&shape_bytes(0.), 12.),
|
||||
role: SemanticRole::Panel,
|
||||
regions: vec![[1.25, 2.5, 100., 36., 0.]],
|
||||
opacity: 1000,
|
||||
region_opacities: vec![1000],
|
||||
});
|
||||
assert!(state.panel.lock().unwrap().is_none());
|
||||
assert!(state.regions.lock().unwrap().is_none());
|
||||
state.commit_presentation();
|
||||
assert_eq!(state.panel.lock().unwrap().as_ref().unwrap().fusion, 12.);
|
||||
assert_eq!(state.regions.lock().unwrap().as_ref().unwrap().len(), 1);
|
||||
*state.pending.lock().unwrap() = Some(PendingPresentation {
|
||||
panel: None,
|
||||
role: SemanticRole::Popover,
|
||||
regions: vec![[0., 0., 200., 100., 14.]],
|
||||
opacity: 800,
|
||||
region_opacities: vec![1000],
|
||||
});
|
||||
state.commit_presentation();
|
||||
assert!(state.panel.lock().unwrap().is_none());
|
||||
assert_eq!(*state.role.lock().unwrap(), Some(SemanticRole::Popover));
|
||||
assert_eq!(*state.opacity.lock().unwrap(), 800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_a_panel_cancels_its_queued_presentation() {
|
||||
let state = SurfaceEffectsState::new();
|
||||
state.set_role(Some(SemanticRole::Panel));
|
||||
*state.pending.lock().unwrap() = Some(PendingPresentation {
|
||||
panel: decode_panel_shapes(&shape_bytes(0.), 0.),
|
||||
role: SemanticRole::Panel,
|
||||
regions: vec![[0., 0., 100., 36., 0.]],
|
||||
opacity: 1000,
|
||||
region_opacities: vec![1000],
|
||||
});
|
||||
state.set_role(None);
|
||||
state.commit_presentation();
|
||||
assert_eq!(*state.role.lock().unwrap(), None);
|
||||
assert!(state.panel.lock().unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presentation_waits_for_buffer_commit_and_keeps_dismissal_ownership() {
|
||||
let state = SurfaceEffectsState::new();
|
||||
let queue = |opacity| PendingPresentation {
|
||||
panel: None,
|
||||
role: SemanticRole::Popover,
|
||||
regions: vec![[1.25, 2.5, 100.0, 80.0, 12.0]],
|
||||
opacity,
|
||||
region_opacities: vec![500],
|
||||
};
|
||||
*state.pending.lock().unwrap() = Some(queue(900));
|
||||
assert_eq!(*state.opacity.lock().unwrap(), 1000);
|
||||
assert!(state.regions.lock().unwrap().is_none());
|
||||
assert_eq!(state.generation.load(Ordering::Acquire), 0);
|
||||
// Several requests before one buffer commit present only the last.
|
||||
*state.pending.lock().unwrap() = Some(queue(800));
|
||||
state.commit_presentation();
|
||||
assert_eq!(*state.opacity.lock().unwrap(), 800);
|
||||
assert_eq!(state.regions.lock().unwrap().as_ref().unwrap()[0][0], 1.25);
|
||||
assert_eq!(*state.region_opacities.lock().unwrap(), [500]);
|
||||
assert_eq!(state.generation.load(Ordering::Acquire), 1);
|
||||
state.commit_presentation();
|
||||
assert_eq!(state.generation.load(Ordering::Acquire), 1);
|
||||
state.dismissing.store(true, Ordering::Release);
|
||||
*state.pending.lock().unwrap() = Some(queue(1000));
|
||||
state.commit_presentation();
|
||||
assert_eq!(
|
||||
*state.opacity.lock().unwrap(),
|
||||
800,
|
||||
"client cannot restart compositor fade"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_opacities_validate_independent_fades() {
|
||||
let bytes: Vec<_> = [0u32, 500, 1000]
|
||||
.into_iter()
|
||||
.flat_map(u32::to_ne_bytes)
|
||||
.collect();
|
||||
assert_eq!(decode_region_opacities(&bytes), Some(vec![0, 500, 1000]));
|
||||
assert_eq!(decode_region_opacities(&[]), Some(vec![]));
|
||||
assert!(decode_region_opacities(&bytes[..11]).is_none());
|
||||
assert!(decode_region_opacities(&1001u32.to_ne_bytes()).is_none());
|
||||
assert!(decode_region_opacities(&[0; 33 * 4]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_regions_validate_all_wire_values() {
|
||||
let region = [0.25f32, -0.75, 100.5, 40.25, 13.5];
|
||||
@@ -464,6 +878,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_panel_groups_and_overflow_fit_one_effects_publication() {
|
||||
let count = ferese_panel::MAX_GROUPS + 1;
|
||||
assert_eq!(count, ferese_protocols::effects::v1::MAX_REGIONS);
|
||||
let region = [0.0f32, 0., 12., 36., 4.];
|
||||
let bytes: Vec<_> = region.into_iter().flat_map(f32::to_ne_bytes).collect();
|
||||
assert_eq!(decode_regions(&bytes.repeat(count)).unwrap().len(), count);
|
||||
assert_eq!(
|
||||
decode_region_opacities(&1000u32.to_ne_bytes().repeat(count))
|
||||
.unwrap()
|
||||
.len(),
|
||||
count
|
||||
);
|
||||
assert!(decode_regions(&bytes.repeat(count + 1)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_changes_advance_generation_only_when_material_changes() {
|
||||
let effects = SurfaceEffectsState::new();
|
||||
|
||||
@@ -19,8 +19,12 @@ pub fn intersection(a: Rect, b: Rect) -> Option<Rect> {
|
||||
|
||||
pub fn clamp_to_work(mut rect: Rect, work: Rect) -> Rect {
|
||||
// Preserve client minimum sizes even when the work area is smaller.
|
||||
rect.x = rect.x.clamp(work.x, work.x + (work.width - rect.width).max(0.0));
|
||||
rect.y = rect.y.clamp(work.y, work.y + (work.height - rect.height).max(0.0));
|
||||
rect.x = rect
|
||||
.x
|
||||
.clamp(work.x, work.x + (work.width - rect.width).max(0.0));
|
||||
rect.y = rect
|
||||
.y
|
||||
.clamp(work.y, work.y + (work.height - rect.height).max(0.0));
|
||||
rect
|
||||
}
|
||||
|
||||
@@ -35,14 +39,27 @@ pub fn centered(size: (f64, f64), anchor: Rect) -> Rect {
|
||||
|
||||
pub fn anchored_size(rect: Rect, size: (f64, f64), left: bool, top: bool) -> Rect {
|
||||
Rect::new(
|
||||
if left { rect.x + rect.width - size.0 } else { rect.x },
|
||||
if top { rect.y + rect.height - size.1 } else { rect.y },
|
||||
if left {
|
||||
rect.x + rect.width - size.0
|
||||
} else {
|
||||
rect.x
|
||||
},
|
||||
if top {
|
||||
rect.y + rect.height - size.1
|
||||
} else {
|
||||
rect.y
|
||||
},
|
||||
size.0,
|
||||
size.1,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn min_overlap(size: (f64, f64), work: Rect, others: &[Rect], anchor: (f64, f64)) -> Option<Rect> {
|
||||
pub fn min_overlap(
|
||||
size: (f64, f64),
|
||||
work: Rect,
|
||||
others: &[Rect],
|
||||
anchor: (f64, f64),
|
||||
) -> Option<Rect> {
|
||||
let (width, height) = size;
|
||||
if !width.is_finite()
|
||||
|| !height.is_finite()
|
||||
@@ -77,12 +94,15 @@ pub fn min_overlap(size: (f64, f64), work: Rect, others: &[Rect], anchor: (f64,
|
||||
.filter_map(|other| intersection(rect, *other))
|
||||
.map(|r| r.width * r.height)
|
||||
.sum::<f64>();
|
||||
let distance = (x + width / 2.0 - anchor.0).powi(2) + (y + height / 2.0 - anchor.1).powi(2);
|
||||
let distance =
|
||||
(x + width / 2.0 - anchor.0).powi(2) + (y + height / 2.0 - anchor.1).powi(2);
|
||||
let cost = (overlap, distance);
|
||||
if best
|
||||
.as_ref()
|
||||
.is_none_or(|(old, _)| cost.0.total_cmp(&old.0).then(cost.1.total_cmp(&old.1)).is_lt())
|
||||
{
|
||||
if best.as_ref().is_none_or(|(old, _)| {
|
||||
cost.0
|
||||
.total_cmp(&old.0)
|
||||
.then(cost.1.total_cmp(&old.1))
|
||||
.is_lt()
|
||||
}) {
|
||||
best = Some((cost, rect));
|
||||
}
|
||||
}
|
||||
@@ -97,7 +117,10 @@ pub struct Cascade {
|
||||
|
||||
impl Cascade {
|
||||
pub fn place(&mut self, output: &str, work: Rect, size: (f64, f64)) -> Rect {
|
||||
let position = self.positions.entry(output.to_owned()).or_insert((work.x, work.y));
|
||||
let position = self
|
||||
.positions
|
||||
.entry(output.to_owned())
|
||||
.or_insert((work.x, work.y));
|
||||
if position.0 < work.x
|
||||
|| position.1 < work.y
|
||||
|| position.0 + size.0 > work.x + work.width
|
||||
@@ -132,7 +155,10 @@ impl Remembered {
|
||||
|
||||
pub fn restore(&self, outputs: &[(String, Rect)]) -> Option<Rect> {
|
||||
let work = outputs.iter().find(|(name, _)| *name == self.output)?.1;
|
||||
if !self.fractions.iter().all(|v| v.is_finite()) || self.fractions[2] <= 0.0 || self.fractions[3] <= 0.0 {
|
||||
if !self.fractions.iter().all(|v| v.is_finite())
|
||||
|| self.fractions[2] <= 0.0
|
||||
|| self.fractions[3] <= 0.0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let [x, y, w, h] = self.fractions;
|
||||
@@ -142,11 +168,15 @@ impl Remembered {
|
||||
w * work.width,
|
||||
h * work.height,
|
||||
);
|
||||
if ![rect.x, rect.y, rect.width, rect.height].iter().all(|v| v.is_finite()) {
|
||||
if ![rect.x, rect.y, rect.width, rect.height]
|
||||
.iter()
|
||||
.all(|v| v.is_finite())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let visible = outputs.iter().any(|(_, area)| {
|
||||
intersection(rect, *area).is_some_and(|r| r.width * r.height >= rect.width * rect.height * 0.25)
|
||||
intersection(rect, *area)
|
||||
.is_some_and(|r| r.width * r.height >= rect.width * rect.height * 0.25)
|
||||
});
|
||||
visible.then_some(rect)
|
||||
}
|
||||
@@ -169,8 +199,13 @@ struct SaveThread {
|
||||
}
|
||||
|
||||
impl SaveThread {
|
||||
fn start(mut persist: impl FnMut(&Path, &Memory) -> std::io::Result<()> + Send + 'static) -> std::io::Result<Self> {
|
||||
let shared = std::sync::Arc::new((std::sync::Mutex::new(SaveState::default()), std::sync::Condvar::new()));
|
||||
fn start(
|
||||
mut persist: impl FnMut(&Path, &Memory) -> std::io::Result<()> + Send + 'static,
|
||||
) -> std::io::Result<Self> {
|
||||
let shared = std::sync::Arc::new((
|
||||
std::sync::Mutex::new(SaveState::default()),
|
||||
std::sync::Condvar::new(),
|
||||
));
|
||||
let worker = shared.clone();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("floating-geometry-save".into())
|
||||
@@ -178,9 +213,13 @@ impl SaveThread {
|
||||
loop {
|
||||
let job = {
|
||||
let (mutex, ready) = &*worker;
|
||||
let mut state = mutex.lock().unwrap();
|
||||
let mut state = mutex.lock().expect(
|
||||
"screenshot writer queue must remain consistent across workers",
|
||||
);
|
||||
while state.pending.is_none() && !state.closed {
|
||||
state = ready.wait(state).unwrap();
|
||||
state = ready.wait(state).expect(
|
||||
"screenshot writer queue must remain consistent across workers",
|
||||
);
|
||||
}
|
||||
match state.pending.take() {
|
||||
Some(job) => job,
|
||||
@@ -202,7 +241,10 @@ impl SaveThread {
|
||||
|
||||
fn queue(&self, path: PathBuf, memory: Memory) {
|
||||
let (mutex, ready) = &*self.shared;
|
||||
mutex.lock().unwrap().pending = Some((path, memory));
|
||||
mutex
|
||||
.lock()
|
||||
.expect("screenshot writer queue must remain consistent across workers")
|
||||
.pending = Some((path, memory));
|
||||
ready.notify_one();
|
||||
}
|
||||
}
|
||||
@@ -210,7 +252,10 @@ impl SaveThread {
|
||||
impl Drop for SaveThread {
|
||||
fn drop(&mut self) {
|
||||
let (mutex, ready) = &*self.shared;
|
||||
mutex.lock().unwrap().closed = true;
|
||||
mutex
|
||||
.lock()
|
||||
.expect("screenshot writer queue must remain consistent across workers")
|
||||
.closed = true;
|
||||
ready.notify_one();
|
||||
// Flush the latest snapshot only during compositor shutdown, after
|
||||
// the event loop has stopped. Drag completion never joins this thread.
|
||||
@@ -230,7 +275,10 @@ impl SaveWorker {
|
||||
if self.worker.is_none() {
|
||||
self.worker = Some(SaveThread::start(|path, memory| memory.persist(path))?);
|
||||
}
|
||||
self.worker.as_ref().unwrap().queue(path, memory.clone());
|
||||
self.worker
|
||||
.as_ref()
|
||||
.expect("screenshot submission initialized the writer before queueing work")
|
||||
.queue(path, memory.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -355,7 +403,9 @@ pub fn snap_lines(raw: Rect, work: Rect, others: &[(u64, Rect)]) -> (Vec<SnapLin
|
||||
},
|
||||
];
|
||||
for &(target, rect) in others {
|
||||
if raw.y - SNAP_DISTANCE <= rect.y + rect.height && raw.y + raw.height + SNAP_DISTANCE >= rect.y {
|
||||
if raw.y - SNAP_DISTANCE <= rect.y + rect.height
|
||||
&& raw.y + raw.height + SNAP_DISTANCE >= rect.y
|
||||
{
|
||||
xs.extend([
|
||||
SnapLine {
|
||||
target,
|
||||
@@ -367,7 +417,9 @@ pub fn snap_lines(raw: Rect, work: Rect, others: &[(u64, Rect)]) -> (Vec<SnapLin
|
||||
},
|
||||
]);
|
||||
}
|
||||
if raw.x - SNAP_DISTANCE <= rect.x + rect.width && raw.x + raw.width + SNAP_DISTANCE >= rect.x {
|
||||
if raw.x - SNAP_DISTANCE <= rect.x + rect.width
|
||||
&& raw.x + raw.width + SNAP_DISTANCE >= rect.x
|
||||
{
|
||||
ys.extend([
|
||||
SnapLine {
|
||||
target,
|
||||
@@ -420,7 +472,9 @@ mod tests {
|
||||
memory
|
||||
};
|
||||
worker.queue(PathBuf::from("unused"), snapshot("first"));
|
||||
started_rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
|
||||
started_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(2))
|
||||
.unwrap();
|
||||
worker.queue(PathBuf::from("unused"), snapshot("superseded"));
|
||||
worker.queue(PathBuf::from("unused"), snapshot("last"));
|
||||
assert_eq!(*written.lock().unwrap(), vec!["first"]);
|
||||
@@ -511,7 +565,10 @@ mod tests {
|
||||
};
|
||||
let placed = min_overlap(size, Rect::new(0., 0., 16., 16.), &others, (8., 8.)).unwrap();
|
||||
let brute = (0..=16 - size.0 as i32)
|
||||
.flat_map(|x| (0..=16 - size.1 as i32).map(move |y| Rect::new(x as f64, y as f64, size.0, size.1)))
|
||||
.flat_map(|x| {
|
||||
(0..=16 - size.1 as i32)
|
||||
.map(move |y| Rect::new(x as f64, y as f64, size.0, size.1))
|
||||
})
|
||||
.map(overlap)
|
||||
.min_by(f64::total_cmp)
|
||||
.unwrap();
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::FocusEffectSettings;
|
||||
use ferese_layout::WindowId;
|
||||
|
||||
pub(crate) fn target(
|
||||
settings: FocusEffectSettings,
|
||||
active: Option<WindowId>,
|
||||
window: WindowId,
|
||||
) -> f64 {
|
||||
if !settings.has_effects() || active == Some(window) {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FocusEffectSettings {
|
||||
pub(crate) fn has_effects(self) -> bool {
|
||||
self.enabled && (self.active_opacity != self.inactive_opacity || self.inactive_dim != 0.0)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve(
|
||||
self,
|
||||
progress: f64,
|
||||
has_active: bool,
|
||||
overview_opacity: f32,
|
||||
) -> (f32, f64) {
|
||||
if !self.enabled {
|
||||
return (1.0, 0.0);
|
||||
}
|
||||
let progress = progress.clamp(0.0, 1.0);
|
||||
let alpha =
|
||||
self.inactive_opacity + (self.active_opacity - self.inactive_opacity) * progress;
|
||||
let dim = if has_active {
|
||||
self.inactive_dim * (1.0 - progress) * (1.0 - f64::from(overview_opacity))
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(alpha as f32, dim)
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded, interruption-safe opacity transition; it never overshoots.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct BoundedFade {
|
||||
pub current: f64,
|
||||
start: f64,
|
||||
target: f64,
|
||||
elapsed: f64,
|
||||
}
|
||||
|
||||
impl BoundedFade {
|
||||
pub(crate) fn needs_update(&self, target: f64) -> bool {
|
||||
self.target != target || self.current != target
|
||||
}
|
||||
|
||||
pub(crate) fn is_animating(&self) -> bool {
|
||||
self.current != self.target
|
||||
}
|
||||
|
||||
pub fn new(value: f64) -> Self {
|
||||
Self {
|
||||
current: value,
|
||||
start: value,
|
||||
target: value,
|
||||
elapsed: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn advance(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool {
|
||||
if self.target != target {
|
||||
self.start = self.current;
|
||||
self.target = target;
|
||||
self.elapsed = 0.0;
|
||||
}
|
||||
|
||||
if duration_ms == 0.0 {
|
||||
self.current = target;
|
||||
return false;
|
||||
}
|
||||
|
||||
self.elapsed += delta.as_secs_f64() * 1000.0;
|
||||
let progress = (self.elapsed / duration_ms).min(1.0);
|
||||
if progress == 1.0 {
|
||||
self.current = target;
|
||||
return false;
|
||||
}
|
||||
|
||||
let eased = progress * progress * (3.0 - 2.0 * progress);
|
||||
self.current = self.start + (target - self.start) * eased;
|
||||
self.current != target
|
||||
}
|
||||
|
||||
/// The target changed at this tick, not at the beginning of a potentially
|
||||
/// long idle interval. Do not spend that old elapsed time on a new fade.
|
||||
pub fn advance_visual(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool {
|
||||
self.advance(
|
||||
target,
|
||||
if self.target != target {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
delta
|
||||
},
|
||||
duration_ms,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn settings() -> FocusEffectSettings {
|
||||
FocusEffectSettings {
|
||||
enabled: true,
|
||||
blur: true,
|
||||
active_opacity: 1.0,
|
||||
inactive_opacity: 0.8,
|
||||
inactive_dim: 0.2,
|
||||
duration_ms: 150.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_effect_bypasses_opacity_and_dimming_without_changing_settings() {
|
||||
let mut effect = settings();
|
||||
effect.enabled = false;
|
||||
assert!(!effect.has_effects());
|
||||
for progress in [0.0, 0.5, 1.0] {
|
||||
assert_eq!(effect.resolve(progress, true, 0.0), (1.0, 0.0));
|
||||
}
|
||||
assert_eq!(target(effect, Some(WindowId(1)), WindowId(2)), 1.0);
|
||||
effect.enabled = true;
|
||||
assert_eq!(effect.resolve(0.0, true, 0.0), (0.8, 0.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_and_dimming_share_one_interruption_safe_transition() {
|
||||
let mut focus = BoundedFade::new(0.0);
|
||||
assert!(focus.advance_visual(1.0, Duration::from_secs(30), 150.0));
|
||||
assert_eq!(focus.current, 0.0);
|
||||
focus.advance_visual(1.0, Duration::from_millis(75), 150.0);
|
||||
let (alpha, dim) = settings().resolve(focus.current, true, 0.0);
|
||||
assert!((alpha - 0.9).abs() < 1e-6);
|
||||
assert!((dim - 0.1).abs() < 1e-9);
|
||||
let before = focus.current;
|
||||
focus.advance_visual(0.0, Duration::from_secs(30), 150.0);
|
||||
assert_eq!(focus.current, before);
|
||||
assert!(!focus.advance_visual(0.0, Duration::from_millis(150), 150.0));
|
||||
assert_eq!(settings().resolve(focus.current, true, 0.0), (0.8, 0.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_is_independent_of_overview_and_no_active_window() {
|
||||
assert_eq!(target(settings(), Some(WindowId(1)), WindowId(1)), 1.0);
|
||||
assert_eq!(target(settings(), Some(WindowId(1)), WindowId(2)), 0.0);
|
||||
assert_eq!(target(settings(), None, WindowId(2)), 0.0);
|
||||
assert_eq!(settings().resolve(0.0, true, 1.0), (0.8, 0.0));
|
||||
assert_eq!(settings().resolve(0.0, false, 0.0), (0.8, 0.0));
|
||||
assert_eq!(settings().resolve(0.0, true, 0.5), (0.8, 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduced_motion_and_zero_duration_snap() {
|
||||
let mut focus = BoundedFade::new(0.0);
|
||||
assert!(!focus.advance_visual(1.0, Duration::ZERO, 0.0));
|
||||
assert_eq!(settings().resolve(focus.current, true, 0.0), (1.0, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use std::time::Duration;
|
||||
|
||||
const MIN_RENDER: Duration = Duration::from_millis(2);
|
||||
const TIMER_MARGIN: Duration = Duration::from_millis(1);
|
||||
const WAKEUP_DECAY: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct FramePlan {
|
||||
@@ -25,6 +26,8 @@ pub(crate) struct FrameScheduler {
|
||||
render_variance: Duration,
|
||||
last_sample: Option<Duration>,
|
||||
extra_margin: Duration,
|
||||
wakeup_delay: Duration,
|
||||
last_wakeup: Option<Duration>,
|
||||
pub missed_deadlines: u64,
|
||||
}
|
||||
|
||||
@@ -42,6 +45,8 @@ impl FrameScheduler {
|
||||
render_variance: Duration::ZERO,
|
||||
last_sample: None,
|
||||
extra_margin: Duration::ZERO,
|
||||
wakeup_delay: Duration::ZERO,
|
||||
last_wakeup: None,
|
||||
missed_deadlines: 0,
|
||||
}
|
||||
}
|
||||
@@ -56,6 +61,10 @@ impl FrameScheduler {
|
||||
self.requested_at.get_or_insert(now);
|
||||
}
|
||||
|
||||
pub fn has_pending_request(&self) -> bool {
|
||||
self.requested_at.is_some() || self.scheduled.is_some()
|
||||
}
|
||||
|
||||
fn budget(&self, now: Duration) -> Duration {
|
||||
let idle = self
|
||||
.last_presented
|
||||
@@ -64,10 +73,27 @@ impl FrameScheduler {
|
||||
// A cold GPU is less predictable. Start immediately on idle wakeup.
|
||||
self.interval
|
||||
} else {
|
||||
(self.render_estimate + self.render_variance * 2 + TIMER_MARGIN + self.extra_margin).min(self.interval)
|
||||
(self.render_estimate
|
||||
+ self.render_variance * 2
|
||||
+ TIMER_MARGIN
|
||||
+ self.extra_margin
|
||||
+ self.wakeup_margin(now))
|
||||
.min(self.interval)
|
||||
}
|
||||
}
|
||||
|
||||
fn wakeup_margin(&self, now: Duration) -> Duration {
|
||||
if self.wakeup_delay.is_zero() {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
let elapsed = self
|
||||
.last_wakeup
|
||||
.map_or(Duration::ZERO, |last| now.saturating_sub(last));
|
||||
// Forget transient contention by elapsed time, independent of refresh rate.
|
||||
self.wakeup_delay
|
||||
.mul_f64((-elapsed.as_secs_f64() / WAKEUP_DECAY.as_secs_f64()).exp())
|
||||
}
|
||||
|
||||
fn phase_at_or_after(&self, time: Duration) -> Duration {
|
||||
let Some(last) = self.last_presented else {
|
||||
return time;
|
||||
@@ -91,7 +117,10 @@ impl FrameScheduler {
|
||||
}
|
||||
|
||||
let requested_at = self.requested_at?;
|
||||
if self.last_presented.is_some_and(|last| last > now + self.interval) {
|
||||
if self
|
||||
.last_presented
|
||||
.is_some_and(|last| last > now + self.interval)
|
||||
{
|
||||
self.last_presented = None;
|
||||
self.last_target = None;
|
||||
}
|
||||
@@ -126,8 +155,15 @@ impl FrameScheduler {
|
||||
Some(plan)
|
||||
}
|
||||
|
||||
pub fn begin(&mut self) -> Option<FramePlan> {
|
||||
pub fn begin(&mut self, now: Duration) -> Option<FramePlan> {
|
||||
let plan = self.scheduled.take()?;
|
||||
if plan.phase_known {
|
||||
// Rendering cost starts after dispatch. Account separately for time
|
||||
// spent waiting for a CPU or for earlier event-loop callbacks.
|
||||
let late = now.saturating_sub(plan.render_at).min(self.interval);
|
||||
self.wakeup_delay = self.wakeup_margin(now).max(late);
|
||||
self.last_wakeup = Some(now);
|
||||
}
|
||||
self.requested_at = None;
|
||||
self.last_target = Some(plan.present_at);
|
||||
Some(plan)
|
||||
@@ -144,8 +180,12 @@ impl FrameScheduler {
|
||||
let ratio = (elapsed.as_secs_f64() / 0.5).clamp(0.01, 1.0);
|
||||
let deviation = sample.saturating_sub(self.render_estimate);
|
||||
let variance_ratio = (elapsed.as_secs_f64() / 6.0).clamp(0.001, 0.1);
|
||||
self.render_variance = self.render_variance.mul_f64(1.0 - variance_ratio).max(deviation);
|
||||
self.render_estimate = self.render_estimate.mul_f64(1.0 - ratio) + sample.mul_f64(ratio);
|
||||
self.render_variance = self
|
||||
.render_variance
|
||||
.mul_f64(1.0 - variance_ratio)
|
||||
.max(deviation);
|
||||
self.render_estimate =
|
||||
self.render_estimate.mul_f64(1.0 - ratio) + sample.mul_f64(ratio);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +204,8 @@ impl FrameScheduler {
|
||||
self.last_target = Some(timestamp);
|
||||
let late = timestamp.saturating_sub(plan.present_at);
|
||||
if plan.phase_known && late > self.interval / 2 {
|
||||
let missed = (late.as_nanos() + self.interval.as_nanos() / 2) / self.interval.as_nanos();
|
||||
let missed =
|
||||
(late.as_nanos() + self.interval.as_nanos() / 2) / self.interval.as_nanos();
|
||||
self.missed_deadlines = self.missed_deadlines.saturating_add(missed as u64);
|
||||
// CPU submission timings may omit asynchronous GPU work. Increase
|
||||
// the margin promptly after a real miss; relax it slowly afterward.
|
||||
@@ -222,7 +263,8 @@ impl FrameScheduler {
|
||||
// the next refresh look too early and halve a client's frame rate.
|
||||
self.last_callback = Some(self.last_presented.map_or(now, |last| {
|
||||
let tolerance = Duration::from_micros(500).min(self.interval / 8);
|
||||
let slots = (now.saturating_sub(last) + tolerance).as_nanos() / self.interval.as_nanos();
|
||||
let slots =
|
||||
(now.saturating_sub(last) + tolerance).as_nanos() / self.interval.as_nanos();
|
||||
last + self.interval.mul_f64(slots as f64)
|
||||
}));
|
||||
}
|
||||
@@ -245,7 +287,7 @@ mod tests {
|
||||
assert_eq!((plan.render_at, plan.present_at), (ms(107), ms(110)));
|
||||
scheduler.request(ms(102));
|
||||
assert_eq!(scheduler.plan(ms(102)), Some(plan));
|
||||
assert_eq!(scheduler.begin(), Some(plan));
|
||||
assert_eq!(scheduler.begin(plan.render_at), Some(plan));
|
||||
scheduler.rendered(plan, ms(108), ms(1), true);
|
||||
scheduler.request(ms(109));
|
||||
assert_eq!(scheduler.plan(ms(109)), None);
|
||||
@@ -260,7 +302,7 @@ mod tests {
|
||||
scheduler.request(ms(10_100));
|
||||
let plan = scheduler.plan(ms(10_100)).unwrap();
|
||||
assert_eq!(plan.render_at, ms(10_100));
|
||||
scheduler.begin();
|
||||
scheduler.begin(plan.render_at);
|
||||
scheduler.rendered(plan, ms(10_101), ms(1), true);
|
||||
scheduler.presented(ms(10_110));
|
||||
assert_eq!(scheduler.missed_deadlines, 0);
|
||||
@@ -272,7 +314,7 @@ mod tests {
|
||||
scheduler.presented(ms(100));
|
||||
scheduler.request(ms(101));
|
||||
let plan = scheduler.plan(ms(101)).unwrap();
|
||||
scheduler.begin();
|
||||
scheduler.begin(plan.render_at);
|
||||
scheduler.rendered(plan, ms(108), ms(1), true);
|
||||
assert_eq!(scheduler.presented(ms(130)), Some(plan));
|
||||
assert_eq!(scheduler.missed_deadlines, 2);
|
||||
@@ -280,12 +322,81 @@ mod tests {
|
||||
assert_eq!(scheduler.presented(ms(129)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_timer_dispatch_moves_the_next_render_earlier_without_skipping_a_refresh() {
|
||||
let mut scheduler = FrameScheduler::new(ms(10));
|
||||
scheduler.presented(ms(100));
|
||||
scheduler.request(ms(101));
|
||||
let first = scheduler.plan(ms(101)).unwrap();
|
||||
assert_eq!(first.render_at, ms(107));
|
||||
let dispatch = first.render_at + ms(3);
|
||||
assert_eq!(scheduler.begin(dispatch), Some(first));
|
||||
scheduler.rendered(first, dispatch + ms(2), ms(2), true);
|
||||
scheduler.presented(ms(120));
|
||||
assert_eq!(scheduler.missed_deadlines, 1);
|
||||
|
||||
scheduler.request(ms(120));
|
||||
let next = scheduler.plan(ms(120)).unwrap();
|
||||
assert_eq!(next.present_at, ms(130), "retain the next physical refresh");
|
||||
assert!(
|
||||
next.render_at + ms(3) + ms(2) <= next.present_at - TIMER_MARGIN,
|
||||
"the next frame must budget both the observed wakeup delay and rendering"
|
||||
);
|
||||
assert_eq!(
|
||||
scheduler.plan(ms(121)),
|
||||
Some(next),
|
||||
"new input must not postpone the plan"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wakeup_history_decays_by_time_is_bounded_and_resets_with_the_output() {
|
||||
let mut fast = FrameScheduler::new(ms(5));
|
||||
let mut slow = FrameScheduler::new(ms(20));
|
||||
let mut margins = Vec::new();
|
||||
for scheduler in [&mut fast, &mut slow] {
|
||||
scheduler.presented(ms(100));
|
||||
scheduler.request(ms(101));
|
||||
let plan = scheduler.plan(ms(101)).unwrap();
|
||||
let now = plan.render_at + ms(3);
|
||||
scheduler.begin(now);
|
||||
assert_eq!(scheduler.wakeup_margin(now), ms(3));
|
||||
margins.push(scheduler.wakeup_margin(now + ms(1000)));
|
||||
assert!(scheduler.wakeup_margin(now + ms(5000)) < Duration::from_micros(21));
|
||||
scheduler.reset(ms(10));
|
||||
assert_eq!(scheduler.wakeup_margin(now), Duration::ZERO);
|
||||
}
|
||||
assert_eq!(
|
||||
margins[0], margins[1],
|
||||
"decay must not depend on display refresh rate"
|
||||
);
|
||||
let mut scheduler = FrameScheduler::new(ms(10));
|
||||
scheduler.presented(ms(100));
|
||||
scheduler.request(ms(101));
|
||||
let plan = scheduler.plan(ms(101)).unwrap();
|
||||
let now = plan.render_at + ms(200);
|
||||
scheduler.begin(now);
|
||||
assert_eq!(scheduler.wakeup_margin(now), ms(10));
|
||||
assert!(scheduler.budget(now) <= ms(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_phase_does_not_train_wakeup_history_or_move_a_pending_plan() {
|
||||
let mut scheduler = FrameScheduler::new(ms(10));
|
||||
scheduler.request(ms(100));
|
||||
let plan = scheduler.plan(ms(100)).unwrap();
|
||||
assert!(!plan.phase_known);
|
||||
assert_eq!(scheduler.plan(ms(120)), Some(plan));
|
||||
scheduler.begin(ms(130));
|
||||
assert_eq!(scheduler.wakeup_margin(ms(130)), Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_retirement_aligns_the_next_frame_to_the_observed_refresh() {
|
||||
let mut scheduler = FrameScheduler::new(ms(10));
|
||||
scheduler.request(ms(100));
|
||||
let plan = scheduler.plan(ms(100)).unwrap();
|
||||
scheduler.begin();
|
||||
scheduler.begin(plan.render_at);
|
||||
scheduler.rendered(plan, ms(102), ms(2), true);
|
||||
scheduler.presented(ms(103));
|
||||
|
||||
@@ -299,12 +410,15 @@ mod tests {
|
||||
scheduler.presented(ms(100));
|
||||
scheduler.request(ms(101));
|
||||
let plan = scheduler.plan(ms(101)).unwrap();
|
||||
scheduler.begin();
|
||||
scheduler.begin(plan.render_at);
|
||||
scheduler.rendered(plan, ms(108), ms(1), false);
|
||||
scheduler.callback_sent(ms(110));
|
||||
scheduler.request(ms(108));
|
||||
assert_eq!(scheduler.plan(ms(108)).unwrap().present_at, ms(120));
|
||||
assert_eq!(scheduler.callback_deadline(ms(111), ms(110)).unwrap(), ms(120));
|
||||
assert_eq!(
|
||||
scheduler.callback_deadline(ms(111), ms(110)).unwrap(),
|
||||
ms(120)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -313,26 +427,34 @@ mod tests {
|
||||
scheduler.presented(ms(100));
|
||||
scheduler.request(ms(101));
|
||||
let unchanged = scheduler.plan(ms(101)).unwrap();
|
||||
scheduler.begin();
|
||||
scheduler.begin(unchanged.render_at);
|
||||
scheduler.rendered(unchanged, ms(108), ms(1), false);
|
||||
let callback_at = scheduler.callback_deadline(ms(108), unchanged.present_at).unwrap();
|
||||
let callback_at = scheduler
|
||||
.callback_deadline(ms(108), unchanged.present_at)
|
||||
.unwrap();
|
||||
assert_eq!(callback_at, ms(110));
|
||||
|
||||
scheduler.request(ms(109));
|
||||
let changed = scheduler.plan(ms(109)).unwrap();
|
||||
scheduler.begin();
|
||||
scheduler.begin(changed.render_at);
|
||||
scheduler.rendered(changed, ms(118), ms(1), true);
|
||||
|
||||
// The old timer is dispatched late, after a new frame was queued.
|
||||
// Neither that timer nor the predicted refresh can release callbacks.
|
||||
assert_eq!(scheduler.callback_deadline(ms(118), callback_at), None);
|
||||
assert_eq!(scheduler.callback_deadline(ms(120), changed.present_at), None);
|
||||
assert_eq!(
|
||||
scheduler.callback_deadline(ms(120), changed.present_at),
|
||||
None
|
||||
);
|
||||
assert_eq!(scheduler.callback_deadline(ms(129), callback_at), None);
|
||||
|
||||
scheduler.presented(ms(130));
|
||||
assert_eq!(scheduler.callback_deadline(ms(130), ms(130)), Some(ms(130)));
|
||||
scheduler.callback_sent(ms(130));
|
||||
assert_eq!(scheduler.callback_deadline(ms(130), callback_at), Some(ms(140)));
|
||||
assert_eq!(
|
||||
scheduler.callback_deadline(ms(130), callback_at),
|
||||
Some(ms(140))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -368,7 +490,7 @@ mod tests {
|
||||
scheduler.presented(ms(100));
|
||||
scheduler.request(ms(101));
|
||||
let plan = scheduler.plan(ms(101)).unwrap();
|
||||
scheduler.begin();
|
||||
scheduler.begin(plan.render_at);
|
||||
scheduler.rendered(plan, ms(108), ms(1), true);
|
||||
scheduler.request(ms(109));
|
||||
assert_eq!(scheduler.presented(ms(90)), Some(plan));
|
||||
@@ -391,7 +513,7 @@ mod tests {
|
||||
unknown.request(ms(100));
|
||||
let plan = unknown.plan(ms(100)).unwrap();
|
||||
assert_eq!(unknown.forecast_time(ms(101), plan), None);
|
||||
unknown.begin();
|
||||
unknown.begin(plan.render_at);
|
||||
unknown.rendered(plan, ms(102), ms(2), true);
|
||||
unknown.presented(ms(130));
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Gamma ramps shared by display backends and the gamma-control protocol.
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::os::unix::fs::FileExt;
|
||||
|
||||
// Keep client-controlled allocation bounded even if a driver reports an invalid size.
|
||||
const MAX_GAMMA_SIZE: usize = 65_536;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub(crate) struct Ramp(Vec<u16>);
|
||||
|
||||
impl Ramp {
|
||||
pub(crate) fn zeroed(size: usize) -> io::Result<Self> {
|
||||
if size == 0 || size > MAX_GAMMA_SIZE {
|
||||
return Err(io::ErrorKind::Unsupported.into());
|
||||
}
|
||||
Ok(Self(vec![0; size * 3]))
|
||||
}
|
||||
|
||||
pub(crate) fn size(&self) -> usize {
|
||||
self.0.len() / 3
|
||||
}
|
||||
|
||||
pub(crate) fn channels_mut(&mut self) -> (&mut [u16], &mut [u16], &mut [u16]) {
|
||||
let size = self.size();
|
||||
let (red, rest) = self.0.split_at_mut(size);
|
||||
let (green, blue) = rest.split_at_mut(size);
|
||||
(red, green, blue)
|
||||
}
|
||||
|
||||
pub(crate) fn at_temperature(&self, temperature: u16) -> Self {
|
||||
let mut ramp = self.clone();
|
||||
for (channel, gain) in ramp
|
||||
.0
|
||||
.chunks_exact_mut(self.size())
|
||||
.zip(temperature_gains(temperature))
|
||||
{
|
||||
for value in channel {
|
||||
*value = (f64::from(*value) * gain).round() as u16;
|
||||
}
|
||||
}
|
||||
ramp
|
||||
}
|
||||
|
||||
pub(crate) fn interpolate(&self, target: &Self, progress: f64) -> Self {
|
||||
assert_eq!(self.size(), target.size());
|
||||
let progress = progress.clamp(0., 1.);
|
||||
Self(
|
||||
self.0
|
||||
.iter()
|
||||
.zip(&target.0)
|
||||
.map(|(&from, &to)| {
|
||||
(f64::from(from) + (f64::from(to) - f64::from(from)) * progress).round() as u16
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn read(file: File, size: usize) -> io::Result<Self> {
|
||||
let metadata = file.metadata()?;
|
||||
let length = size
|
||||
.checked_mul(6)
|
||||
.filter(|_| size > 0 && size <= MAX_GAMMA_SIZE);
|
||||
let Some(length) = length else {
|
||||
return Err(io::ErrorKind::InvalidData.into());
|
||||
};
|
||||
// pread preserves the caller's offset and avoids mmap's truncation SIGBUS.
|
||||
// Only fixed-size files are accepted, not pipes or device streams.
|
||||
if !metadata.is_file() || metadata.len() != length as u64 {
|
||||
return Err(io::ErrorKind::InvalidData.into());
|
||||
}
|
||||
let mut bytes = vec![0; length];
|
||||
file.read_exact_at(&mut bytes, 0)?;
|
||||
Ok(Self(
|
||||
bytes
|
||||
.chunks_exact(2)
|
||||
.map(|v| u16::from_ne_bytes([v[0], v[1]]))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn channels(&self) -> (&[u16], &[u16], &[u16]) {
|
||||
let size = self.0.len() / 3;
|
||||
(
|
||||
&self.0[..size],
|
||||
&self.0[size..2 * size],
|
||||
&self.0[2 * size..],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn temperature_gains(kelvin: u16) -> [f64; 3] {
|
||||
// Tanner Helland's temperature approximation, normalized to neutral at 6500 K.
|
||||
// https://tannerhelland.com/2012/09/18/convert-temperature-rgb-algorithm-code.html
|
||||
let green = |t: f64| (99.4708025861 * t.ln() - 161.1195681661).clamp(0., 255.);
|
||||
let blue = |t: f64| {
|
||||
if t <= 19. {
|
||||
0.
|
||||
} else {
|
||||
(138.5177312231 * (t - 10.).ln() - 305.0447927307).clamp(0., 255.)
|
||||
}
|
||||
};
|
||||
let temperature = f64::from(kelvin.clamp(1000, 6500)) / 100.;
|
||||
[
|
||||
1.,
|
||||
green(temperature) / green(65.),
|
||||
blue(temperature) / blue(65.),
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
//! Navigation previews follow finger motion; actions commit once on release.
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[repr(u32)]
|
||||
pub(crate) enum SwipeDirection {
|
||||
@@ -12,10 +10,16 @@ pub(crate) enum SwipeDirection {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Swipe {
|
||||
delta: Option<(f64, f64)>,
|
||||
tracker_x: ferese_animation::gesture::VelocityTracker,
|
||||
tracker_y: ferese_animation::gesture::VelocityTracker,
|
||||
pub release_velocity: f64,
|
||||
pub unbounded_release_velocity: f64,
|
||||
|
||||
threshold: f64,
|
||||
fingers: u32,
|
||||
direction: Option<SwipeDirection>,
|
||||
preview_started: bool,
|
||||
momentum_navigation: bool,
|
||||
}
|
||||
|
||||
impl Swipe {
|
||||
@@ -25,6 +29,66 @@ impl Swipe {
|
||||
self.fingers = fingers;
|
||||
self.direction = None;
|
||||
self.preview_started = false;
|
||||
self.momentum_navigation = false;
|
||||
self.tracker_x = Default::default();
|
||||
self.tracker_y = Default::default();
|
||||
self.release_velocity = 0.0;
|
||||
self.unbounded_release_velocity = 0.0;
|
||||
}
|
||||
|
||||
pub fn start_time(&mut self, time: std::time::Duration) {
|
||||
self.tracker_x.push(time, 0.0);
|
||||
self.tracker_y.push(time, 0.0);
|
||||
}
|
||||
|
||||
pub fn update_at(&mut self, dx: f64, dy: f64, time: std::time::Duration) {
|
||||
self.update(dx, dy);
|
||||
if let Some((x, y)) = self.delta {
|
||||
self.tracker_x.push(time, x);
|
||||
self.tracker_y.push(time, y);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish_at(
|
||||
&mut self,
|
||||
cancelled: bool,
|
||||
time: std::time::Duration,
|
||||
) -> Option<SwipeDirection> {
|
||||
let Some(direction) = self.direction else {
|
||||
return self.finish(cancelled);
|
||||
};
|
||||
let v = match direction {
|
||||
SwipeDirection::Left => -self.tracker_x.velocity(time),
|
||||
SwipeDirection::Right => self.tracker_x.velocity(time),
|
||||
SwipeDirection::Up => -self.tracker_y.velocity(time),
|
||||
SwipeDirection::Down => self.tracker_y.velocity(time),
|
||||
};
|
||||
self.release_velocity = if cancelled {
|
||||
0.0
|
||||
} else {
|
||||
v / (2.0 * self.threshold.max(1.0))
|
||||
};
|
||||
self.unbounded_release_velocity = self.release_velocity;
|
||||
if !self.preview_started && !self.momentum_navigation {
|
||||
return self.finish(cancelled);
|
||||
}
|
||||
let Some((_, progress)) = self.preview() else {
|
||||
self.release_velocity = 0.0;
|
||||
self.unbounded_release_velocity = 0.0;
|
||||
self.delta = None;
|
||||
return None;
|
||||
};
|
||||
// The preview is bounded. Its visible velocity is zero when pushing
|
||||
// farther beyond either endpoint, even if the fingers keep moving.
|
||||
if (progress == 0.0 && self.release_velocity < 0.0)
|
||||
|| (progress == 1.0 && self.release_velocity > 0.0)
|
||||
{
|
||||
self.release_velocity = 0.0;
|
||||
}
|
||||
self.delta = None;
|
||||
(!cancelled
|
||||
&& ferese_animation::gesture::project(progress, self.release_velocity, 0.997) >= 0.5)
|
||||
.then_some(direction)
|
||||
}
|
||||
|
||||
pub fn fingers(&self) -> u32 {
|
||||
@@ -49,11 +113,20 @@ impl Swipe {
|
||||
self.preview_started = true;
|
||||
}
|
||||
|
||||
pub fn mark_momentum_navigation(&mut self) {
|
||||
self.momentum_navigation = true;
|
||||
}
|
||||
|
||||
pub fn preview_started(&self) -> bool {
|
||||
self.preview_started
|
||||
}
|
||||
|
||||
pub fn preview(&self) -> Option<(SwipeDirection, f64)> {
|
||||
self.unbounded_preview()
|
||||
.map(|(direction, progress)| (direction, progress.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
pub fn unbounded_preview(&self) -> Option<(SwipeDirection, f64)> {
|
||||
let (x, y) = self.delta?;
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
@@ -65,7 +138,7 @@ impl Swipe {
|
||||
SwipeDirection::Up => -y,
|
||||
SwipeDirection::Down => y,
|
||||
};
|
||||
Some((direction, (distance / (self.threshold.max(1.0) * 2.0)).clamp(0.0, 1.0)))
|
||||
Some((direction, distance / (self.threshold.max(1.0) * 2.0)))
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, cancelled: bool) -> Option<SwipeDirection> {
|
||||
@@ -73,7 +146,8 @@ impl Swipe {
|
||||
if cancelled {
|
||||
return None;
|
||||
}
|
||||
dominant_direction(x, y, self.threshold).filter(|direction| Some(*direction) == self.direction)
|
||||
dominant_direction(x, y, self.threshold)
|
||||
.filter(|direction| Some(*direction) == self.direction)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +177,47 @@ fn dominant_direction(x: f64, y: f64, threshold: f64) -> Option<SwipeDirection>
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn release_uses_momentum_and_a_pause_removes_it() {
|
||||
use std::time::Duration;
|
||||
let swipe = || {
|
||||
let mut swipe = Swipe::default();
|
||||
swipe.begin(3, false, 80);
|
||||
swipe.start_time(Duration::ZERO);
|
||||
swipe.update_at(0.0, -20.0, Duration::from_millis(10));
|
||||
swipe.update_at(0.0, -20.0, Duration::from_millis(20));
|
||||
swipe.mark_preview_started();
|
||||
swipe
|
||||
};
|
||||
let mut flick = swipe();
|
||||
assert_eq!(flick.preview(), Some((SwipeDirection::Up, 0.25)));
|
||||
assert_eq!(
|
||||
flick.finish_at(false, Duration::from_millis(20)),
|
||||
Some(SwipeDirection::Up)
|
||||
);
|
||||
assert!((flick.release_velocity - 12.5).abs() < 1e-9);
|
||||
let mut paused = swipe();
|
||||
assert_eq!(paused.finish_at(false, Duration::from_millis(80)), None);
|
||||
assert_eq!(paused.release_velocity, 0.0);
|
||||
let mut cancelled = swipe();
|
||||
assert_eq!(cancelled.finish_at(true, Duration::from_millis(20)), None);
|
||||
assert_eq!(cancelled.release_velocity, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversing_momentum_can_cancel_past_halfway() {
|
||||
use std::time::Duration;
|
||||
let mut swipe = Swipe::default();
|
||||
swipe.begin(3, false, 80);
|
||||
swipe.start_time(Duration::ZERO);
|
||||
swipe.update_at(0.0, -120.0, Duration::from_millis(200));
|
||||
swipe.update_at(0.0, 20.0, Duration::from_millis(210));
|
||||
swipe.mark_preview_started();
|
||||
assert_eq!(swipe.preview(), Some((SwipeDirection::Up, 0.625)));
|
||||
assert_eq!(swipe.finish_at(false, Duration::from_millis(210)), None);
|
||||
assert!(swipe.release_velocity < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_and_new_gestures_reset_preview_state() {
|
||||
let mut swipe = Swipe::default();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
mod move_grab;
|
||||
mod resize_grab;
|
||||
|
||||
pub use move_grab::MoveSurfaceGrab;
|
||||
pub use resize_grab::{ResizeEdge, ResizeSurfaceGrab, handle_resize_commit};
|
||||
pub use resize_grab::ResizeEdge;
|
||||
|
||||
mod window_grab;
|
||||
pub(crate) use resize_grab::{constrained_size, resized_rect};
|
||||
pub(crate) use window_grab::WindowPointerGrab;
|
||||
@@ -1,18 +1,5 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use smithay::desktop::Window;
|
||||
use smithay::input::pointer::{
|
||||
AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent,
|
||||
GesturePinchUpdateEvent, GestureSwipeBeginEvent, GestureSwipeEndEvent, GestureSwipeUpdateEvent, GrabStartData,
|
||||
MotionEvent, PointerGrab, PointerInnerHandle, RelativeMotionEvent,
|
||||
};
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
use smithay::utils::{Logical, Point, Rectangle, Size};
|
||||
use smithay::wayland::compositor::with_states;
|
||||
use smithay::wayland::shell::xdg::SurfaceCachedState;
|
||||
|
||||
use crate::Ferese;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ResizeEdge(xdg_toplevel::ResizeEdge);
|
||||
@@ -24,6 +11,15 @@ impl From<xdg_toplevel::ResizeEdge> for ResizeEdge {
|
||||
}
|
||||
|
||||
impl ResizeEdge {
|
||||
pub(crate) fn cursor(self) -> smithay::input::pointer::CursorIcon {
|
||||
use smithay::input::pointer::CursorIcon;
|
||||
match (self.left(), self.right(), self.top(), self.bottom()) {
|
||||
(true, _, true, _) | (_, true, _, true) => CursorIcon::NwseResize,
|
||||
(true, _, _, true) | (_, true, true, _) => CursorIcon::NeswResize,
|
||||
(true, _, _, _) | (_, true, _, _) => CursorIcon::EwResize,
|
||||
_ => CursorIcon::NsResize,
|
||||
}
|
||||
}
|
||||
pub fn at(point: Point<f64, Logical>, rect: Rectangle<i32, Logical>) -> Self {
|
||||
use xdg_toplevel::ResizeEdge::*;
|
||||
let x = (point.x - rect.loc.x as f64) / rect.size.w.max(1) as f64;
|
||||
@@ -47,14 +43,16 @@ impl ResizeEdge {
|
||||
_ => Bottom,
|
||||
})
|
||||
}
|
||||
fn left(self) -> bool {
|
||||
pub(crate) fn left(self) -> bool {
|
||||
matches!(
|
||||
self.0,
|
||||
xdg_toplevel::ResizeEdge::Left | xdg_toplevel::ResizeEdge::TopLeft | xdg_toplevel::ResizeEdge::BottomLeft
|
||||
xdg_toplevel::ResizeEdge::Left
|
||||
| xdg_toplevel::ResizeEdge::TopLeft
|
||||
| xdg_toplevel::ResizeEdge::BottomLeft
|
||||
)
|
||||
}
|
||||
|
||||
fn right(self) -> bool {
|
||||
pub(crate) fn right(self) -> bool {
|
||||
matches!(
|
||||
self.0,
|
||||
xdg_toplevel::ResizeEdge::Right
|
||||
@@ -63,14 +61,16 @@ impl ResizeEdge {
|
||||
)
|
||||
}
|
||||
|
||||
fn top(self) -> bool {
|
||||
pub(crate) fn top(self) -> bool {
|
||||
matches!(
|
||||
self.0,
|
||||
xdg_toplevel::ResizeEdge::Top | xdg_toplevel::ResizeEdge::TopLeft | xdg_toplevel::ResizeEdge::TopRight
|
||||
xdg_toplevel::ResizeEdge::Top
|
||||
| xdg_toplevel::ResizeEdge::TopLeft
|
||||
| xdg_toplevel::ResizeEdge::TopRight
|
||||
)
|
||||
}
|
||||
|
||||
fn bottom(self) -> bool {
|
||||
pub(crate) fn bottom(self) -> bool {
|
||||
matches!(
|
||||
self.0,
|
||||
xdg_toplevel::ResizeEdge::Bottom
|
||||
@@ -80,314 +80,49 @@ impl ResizeEdge {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ResizeSurfaceGrab {
|
||||
start_data: GrabStartData<Ferese>,
|
||||
window: Window,
|
||||
edges: ResizeEdge,
|
||||
initial_rect: Rectangle<i32, Logical>,
|
||||
last_size: Size<i32, Logical>,
|
||||
finished: bool,
|
||||
snap_x: crate::floating::AxisSnap,
|
||||
snap_y: crate::floating::AxisSnap,
|
||||
}
|
||||
|
||||
impl ResizeSurfaceGrab {
|
||||
pub fn new(
|
||||
start_data: GrabStartData<Ferese>,
|
||||
window: Window,
|
||||
edges: ResizeEdge,
|
||||
initial_rect: Rectangle<i32, Logical>,
|
||||
) -> Self {
|
||||
ResizeState::with(
|
||||
window.toplevel().expect("managed window has a toplevel").wl_surface(),
|
||||
|state| *state = ResizeState::Resizing { edges, initial_rect },
|
||||
);
|
||||
Self {
|
||||
start_data,
|
||||
window,
|
||||
edges,
|
||||
initial_rect,
|
||||
last_size: initial_rect.size,
|
||||
finished: false,
|
||||
snap_x: Default::default(),
|
||||
snap_y: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PointerGrab<Ferese> for ResizeSurfaceGrab {
|
||||
fn motion(
|
||||
&mut self,
|
||||
data: &mut Ferese,
|
||||
handle: &mut PointerInnerHandle<'_, Ferese>,
|
||||
_focus: Option<(WlSurface, Point<f64, Logical>)>,
|
||||
event: &MotionEvent,
|
||||
) {
|
||||
handle.motion(data, None, event);
|
||||
let mut delta = event.location - self.start_data.location;
|
||||
let initial = self.initial_rect;
|
||||
let raw_left = initial.loc.x as f64 + if self.edges.left() { delta.x } else { 0. };
|
||||
let raw_top = initial.loc.y as f64 + if self.edges.top() { delta.y } else { 0. };
|
||||
let raw_right = (initial.loc.x + initial.size.w) as f64 + if self.edges.right() { delta.x } else { 0. };
|
||||
let raw_bottom = (initial.loc.y + initial.size.h) as f64 + if self.edges.bottom() { delta.y } else { 0. };
|
||||
let raw = ferese_layout::Rect::new(
|
||||
raw_left,
|
||||
raw_top,
|
||||
(raw_right - raw_left).max(1.),
|
||||
(raw_bottom - raw_top).max(1.),
|
||||
);
|
||||
let (xs, ys) = data.floating_snap_lines(&self.window, raw);
|
||||
if data.floating_snap_bypassed() {
|
||||
self.snap_x.clear();
|
||||
self.snap_y.clear();
|
||||
} else {
|
||||
if self.edges.left() || self.edges.right() {
|
||||
let moving = if self.edges.left() { raw_left } else { raw_right };
|
||||
delta.x += self.snap_x.apply(moving, 0., &xs) - moving;
|
||||
}
|
||||
if self.edges.top() || self.edges.bottom() {
|
||||
let moving = if self.edges.top() { raw_top } else { raw_bottom };
|
||||
delta.y += self.snap_y.apply(moving, 0., &ys) - moving;
|
||||
}
|
||||
}
|
||||
let surface = self.window.toplevel().expect("managed window has a toplevel");
|
||||
let (minimum, maximum) = with_states(surface.wl_surface(), |states| {
|
||||
let mut cached = states.cached_state.get::<SurfaceCachedState>();
|
||||
let state = cached.current();
|
||||
(state.min_size, state.max_size)
|
||||
});
|
||||
self.last_size = constrained_size(self.initial_rect.size, delta, self.edges, minimum, maximum);
|
||||
let rect = resized_rect(self.initial_rect, self.last_size, self.edges);
|
||||
data.set_floating_window_geometry(&self.window, rect.loc, rect.size);
|
||||
if let Some(id) = data.windows.ids().get(&self.window).copied() {
|
||||
data.windows.update(id, |w| {
|
||||
w.resize_anchor = Some((
|
||||
self.edges.left(),
|
||||
self.edges.top(),
|
||||
ferese_layout::Rect::new(
|
||||
initial.loc.x as f64,
|
||||
initial.loc.y as f64,
|
||||
initial.size.w as f64,
|
||||
initial.size.h as f64,
|
||||
),
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
surface.with_pending_state(|state| {
|
||||
state.states.set(xdg_toplevel::State::Resizing);
|
||||
state.size = Some(self.last_size);
|
||||
});
|
||||
surface.send_pending_configure();
|
||||
}
|
||||
|
||||
fn relative_motion(
|
||||
&mut self,
|
||||
data: &mut Ferese,
|
||||
handle: &mut PointerInnerHandle<'_, Ferese>,
|
||||
focus: Option<(WlSurface, Point<f64, Logical>)>,
|
||||
event: &RelativeMotionEvent,
|
||||
) {
|
||||
handle.relative_motion(data, focus, event);
|
||||
}
|
||||
|
||||
fn button(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>, event: &ButtonEvent) {
|
||||
handle.button(data, event);
|
||||
if handle.current_pressed().is_empty() {
|
||||
self.finished = true;
|
||||
data.remember_floating(&self.window);
|
||||
handle.unset_grab(self, data, event.serial, event.time, true);
|
||||
let surface = self.window.toplevel().expect("managed window has a toplevel");
|
||||
surface.with_pending_state(|state| {
|
||||
state.states.unset(xdg_toplevel::State::Resizing);
|
||||
state.size = Some(self.last_size);
|
||||
});
|
||||
surface.send_pending_configure();
|
||||
ResizeState::with(surface.wl_surface(), |state| {
|
||||
*state = ResizeState::WaitingForFinalCommit {
|
||||
edges: self.edges,
|
||||
initial_rect: self.initial_rect,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn axis(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>, frame: AxisFrame) {
|
||||
handle.axis(data, frame);
|
||||
}
|
||||
|
||||
fn frame(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>) {
|
||||
handle.frame(data);
|
||||
}
|
||||
|
||||
fn gesture_swipe_begin(
|
||||
&mut self,
|
||||
data: &mut Ferese,
|
||||
handle: &mut PointerInnerHandle<'_, Ferese>,
|
||||
event: &GestureSwipeBeginEvent,
|
||||
) {
|
||||
handle.gesture_swipe_begin(data, event);
|
||||
}
|
||||
|
||||
fn gesture_swipe_update(
|
||||
&mut self,
|
||||
data: &mut Ferese,
|
||||
handle: &mut PointerInnerHandle<'_, Ferese>,
|
||||
event: &GestureSwipeUpdateEvent,
|
||||
) {
|
||||
handle.gesture_swipe_update(data, event);
|
||||
}
|
||||
|
||||
fn gesture_swipe_end(
|
||||
&mut self,
|
||||
data: &mut Ferese,
|
||||
handle: &mut PointerInnerHandle<'_, Ferese>,
|
||||
event: &GestureSwipeEndEvent,
|
||||
) {
|
||||
handle.gesture_swipe_end(data, event);
|
||||
}
|
||||
|
||||
fn gesture_pinch_begin(
|
||||
&mut self,
|
||||
data: &mut Ferese,
|
||||
handle: &mut PointerInnerHandle<'_, Ferese>,
|
||||
event: &GesturePinchBeginEvent,
|
||||
) {
|
||||
handle.gesture_pinch_begin(data, event);
|
||||
}
|
||||
|
||||
fn gesture_pinch_update(
|
||||
&mut self,
|
||||
data: &mut Ferese,
|
||||
handle: &mut PointerInnerHandle<'_, Ferese>,
|
||||
event: &GesturePinchUpdateEvent,
|
||||
) {
|
||||
handle.gesture_pinch_update(data, event);
|
||||
}
|
||||
|
||||
fn gesture_pinch_end(
|
||||
&mut self,
|
||||
data: &mut Ferese,
|
||||
handle: &mut PointerInnerHandle<'_, Ferese>,
|
||||
event: &GesturePinchEndEvent,
|
||||
) {
|
||||
handle.gesture_pinch_end(data, event);
|
||||
}
|
||||
|
||||
fn gesture_hold_begin(
|
||||
&mut self,
|
||||
data: &mut Ferese,
|
||||
handle: &mut PointerInnerHandle<'_, Ferese>,
|
||||
event: &GestureHoldBeginEvent,
|
||||
) {
|
||||
handle.gesture_hold_begin(data, event);
|
||||
}
|
||||
|
||||
fn gesture_hold_end(
|
||||
&mut self,
|
||||
data: &mut Ferese,
|
||||
handle: &mut PointerInnerHandle<'_, Ferese>,
|
||||
event: &GestureHoldEndEvent,
|
||||
) {
|
||||
handle.gesture_hold_end(data, event);
|
||||
}
|
||||
|
||||
fn start_data(&self) -> &GrabStartData<Ferese> {
|
||||
&self.start_data
|
||||
}
|
||||
|
||||
fn unset(&mut self, data: &mut Ferese) {
|
||||
if self.finished {
|
||||
return;
|
||||
}
|
||||
|
||||
let surface = self.window.toplevel().expect("managed window has a toplevel");
|
||||
surface.with_pending_state(|state| {
|
||||
state.states.unset(xdg_toplevel::State::Resizing);
|
||||
state.size = Some(self.initial_rect.size);
|
||||
});
|
||||
surface.send_pending_configure();
|
||||
ResizeState::with(surface.wl_surface(), |state| *state = ResizeState::Idle);
|
||||
data.set_floating_window_geometry(&self.window, self.initial_rect.loc, self.initial_rect.size);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
enum ResizeState {
|
||||
#[default]
|
||||
Idle,
|
||||
Resizing {
|
||||
edges: ResizeEdge,
|
||||
initial_rect: Rectangle<i32, Logical>,
|
||||
},
|
||||
WaitingForFinalCommit {
|
||||
edges: ResizeEdge,
|
||||
initial_rect: Rectangle<i32, Logical>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ResizeState {
|
||||
fn with<T>(surface: &WlSurface, callback: impl FnOnce(&mut Self) -> T) -> T {
|
||||
with_states(surface, |states| {
|
||||
states.data_map.insert_if_missing(RefCell::<Self>::default);
|
||||
callback(
|
||||
&mut states
|
||||
.data_map
|
||||
.get::<RefCell<Self>>()
|
||||
.expect("resize state was inserted")
|
||||
.borrow_mut(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn commit(&mut self) -> Option<(ResizeEdge, Rectangle<i32, Logical>)> {
|
||||
match *self {
|
||||
Self::Idle => None,
|
||||
Self::Resizing { edges, initial_rect } => Some((edges, initial_rect)),
|
||||
Self::WaitingForFinalCommit { edges, initial_rect } => {
|
||||
*self = Self::Idle;
|
||||
Some((edges, initial_rect))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_resize_commit(surface: &WlSurface) {
|
||||
// The compositor's visual rect owns the anchored edge. A delayed client
|
||||
// commit must not move the window or change its stacking order.
|
||||
ResizeState::with(surface, ResizeState::commit);
|
||||
}
|
||||
|
||||
fn constrained_size(
|
||||
pub(crate) fn constrained_size(
|
||||
initial: Size<i32, Logical>,
|
||||
delta: Point<f64, Logical>,
|
||||
edges: ResizeEdge,
|
||||
minimum: Size<i32, Logical>,
|
||||
maximum: Size<i32, Logical>,
|
||||
minimum: (i32, i32),
|
||||
maximum: (i32, i32),
|
||||
) -> Size<i32, Logical> {
|
||||
let mut width = initial.w;
|
||||
let mut height = initial.h;
|
||||
if edges.left() {
|
||||
width -= delta.x as i32;
|
||||
width = width.saturating_sub(delta.x as i32);
|
||||
} else if edges.right() {
|
||||
width += delta.x as i32;
|
||||
}
|
||||
if edges.top() {
|
||||
height -= delta.y as i32;
|
||||
} else if edges.bottom() {
|
||||
height += delta.y as i32;
|
||||
width = width.saturating_add(delta.x as i32);
|
||||
}
|
||||
|
||||
let maximum_width = if maximum.w == 0 { i32::MAX } else { maximum.w };
|
||||
let maximum_height = if maximum.h == 0 { i32::MAX } else { maximum.h };
|
||||
if edges.top() {
|
||||
height = height.saturating_sub(delta.y as i32);
|
||||
} else if edges.bottom() {
|
||||
height = height.saturating_add(delta.y as i32);
|
||||
}
|
||||
|
||||
// Protocol validation rejects these ranges, but a grab may still be alive
|
||||
// when its client is disconnected. Keep this path safe for any cached state.
|
||||
let minimum_width = minimum.0.max(1);
|
||||
let minimum_height = minimum.1.max(1);
|
||||
let maximum_width = if maximum.0 <= 0 {
|
||||
i32::MAX
|
||||
} else {
|
||||
maximum.0.max(minimum_width)
|
||||
};
|
||||
let maximum_height = if maximum.1 <= 0 {
|
||||
i32::MAX
|
||||
} else {
|
||||
maximum.1.max(minimum_height)
|
||||
};
|
||||
(
|
||||
width.clamp(minimum.w.max(1), maximum_width),
|
||||
height.clamp(minimum.h.max(1), maximum_height),
|
||||
width.clamp(minimum_width, maximum_width),
|
||||
height.clamp(minimum_height, maximum_height),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn resized_rect(
|
||||
pub(crate) fn resized_rect(
|
||||
initial: Rectangle<i32, Logical>,
|
||||
size: Size<i32, Logical>,
|
||||
edges: ResizeEdge,
|
||||
@@ -395,10 +130,15 @@ fn resized_rect(
|
||||
let mut location = initial.loc;
|
||||
|
||||
if edges.left() {
|
||||
location.x += initial.size.w - size.w;
|
||||
location.x = location
|
||||
.x
|
||||
.saturating_add(initial.size.w.saturating_sub(size.w));
|
||||
}
|
||||
|
||||
if edges.top() {
|
||||
location.y += initial.size.h - size.h;
|
||||
location.y = location
|
||||
.y
|
||||
.saturating_add(initial.size.h.saturating_sub(size.h));
|
||||
}
|
||||
|
||||
Rectangle::new(location, size)
|
||||
@@ -439,8 +179,8 @@ mod tests {
|
||||
(800, 600).into(),
|
||||
(100.0, 50.0).into(),
|
||||
ResizeEdge(xdg_toplevel::ResizeEdge::TopLeft),
|
||||
(1, 1).into(),
|
||||
(0, 0).into(),
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
);
|
||||
assert_eq!(size, (700, 550).into());
|
||||
}
|
||||
@@ -451,8 +191,8 @@ mod tests {
|
||||
(800, 600).into(),
|
||||
(100.0, 50.0).into(),
|
||||
ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight),
|
||||
(1, 1).into(),
|
||||
(0, 0).into(),
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
);
|
||||
assert_eq!(size, (900, 650).into());
|
||||
}
|
||||
@@ -463,12 +203,38 @@ mod tests {
|
||||
(800, 600).into(),
|
||||
(1_000.0, 1_000.0).into(),
|
||||
ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight),
|
||||
(640, 480).into(),
|
||||
(1_024, 768).into(),
|
||||
(640, 480),
|
||||
(1_024, 768),
|
||||
);
|
||||
assert_eq!(size, (1_024, 768).into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_and_extreme_client_limits_cannot_panic_or_overflow() {
|
||||
for (minimum, maximum) in [
|
||||
((800, 700), (400, 300)),
|
||||
((-1, -2), (-3, -4)),
|
||||
((i32::MAX, i32::MAX), (1, 1)),
|
||||
((0, 0), (0, 0)),
|
||||
] {
|
||||
for delta in [(f64::MAX, f64::MAX), (-f64::MAX, -f64::MAX), (0.0, 0.0)] {
|
||||
let size = constrained_size(
|
||||
(800, 600).into(),
|
||||
delta.into(),
|
||||
ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight),
|
||||
minimum,
|
||||
maximum,
|
||||
);
|
||||
assert!(size.w >= 1 && size.h >= 1);
|
||||
let _ = resized_rect(
|
||||
Rectangle::new((100, 80).into(), (800, 600).into()),
|
||||
size,
|
||||
ResizeEdge(xdg_toplevel::ResizeEdge::TopLeft),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resized_rect_keeps_the_opposite_edge_fixed() {
|
||||
let initial = Rectangle::new((100, 80).into(), (800, 600).into());
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
use smithay::desktop::Window;
|
||||
use smithay::input::pointer::{
|
||||
AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent,
|
||||
GesturePinchUpdateEvent, GestureSwipeBeginEvent, GestureSwipeEndEvent, GestureSwipeUpdateEvent, GrabStartData,
|
||||
MotionEvent, PointerGrab, PointerInnerHandle, RelativeMotionEvent,
|
||||
AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent,
|
||||
GesturePinchEndEvent, GesturePinchUpdateEvent, GestureSwipeBeginEvent, GestureSwipeEndEvent,
|
||||
GestureSwipeUpdateEvent, GrabStartData, MotionEvent, PointerGrab, PointerInnerHandle,
|
||||
RelativeMotionEvent,
|
||||
};
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
use smithay::utils::{Logical, Point, Size};
|
||||
use smithay::utils::{Logical, Point};
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::floating::AxisSnap;
|
||||
|
||||
pub struct MoveSurfaceGrab {
|
||||
pub(crate) struct WindowPointerGrab {
|
||||
pub start_data: GrabStartData<Ferese>,
|
||||
pub window: Window,
|
||||
pub initial_location: Point<i32, Logical>,
|
||||
pub initial_size: Size<i32, Logical>,
|
||||
pub finished: bool,
|
||||
pub snap_x: AxisSnap,
|
||||
pub snap_y: AxisSnap,
|
||||
}
|
||||
|
||||
impl PointerGrab<Ferese> for MoveSurfaceGrab {
|
||||
impl PointerGrab<Ferese> for WindowPointerGrab {
|
||||
fn motion(
|
||||
&mut self,
|
||||
data: &mut Ferese,
|
||||
@@ -29,49 +23,69 @@ impl PointerGrab<Ferese> for MoveSurfaceGrab {
|
||||
event: &MotionEvent,
|
||||
) {
|
||||
handle.motion(data, None, event);
|
||||
let location = self.initial_location.to_f64() + (event.location - self.start_data.location);
|
||||
let raw = ferese_layout::Rect::new(
|
||||
location.x,
|
||||
location.y,
|
||||
self.initial_size.w as f64,
|
||||
self.initial_size.h as f64,
|
||||
);
|
||||
let (xs, ys) = data.floating_snap_lines(&self.window, raw);
|
||||
let location = if data.floating_snap_bypassed() {
|
||||
self.snap_x.clear();
|
||||
self.snap_y.clear();
|
||||
location
|
||||
} else {
|
||||
Point::from((
|
||||
self.snap_x.apply(raw.x, raw.width, &xs),
|
||||
self.snap_y.apply(raw.y, raw.height, &ys),
|
||||
))
|
||||
if !self.finished
|
||||
&& let Some(operation) = data.pointer_operation.as_mut()
|
||||
{
|
||||
operation.location = event.location;
|
||||
}
|
||||
.to_i32_round();
|
||||
data.set_floating_window_geometry(&self.window, location, self.initial_size);
|
||||
}
|
||||
|
||||
fn relative_motion(
|
||||
&mut self,
|
||||
data: &mut Ferese,
|
||||
handle: &mut PointerInnerHandle<'_, Ferese>,
|
||||
focus: Option<(WlSurface, Point<f64, Logical>)>,
|
||||
_focus: Option<(WlSurface, Point<f64, Logical>)>,
|
||||
event: &RelativeMotionEvent,
|
||||
) {
|
||||
handle.relative_motion(data, focus, event);
|
||||
handle.relative_motion(data, None, event);
|
||||
}
|
||||
|
||||
fn button(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>, event: &ButtonEvent) {
|
||||
fn button(
|
||||
&mut self,
|
||||
data: &mut Ferese,
|
||||
handle: &mut PointerInnerHandle<'_, Ferese>,
|
||||
event: &ButtonEvent,
|
||||
) {
|
||||
if let Some(operation) = data.pointer_operation.as_mut() {
|
||||
if event.state == smithay::backend::input::ButtonState::Pressed {
|
||||
operation.pressed.insert(event.button);
|
||||
if event.button == 0x111 && event.button != self.start_data.button {
|
||||
operation.toggle_drop_mode();
|
||||
}
|
||||
} else {
|
||||
operation.pressed.remove(&event.button);
|
||||
}
|
||||
}
|
||||
handle.button(data, event);
|
||||
if handle.current_pressed().is_empty() {
|
||||
if event.button == self.start_data.button
|
||||
&& event.state == smithay::backend::input::ButtonState::Released
|
||||
{
|
||||
self.finished = true;
|
||||
data.remember_floating(&self.window);
|
||||
handle.unset_grab(self, data, event.serial, event.time, true);
|
||||
if let Some(operation) = data.pointer_operation.as_mut() {
|
||||
operation.finished = true;
|
||||
}
|
||||
}
|
||||
// Extra buttons are drained with focus cleared, so their releases cannot
|
||||
// reach a client that never received the corresponding presses.
|
||||
if handle.current_pressed().is_empty() {
|
||||
handle.unset_grab(self, data, event.serial, event.time, false);
|
||||
}
|
||||
}
|
||||
|
||||
fn axis(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>, frame: AxisFrame) {
|
||||
handle.axis(data, frame);
|
||||
fn axis(
|
||||
&mut self,
|
||||
data: &mut Ferese,
|
||||
_handle: &mut PointerInnerHandle<'_, Ferese>,
|
||||
frame: AxisFrame,
|
||||
) {
|
||||
if let Some(operation) = data.pointer_operation.as_mut() {
|
||||
let amount = if frame.axis.0 != 0.0 {
|
||||
frame.axis.0
|
||||
} else {
|
||||
frame.axis.1
|
||||
};
|
||||
operation.scroll(amount);
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>) {
|
||||
@@ -155,10 +169,12 @@ impl PointerGrab<Ferese> for MoveSurfaceGrab {
|
||||
}
|
||||
|
||||
fn unset(&mut self, data: &mut Ferese) {
|
||||
if self.finished {
|
||||
return;
|
||||
// Resolve focus after the layout settles and the pointer mutex is released.
|
||||
data.window_grab_focus_pending = true;
|
||||
if !self.finished
|
||||
&& let Some(operation) = data.pointer_operation.as_mut()
|
||||
{
|
||||
operation.cancelled = true;
|
||||
}
|
||||
|
||||
data.set_floating_window_geometry(&self.window, self.initial_location, self.initial_size);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
use std::time::Duration;
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use smithay::input::Seat;
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
use smithay::reexports::wayland_server::backend::ClientId;
|
||||
use smithay::utils::Serial;
|
||||
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
#[cfg(test)]
|
||||
use smithay::utils::SERIAL_COUNTER;
|
||||
use smithay::wayland::xdg_activation::{
|
||||
XdgActivationHandler, XdgActivationState, XdgActivationToken, XdgActivationTokenData,
|
||||
@@ -9,6 +16,53 @@ use smithay::wayland::xdg_activation::{
|
||||
use crate::Ferese;
|
||||
|
||||
const TOKEN_MAX_AGE: Duration = Duration::from_secs(10);
|
||||
const MAX_INTERACTIONS: usize = 64;
|
||||
|
||||
pub(crate) struct InputHistory<C = ClientId> {
|
||||
events: VecDeque<(Serial, C, Instant)>,
|
||||
}
|
||||
|
||||
impl<C> Default for InputHistory<C> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
events: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: PartialEq> InputHistory<C> {
|
||||
fn record(&mut self, serial: Serial, client: C, time: Instant) {
|
||||
while self.events.len() >= MAX_INTERACTIONS {
|
||||
self.events.pop_front();
|
||||
}
|
||||
|
||||
self.events.push_back((serial, client, time));
|
||||
}
|
||||
|
||||
fn validate(&self, serial: Serial, client: &C, now: Instant) -> Option<Instant> {
|
||||
self.events.iter().rev().find_map(|(event, owner, time)| {
|
||||
(*event == serial
|
||||
&& owner == client
|
||||
&& now.saturating_duration_since(*time) <= TOKEN_MAX_AGE)
|
||||
.then_some(*time)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ApprovedInteraction(Instant);
|
||||
|
||||
impl Ferese {
|
||||
pub(crate) fn record_activation_input(&mut self, serial: Serial, surface: Option<WlSurface>) {
|
||||
if self.session_lock.active() || self.input_capture.active() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(client) = surface.and_then(|surface| surface.client()) {
|
||||
self.activation_inputs
|
||||
.record(serial, client.id(), Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl XdgActivationHandler for Ferese {
|
||||
fn activation_state(&mut self) -> &mut XdgActivationState {
|
||||
@@ -16,47 +70,256 @@ impl XdgActivationHandler for Ferese {
|
||||
}
|
||||
|
||||
fn token_created(&mut self, _token: XdgActivationToken, data: XdgActivationTokenData) -> bool {
|
||||
data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE
|
||||
if self.session_lock.active() || self.input_capture.active() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some((serial, seat)) = &data.serial else {
|
||||
return false;
|
||||
};
|
||||
let Some(client) = &data.client_id else {
|
||||
return false;
|
||||
};
|
||||
if Seat::<Self>::from_resource(seat).as_ref() != Some(&self.seat)
|
||||
|| seat.client().is_none_or(|owner| owner.id() != *client)
|
||||
|| data
|
||||
.surface
|
||||
.as_ref()
|
||||
.is_some_and(|surface| surface.client().is_none_or(|owner| owner.id() != *client))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(time) = self
|
||||
.activation_inputs
|
||||
.validate(*serial, client, Instant::now())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
data.user_data
|
||||
.insert_if_missing(|| ApprovedInteraction(time));
|
||||
true
|
||||
}
|
||||
|
||||
fn request_activation(&mut self, token: XdgActivationToken, data: XdgActivationTokenData, surface: WlSurface) {
|
||||
if self.session_lock.active || self.input_capture.captures(1) {
|
||||
fn request_activation(
|
||||
&mut self,
|
||||
token: XdgActivationToken,
|
||||
data: XdgActivationTokenData,
|
||||
surface: WlSurface,
|
||||
) {
|
||||
self.xdg_activation_state.remove_token(&token);
|
||||
if self.session_lock.active() || self.input_capture.captures(1) {
|
||||
return;
|
||||
}
|
||||
let valid = data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE;
|
||||
self.xdg_activation_state.remove_token(&token);
|
||||
|
||||
// The recipient may be a different client: launcher-to-app token handoff.
|
||||
// Retain the original interaction time rather than renewing it at commit.
|
||||
let valid = data.timestamp.elapsed() <= TOKEN_MAX_AGE
|
||||
&& data
|
||||
.user_data
|
||||
.get::<ApprovedInteraction>()
|
||||
.is_some_and(|proof| proof.0.elapsed() <= TOKEN_MAX_AGE);
|
||||
if !valid {
|
||||
tracing::debug!("ignored stale or non-interactive activation request");
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(window) = self
|
||||
.space
|
||||
.elements()
|
||||
.find(|window| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == &surface)
|
||||
})
|
||||
.cloned()
|
||||
else {
|
||||
let Some(id) = self.windows.id_for_surface(&surface) else {
|
||||
return;
|
||||
};
|
||||
self.activate_managed_window(id);
|
||||
}
|
||||
}
|
||||
|
||||
self.focused_window = self.windows.ids().get(&window).copied();
|
||||
if let Some(focused) = self.focused_window
|
||||
&& let Err(error) = self.workspaces.focus_window(focused)
|
||||
{
|
||||
tracing::error!(%error, ?focused, "failed to update workspace focus");
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn token_creation_validates_real_seat_owner_and_input_age() {
|
||||
use smithay::reexports::calloop::EventLoop;
|
||||
use smithay::reexports::wayland_server::protocol::wl_seat::WlSeat;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::Arc;
|
||||
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"handlers::activation::tests::token_creation_validates_real_seat_owner_and_input_age",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
self.raise_window(&window, true);
|
||||
let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
|
||||
keyboard.set_focus(self, Some(surface), SERIAL_COUNTER.next_serial());
|
||||
self.space.elements().for_each(|window| {
|
||||
if let Some(toplevel) = window.toplevel() {
|
||||
toplevel.send_pending_configure();
|
||||
|
||||
let mut events = EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
let (server, mut wire) = UnixStream::pair().unwrap();
|
||||
let launcher = state
|
||||
.display_handle
|
||||
.insert_client(server, Arc::new(crate::state::ClientState::default()))
|
||||
.unwrap();
|
||||
let send = |wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32]| {
|
||||
let bytes: Vec<_> = [object, ((args.len() as u32 + 2) * 4) << 16 | opcode]
|
||||
.into_iter()
|
||||
.chain(args.iter().copied())
|
||||
.flat_map(u32::to_ne_bytes)
|
||||
.collect();
|
||||
wire.write_all(&bytes).unwrap();
|
||||
};
|
||||
send(&mut wire, 1, 1, &[2]); // wl_display.get_registry
|
||||
events
|
||||
.dispatch(Duration::from_millis(1), &mut state)
|
||||
.unwrap();
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
wire.set_nonblocking(true).unwrap();
|
||||
let mut globals = Vec::new();
|
||||
let _ = wire.read_to_end(&mut globals);
|
||||
wire.set_nonblocking(false).unwrap();
|
||||
let mut offset = 0;
|
||||
let mut seat_name = None;
|
||||
while offset < globals.len() {
|
||||
let header = u32::from_ne_bytes(globals[offset + 4..offset + 8].try_into().unwrap());
|
||||
if u32::from_ne_bytes(globals[offset..offset + 4].try_into().unwrap()) == 2
|
||||
&& header & 0xffff == 0
|
||||
{
|
||||
let length =
|
||||
u32::from_ne_bytes(globals[offset + 12..offset + 16].try_into().unwrap())
|
||||
as usize;
|
||||
if &globals[offset + 16..offset + 16 + length] == b"wl_seat\0" {
|
||||
seat_name = Some(u32::from_ne_bytes(
|
||||
globals[offset + 8..offset + 12].try_into().unwrap(),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
offset += (header >> 16) as usize;
|
||||
}
|
||||
|
||||
let mut args = vec![seat_name.expect("seat global"), 8];
|
||||
args.extend(
|
||||
b"wl_seat\0"
|
||||
.chunks_exact(4)
|
||||
.map(|word| u32::from_ne_bytes(word.try_into().unwrap())),
|
||||
);
|
||||
args.extend([7, 3]);
|
||||
send(&mut wire, 2, 0, &args);
|
||||
events
|
||||
.dispatch(Duration::from_millis(1), &mut state)
|
||||
.unwrap();
|
||||
let seat: WlSeat = state
|
||||
.seat
|
||||
.client_seats(&launcher)
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("bound seat");
|
||||
let (token, _) = state.xdg_activation_state.create_external_token(None);
|
||||
let token = token.clone();
|
||||
let serial = SERIAL_COUNTER.next_serial();
|
||||
let data = || XdgActivationTokenData {
|
||||
client_id: Some(launcher.id()),
|
||||
serial: Some((serial, seat.clone())),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
!state.token_created(token.clone(), data()),
|
||||
"fabricated serial accepted"
|
||||
);
|
||||
state.activation_inputs.record(
|
||||
serial,
|
||||
launcher.id(),
|
||||
Instant::now() - TOKEN_MAX_AGE - Duration::from_millis(1),
|
||||
);
|
||||
assert!(
|
||||
!state.token_created(token.clone(), data()),
|
||||
"fresh token renewed stale input"
|
||||
);
|
||||
state
|
||||
.activation_inputs
|
||||
.record(serial, launcher.id(), Instant::now());
|
||||
let approved = data();
|
||||
assert!(state.token_created(token.clone(), approved.clone()));
|
||||
assert!(approved.user_data.get::<ApprovedInteraction>().is_some());
|
||||
|
||||
let (server, mut recipient_wire) = UnixStream::pair().unwrap();
|
||||
let recipient = state
|
||||
.display_handle
|
||||
.insert_client(server, Arc::new(crate::state::ClientState::default()))
|
||||
.unwrap();
|
||||
let mut stolen = data();
|
||||
stolen.client_id = Some(recipient.id());
|
||||
assert!(
|
||||
!state.token_created(token.clone(), stolen),
|
||||
"other client claimed launcher's seat/input"
|
||||
);
|
||||
// The proof is attached to the token, not the recipient identity. This
|
||||
// is what lets a validated launcher hand it to a different application.
|
||||
assert_ne!(approved.client_id, Some(recipient.id()));
|
||||
assert!(
|
||||
approved
|
||||
.user_data
|
||||
.get::<ApprovedInteraction>()
|
||||
.unwrap()
|
||||
.0
|
||||
.elapsed()
|
||||
< TOKEN_MAX_AGE
|
||||
);
|
||||
|
||||
// A valid handoff must not focus a recipient before its first buffer.
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_wm_base::XdgWmBase;
|
||||
use smithay::reexports::wayland_server::protocol::wl_compositor::WlCompositor;
|
||||
use smithay::wayland::shell::xdg::XdgWmBaseUserData;
|
||||
let compositor = recipient
|
||||
.create_resource::<WlCompositor, smithay::wayland::GlobalData, Ferese>(
|
||||
&state.display_handle,
|
||||
6,
|
||||
smithay::wayland::GlobalData,
|
||||
)
|
||||
.unwrap();
|
||||
let shell = recipient
|
||||
.create_resource::<XdgWmBase, XdgWmBaseUserData, Ferese>(
|
||||
&state.display_handle,
|
||||
6,
|
||||
Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
send(&mut recipient_wire, compositor.id().protocol_id(), 0, &[2]);
|
||||
send(&mut recipient_wire, shell.id().protocol_id(), 2, &[3, 2]);
|
||||
send(&mut recipient_wire, 3, 1, &[4]);
|
||||
events
|
||||
.dispatch(Duration::from_millis(1), &mut state)
|
||||
.unwrap();
|
||||
let target = state
|
||||
.space
|
||||
.elements()
|
||||
.find_map(|window| {
|
||||
let surface = window.toplevel()?.wl_surface();
|
||||
surface
|
||||
.client()
|
||||
.is_some_and(|client| client.id() == recipient.id())
|
||||
.then(|| surface.clone())
|
||||
})
|
||||
.expect("recipient's toplevel");
|
||||
state.request_activation(token, approved, target.clone());
|
||||
assert_eq!(state.seat.get_keyboard().unwrap().current_focus(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_owner_serial_and_original_interaction_age() {
|
||||
let now = Instant::now();
|
||||
let mut history = InputHistory::default();
|
||||
let serial = Serial::from(12);
|
||||
history.record(serial, "launcher", now);
|
||||
assert_eq!(history.validate(serial, &"launcher", now), Some(now));
|
||||
assert_eq!(history.validate(Serial::from(13), &"launcher", now), None);
|
||||
assert_eq!(history.validate(serial, &"other-client", now), None);
|
||||
assert_eq!(
|
||||
history.validate(
|
||||
serial,
|
||||
&"launcher",
|
||||
now + TOKEN_MAX_AGE + Duration::from_millis(1)
|
||||
),
|
||||
None
|
||||
);
|
||||
// An approved token retains the interaction time across client handoff.
|
||||
let approved = ApprovedInteraction(history.validate(serial, &"launcher", now).unwrap());
|
||||
assert_eq!(approved.0, now);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,23 @@
|
||||
use smithay::backend::allocator::dmabuf::Dmabuf;
|
||||
use smithay::backend::renderer::utils::on_commit_buffer_handler;
|
||||
use smithay::reexports::wayland_server::Client;
|
||||
use smithay::reexports::calloop::Interest;
|
||||
use smithay::reexports::wayland_server::protocol::wl_buffer;
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
use smithay::reexports::wayland_server::{Client, Resource};
|
||||
use smithay::wayland::buffer::BufferHandler;
|
||||
use smithay::wayland::compositor::{
|
||||
CompositorClientState, CompositorHandler, CompositorState, get_parent, is_sync_subsurface,
|
||||
BufferAssignment, CompositorClientState, CompositorHandler, CompositorState, SurfaceAttributes,
|
||||
add_blocker, add_pre_commit_hook, get_parent, get_role, is_sync_subsurface, with_states,
|
||||
};
|
||||
use smithay::wayland::dmabuf::{
|
||||
DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier, get_dmabuf,
|
||||
};
|
||||
use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier};
|
||||
use smithay::wayland::shm::{ShmHandler, ShmState};
|
||||
|
||||
use super::{layer_shell, xdg_shell};
|
||||
use crate::Ferese;
|
||||
use crate::state::ClientState;
|
||||
|
||||
fn layer_affects_backdrop(layer: smithay::wayland::shell::wlr_layer::Layer) -> bool {
|
||||
matches!(
|
||||
layer,
|
||||
smithay::wayland::shell::wlr_layer::Layer::Background | smithay::wayland::shell::wlr_layer::Layer::Bottom
|
||||
)
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
fn surface_affects_backdrop(&self, surface: &WlSurface) -> bool {
|
||||
let mut root = surface.clone();
|
||||
while let Some(parent) = get_parent(&root) {
|
||||
root = parent;
|
||||
}
|
||||
if matches!(&self.cursor_status, smithay::input::pointer::CursorImageStatus::Surface(cursor) if cursor == &root)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.space
|
||||
.layer_for_surface(&root, smithay::desktop::WindowSurfaceType::ALL)
|
||||
.is_none_or(|layer| layer_affects_backdrop(layer.cached_state().layer))
|
||||
}
|
||||
}
|
||||
|
||||
impl CompositorHandler for Ferese {
|
||||
fn compositor_state(&mut self) -> &mut CompositorState {
|
||||
&mut self.compositor_state
|
||||
@@ -51,6 +32,39 @@ impl CompositorHandler for Ferese {
|
||||
|
||||
fn new_surface(&mut self, surface: &WlSurface) {
|
||||
self.update_surface_preferences(surface);
|
||||
// A commit is not proof that the GPU has finished writing its dma-buf.
|
||||
// Keep the previous surface state renderable until the acquire fence
|
||||
// signals, including during resize snapshots and subsurface updates.
|
||||
add_pre_commit_hook::<Self, _>(surface, |state, _dh, surface| {
|
||||
let dmabuf = with_states(surface, |states| {
|
||||
states
|
||||
.cached_state
|
||||
.get::<SurfaceAttributes>()
|
||||
.pending()
|
||||
.buffer
|
||||
.as_ref()
|
||||
.and_then(|assignment| match assignment {
|
||||
BufferAssignment::NewBuffer(buffer) => get_dmabuf(buffer).ok().cloned(),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let Some((dmabuf, client)) = dmabuf.zip(surface.client()) else {
|
||||
return;
|
||||
};
|
||||
if let Ok((blocker, source)) = dmabuf.generate_blocker(Interest::READ) {
|
||||
let inserted = state.loop_handle.insert_source(source, move |_, _, state| {
|
||||
let dh = state.display_handle.clone();
|
||||
state
|
||||
.client_compositor_state(&client)
|
||||
.blocker_cleared(state, &dh);
|
||||
Ok(())
|
||||
});
|
||||
match inserted {
|
||||
Ok(_) => add_blocker(surface, blocker),
|
||||
Err(error) => tracing::warn!(%error, "could not monitor surface GPU readiness"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn new_subsurface(&mut self, surface: &WlSurface, _parent: &WlSurface) {
|
||||
@@ -58,23 +72,25 @@ impl CompositorHandler for Ferese {
|
||||
}
|
||||
|
||||
fn commit(&mut self, surface: &WlSurface) {
|
||||
if get_role(surface) == Some(smithay::wayland::shell::xdg::XDG_TOPLEVEL_ROLE)
|
||||
&& let Some(window) = self.window_for_surface(surface)
|
||||
&& let Some(toplevel) = window.toplevel()
|
||||
&& !xdg_shell::validate_size_constraints(toplevel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.update_surface_preferences(surface);
|
||||
self.capture_resize_before_commit(surface);
|
||||
if self.surface_affects_backdrop(surface) {
|
||||
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
|
||||
}
|
||||
self.capture_close_before_commit(surface);
|
||||
on_commit_buffer_handler::<Self>(surface);
|
||||
crate::effects::commit_presentation(surface);
|
||||
if !is_sync_subsurface(surface) {
|
||||
let mut root = surface.clone();
|
||||
while let Some(parent) = get_parent(&root) {
|
||||
root = parent;
|
||||
}
|
||||
let window = {
|
||||
self.space
|
||||
.elements()
|
||||
.find(|window| window.toplevel().is_some_and(|toplevel| toplevel.wl_surface() == &root))
|
||||
.cloned()
|
||||
};
|
||||
let window = self.window_for_surface(&root);
|
||||
|
||||
if let Some(window) = window {
|
||||
window.on_commit();
|
||||
@@ -84,6 +100,7 @@ impl CompositorHandler for Ferese {
|
||||
} else if self.windows.ids().contains_key(&window) {
|
||||
self.remove_tiled_window(&window);
|
||||
self.space.map_element(window.clone(), (0, 0), false);
|
||||
self.invalidate_window_stacking();
|
||||
self.restore_keyboard_focus();
|
||||
}
|
||||
if window
|
||||
@@ -96,27 +113,31 @@ impl CompositorHandler for Ferese {
|
||||
self.record_client_commit(&window);
|
||||
}
|
||||
}
|
||||
self.refresh_idle_inhibition();
|
||||
|
||||
layer_shell::handle_commit(self, surface);
|
||||
xdg_shell::handle_commit(&mut self.popups, &mut self.space, surface);
|
||||
crate::backends::direct::render_all(self);
|
||||
xdg_shell::handle_commit(self, surface);
|
||||
self.position_committed_input_popup(surface);
|
||||
crate::backends::direct::render_surface(self, surface);
|
||||
}
|
||||
|
||||
fn destroyed(&mut self, surface: &WlSurface) {
|
||||
if self.session_lock.active {
|
||||
self.dmabuf_feedback.destroyed(surface);
|
||||
if self.session_lock.active() {
|
||||
self.session_lock
|
||||
.surfaces
|
||||
.retain(|_, lock| lock.wl_surface() != surface);
|
||||
self.focus_lock_surface();
|
||||
}
|
||||
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
|
||||
crate::backends::direct::render_all(self);
|
||||
|
||||
crate::backends::direct::render_surface(self, surface);
|
||||
if self.idle_inhibitors.remove(surface).is_some() {
|
||||
self.refresh_idle_inhibition();
|
||||
}
|
||||
if matches!(&self.cursor_status, smithay::input::pointer::CursorImageStatus::Surface(cursor) if cursor == surface)
|
||||
{
|
||||
self.cursor_status = smithay::input::pointer::CursorImageStatus::default_named();
|
||||
crate::backends::direct::render_all(self);
|
||||
crate::backends::direct::render_cursor(self);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,9 +151,17 @@ impl DmabufHandler for Ferese {
|
||||
&mut self.dmabuf_state
|
||||
}
|
||||
|
||||
fn dmabuf_imported(&mut self, _global: &DmabufGlobal, dmabuf: Dmabuf, notifier: ImportNotifier) {
|
||||
self.queue_dmabuf_import(dmabuf, notifier);
|
||||
crate::backends::direct::render_all(self);
|
||||
fn new_surface_feedback(
|
||||
&mut self,
|
||||
surface: &WlSurface,
|
||||
global: &DmabufGlobal,
|
||||
) -> Option<smithay::wayland::dmabuf::DmabufFeedback> {
|
||||
self.dmabuf_feedback.new_surface(surface, *global)
|
||||
}
|
||||
|
||||
fn dmabuf_imported(&mut self, global: &DmabufGlobal, dmabuf: Dmabuf, notifier: ImportNotifier) {
|
||||
self.dmabuf_imports
|
||||
.enqueue(*global, dmabuf, notifier, std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,17 +170,3 @@ impl ShmHandler for Ferese {
|
||||
&self.shm_state
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use smithay::wayland::shell::wlr_layer::Layer;
|
||||
|
||||
use super::*;
|
||||
#[test]
|
||||
fn panel_commits_do_not_invalidate_their_own_backdrop() {
|
||||
assert!(!layer_affects_backdrop(Layer::Top));
|
||||
assert!(!layer_affects_backdrop(Layer::Overlay));
|
||||
assert!(layer_affects_backdrop(Layer::Bottom));
|
||||
assert!(layer_affects_backdrop(Layer::Background));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use crate::Ferese;
|
||||
use smithay::reexports::wayland_server::backend::ClientId;
|
||||
use smithay::reexports::wayland_server::{
|
||||
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, protocol as wl,
|
||||
};
|
||||
use smithay::reexports::{
|
||||
wayland_protocols as protocols, wayland_protocols_misc as misc, wayland_protocols_wlr as wlr,
|
||||
};
|
||||
use smithay::wayland::{Dispatch2, GlobalDispatch2};
|
||||
|
||||
// Forward stock protocol interfaces through Smithay's current dispatch API.
|
||||
// Keep xdg_toplevel and ext_session_lock_v1 explicit: Ferese validates their
|
||||
// requests before delegation, including ownership of the active session lock.
|
||||
macro_rules! delegate_protocols {
|
||||
($($interface:path),* $(,)?) => {
|
||||
$(
|
||||
impl<U: Dispatch2<$interface, Ferese>> Dispatch<$interface, U> for Ferese {
|
||||
fn request(
|
||||
state: &mut Self,
|
||||
client: &Client,
|
||||
resource: &$interface,
|
||||
request: <$interface as Resource>::Request,
|
||||
data: &U,
|
||||
display: &DisplayHandle,
|
||||
init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
data.request(state, client, resource, request, display, init);
|
||||
}
|
||||
|
||||
fn destroyed(state: &mut Self, client: ClientId, resource: &$interface, data: &U) {
|
||||
data.destroyed(state, client, resource);
|
||||
}
|
||||
}
|
||||
|
||||
impl<U: GlobalDispatch2<$interface, Ferese>> GlobalDispatch<$interface, U> for Ferese {
|
||||
fn bind(
|
||||
state: &mut Self,
|
||||
display: &DisplayHandle,
|
||||
client: &Client,
|
||||
resource: New<$interface>,
|
||||
data: &U,
|
||||
init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
data.bind(state, display, client, resource, init);
|
||||
}
|
||||
|
||||
fn can_view(client: Client, data: &U) -> bool {
|
||||
data.can_view(&client)
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
delegate_protocols!(
|
||||
wlr::virtual_pointer::v1::server::zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
|
||||
wlr::virtual_pointer::v1::server::zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
|
||||
protocols::ext::image_capture_source::v1::server::ext_image_capture_source_v1::ExtImageCaptureSourceV1,
|
||||
protocols::ext::image_capture_source::v1::server::ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1,
|
||||
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_manager_v1::ExtImageCopyCaptureManagerV1,
|
||||
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_session_v1::ExtImageCopyCaptureSessionV1,
|
||||
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_cursor_session_v1::ExtImageCopyCaptureCursorSessionV1,
|
||||
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_frame_v1::ExtImageCopyCaptureFrameV1,
|
||||
protocols::wp::idle_inhibit::zv1::server::zwp_idle_inhibit_manager_v1::ZwpIdleInhibitManagerV1,
|
||||
protocols::wp::idle_inhibit::zv1::server::zwp_idle_inhibitor_v1::ZwpIdleInhibitorV1,
|
||||
protocols::ext::data_control::v1::server::ext_data_control_device_v1::ExtDataControlDeviceV1,
|
||||
protocols::ext::data_control::v1::server::ext_data_control_manager_v1::ExtDataControlManagerV1,
|
||||
protocols::ext::data_control::v1::server::ext_data_control_source_v1::ExtDataControlSourceV1,
|
||||
protocols::ext::idle_notify::v1::server::ext_idle_notification_v1::ExtIdleNotificationV1,
|
||||
protocols::ext::idle_notify::v1::server::ext_idle_notifier_v1::ExtIdleNotifierV1,
|
||||
protocols::ext::session_lock::v1::server::ext_session_lock_manager_v1::ExtSessionLockManagerV1,
|
||||
protocols::ext::session_lock::v1::server::ext_session_lock_surface_v1::ExtSessionLockSurfaceV1,
|
||||
protocols::wp::alpha_modifier::v1::server::wp_alpha_modifier_surface_v1::WpAlphaModifierSurfaceV1,
|
||||
protocols::wp::alpha_modifier::v1::server::wp_alpha_modifier_v1::WpAlphaModifierV1,
|
||||
protocols::wp::cursor_shape::v1::server::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1,
|
||||
protocols::wp::cursor_shape::v1::server::wp_cursor_shape_manager_v1::WpCursorShapeManagerV1,
|
||||
protocols::wp::fractional_scale::v1::server::wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1,
|
||||
protocols::wp::fractional_scale::v1::server::wp_fractional_scale_v1::WpFractionalScaleV1,
|
||||
protocols::wp::keyboard_shortcuts_inhibit::zv1::server::zwp_keyboard_shortcuts_inhibit_manager_v1::ZwpKeyboardShortcutsInhibitManagerV1,
|
||||
protocols::wp::keyboard_shortcuts_inhibit::zv1::server::zwp_keyboard_shortcuts_inhibitor_v1::ZwpKeyboardShortcutsInhibitorV1,
|
||||
protocols::wp::linux_dmabuf::zv1::server::zwp_linux_buffer_params_v1::ZwpLinuxBufferParamsV1,
|
||||
protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_feedback_v1::ZwpLinuxDmabufFeedbackV1,
|
||||
protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1,
|
||||
protocols::wp::pointer_constraints::zv1::server::zwp_confined_pointer_v1::ZwpConfinedPointerV1,
|
||||
protocols::wp::pointer_constraints::zv1::server::zwp_locked_pointer_v1::ZwpLockedPointerV1,
|
||||
protocols::wp::pointer_constraints::zv1::server::zwp_pointer_constraints_v1::ZwpPointerConstraintsV1,
|
||||
protocols::wp::presentation_time::server::wp_presentation::WpPresentation,
|
||||
protocols::wp::presentation_time::server::wp_presentation_feedback::WpPresentationFeedback,
|
||||
protocols::wp::primary_selection::zv1::server::zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1,
|
||||
protocols::wp::primary_selection::zv1::server::zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1,
|
||||
protocols::wp::primary_selection::zv1::server::zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,
|
||||
protocols::wp::relative_pointer::zv1::server::zwp_relative_pointer_manager_v1::ZwpRelativePointerManagerV1,
|
||||
protocols::wp::relative_pointer::zv1::server::zwp_relative_pointer_v1::ZwpRelativePointerV1,
|
||||
protocols::wp::security_context::v1::server::wp_security_context_manager_v1::WpSecurityContextManagerV1,
|
||||
protocols::wp::security_context::v1::server::wp_security_context_v1::WpSecurityContextV1,
|
||||
protocols::wp::single_pixel_buffer::v1::server::wp_single_pixel_buffer_manager_v1::WpSinglePixelBufferManagerV1,
|
||||
protocols::wp::text_input::zv3::server::zwp_text_input_manager_v3::ZwpTextInputManagerV3,
|
||||
protocols::wp::text_input::zv3::server::zwp_text_input_v3::ZwpTextInputV3,
|
||||
protocols::wp::viewporter::server::wp_viewport::WpViewport,
|
||||
protocols::wp::viewporter::server::wp_viewporter::WpViewporter,
|
||||
protocols::xdg::activation::v1::server::xdg_activation_token_v1::XdgActivationTokenV1,
|
||||
protocols::xdg::activation::v1::server::xdg_activation_v1::XdgActivationV1,
|
||||
protocols::xdg::decoration::zv1::server::zxdg_decoration_manager_v1::ZxdgDecorationManagerV1,
|
||||
protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1,
|
||||
protocols::xdg::foreign::zv2::server::zxdg_exported_v2::ZxdgExportedV2,
|
||||
protocols::xdg::foreign::zv2::server::zxdg_exporter_v2::ZxdgExporterV2,
|
||||
protocols::xdg::foreign::zv2::server::zxdg_imported_v2::ZxdgImportedV2,
|
||||
protocols::xdg::foreign::zv2::server::zxdg_importer_v2::ZxdgImporterV2,
|
||||
protocols::xdg::shell::server::xdg_popup::XdgPopup,
|
||||
protocols::xdg::shell::server::xdg_positioner::XdgPositioner,
|
||||
protocols::xdg::shell::server::xdg_surface::XdgSurface,
|
||||
protocols::xdg::shell::server::xdg_wm_base::XdgWmBase,
|
||||
protocols::xdg::toplevel_icon::v1::server::xdg_toplevel_icon_manager_v1::XdgToplevelIconManagerV1,
|
||||
protocols::xdg::toplevel_icon::v1::server::xdg_toplevel_icon_v1::XdgToplevelIconV1,
|
||||
protocols::xdg::xdg_output::zv1::server::zxdg_output_manager_v1::ZxdgOutputManagerV1,
|
||||
protocols::xdg::xdg_output::zv1::server::zxdg_output_v1::ZxdgOutputV1,
|
||||
misc::zwp_input_method_v2::server::zwp_input_method_keyboard_grab_v2::ZwpInputMethodKeyboardGrabV2,
|
||||
misc::zwp_input_method_v2::server::zwp_input_method_manager_v2::ZwpInputMethodManagerV2,
|
||||
misc::zwp_input_method_v2::server::zwp_input_method_v2::ZwpInputMethodV2,
|
||||
misc::zwp_input_method_v2::server::zwp_input_popup_surface_v2::ZwpInputPopupSurfaceV2,
|
||||
misc::zwp_virtual_keyboard_v1::server::zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1,
|
||||
misc::zwp_virtual_keyboard_v1::server::zwp_virtual_keyboard_v1::ZwpVirtualKeyboardV1,
|
||||
wlr::data_control::v1::server::zwlr_data_control_device_v1::ZwlrDataControlDeviceV1,
|
||||
wlr::data_control::v1::server::zwlr_data_control_manager_v1::ZwlrDataControlManagerV1,
|
||||
wlr::data_control::v1::server::zwlr_data_control_source_v1::ZwlrDataControlSourceV1,
|
||||
wlr::layer_shell::v1::server::zwlr_layer_shell_v1::ZwlrLayerShellV1,
|
||||
wlr::layer_shell::v1::server::zwlr_layer_surface_v1::ZwlrLayerSurfaceV1,
|
||||
wl::wl_buffer::WlBuffer,
|
||||
wl::wl_callback::WlCallback,
|
||||
wl::wl_compositor::WlCompositor,
|
||||
wl::wl_data_device::WlDataDevice,
|
||||
wl::wl_data_device_manager::WlDataDeviceManager,
|
||||
wl::wl_data_source::WlDataSource,
|
||||
wl::wl_keyboard::WlKeyboard,
|
||||
wl::wl_output::WlOutput,
|
||||
wl::wl_pointer::WlPointer,
|
||||
wl::wl_region::WlRegion,
|
||||
wl::wl_seat::WlSeat,
|
||||
wl::wl_shm::WlShm,
|
||||
wl::wl_shm_pool::WlShmPool,
|
||||
wl::wl_subcompositor::WlSubcompositor,
|
||||
wl::wl_subsurface::WlSubsurface,
|
||||
wl::wl_surface::WlSurface,
|
||||
wl::wl_touch::WlTouch,
|
||||
);
|
||||
@@ -0,0 +1,101 @@
|
||||
use std::fs::File;
|
||||
|
||||
use smithay::output::Output;
|
||||
use smithay::reexports::wayland_protocols_wlr::gamma_control::v1::server::{
|
||||
zwlr_gamma_control_manager_v1::{self, ZwlrGammaControlManagerV1},
|
||||
zwlr_gamma_control_v1::{self, ZwlrGammaControlV1},
|
||||
};
|
||||
use smithay::reexports::wayland_server::{
|
||||
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
|
||||
};
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::backends::direct::gamma;
|
||||
use crate::gamma::Ramp;
|
||||
|
||||
pub(crate) fn init_global(display: &DisplayHandle) {
|
||||
display.create_global::<Ferese, ZwlrGammaControlManagerV1, ()>(1, ());
|
||||
}
|
||||
|
||||
impl GlobalDispatch<ZwlrGammaControlManagerV1, ()> for Ferese {
|
||||
fn bind(
|
||||
_state: &mut Self,
|
||||
_display: &DisplayHandle,
|
||||
_client: &Client,
|
||||
resource: New<ZwlrGammaControlManagerV1>,
|
||||
_data: &(),
|
||||
data_init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
data_init.init(resource, ());
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZwlrGammaControlManagerV1, ()> for Ferese {
|
||||
fn request(
|
||||
state: &mut Self,
|
||||
_client: &Client,
|
||||
_resource: &ZwlrGammaControlManagerV1,
|
||||
request: zwlr_gamma_control_manager_v1::Request,
|
||||
_data: &(),
|
||||
_display: &DisplayHandle,
|
||||
data_init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
match request {
|
||||
zwlr_gamma_control_manager_v1::Request::GetGammaControl { id, output } => {
|
||||
let output = Output::from_resource(&output);
|
||||
let control = data_init.init(id, output.clone());
|
||||
if let Some(size) = output
|
||||
.as_ref()
|
||||
.and_then(|output| gamma::acquire(state, output, &control))
|
||||
{
|
||||
control.gamma_size(size as u32);
|
||||
} else {
|
||||
control.failed();
|
||||
}
|
||||
}
|
||||
zwlr_gamma_control_manager_v1::Request::Destroy => {}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZwlrGammaControlV1, Option<Output>> for Ferese {
|
||||
fn request(
|
||||
state: &mut Self,
|
||||
_client: &Client,
|
||||
control: &ZwlrGammaControlV1,
|
||||
request: zwlr_gamma_control_v1::Request,
|
||||
output: &Option<Output>,
|
||||
_display: &DisplayHandle,
|
||||
_data_init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
match request {
|
||||
zwlr_gamma_control_v1::Request::SetGamma { fd } => {
|
||||
let Some(output) = output else { return };
|
||||
let Some(size) = gamma::size(state, output, control) else {
|
||||
return;
|
||||
};
|
||||
match Ramp::read(File::from(fd), size) {
|
||||
Ok(ramp) => gamma::set(state, output, control, ramp),
|
||||
Err(error) => control.post_error(
|
||||
zwlr_gamma_control_v1::Error::InvalidGamma,
|
||||
format!("Invalid gamma table: {error}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
zwlr_gamma_control_v1::Request::Destroy => {}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn destroyed(
|
||||
state: &mut Self,
|
||||
_client: smithay::reexports::wayland_server::backend::ClientId,
|
||||
control: &ZwlrGammaControlV1,
|
||||
output: &Option<Output>,
|
||||
) {
|
||||
if let Some(output) = output {
|
||||
gamma::release(state, output, control);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,952 @@
|
||||
//! Persistent, damage-driven capture for the native portal. One frame may be
|
||||
//! outstanding per session; client buffers remain owned until GPU completion.
|
||||
use smithay::reexports::calloop::RegistrationToken;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ferese_layout::WindowId;
|
||||
use smithay::backend::allocator::{Fourcc, Modifier};
|
||||
use smithay::backend::egl::EGLDevice;
|
||||
use smithay::backend::renderer::gles::{Capability, GlesRenderer, GlesTexture};
|
||||
use smithay::backend::renderer::Texture;
|
||||
use smithay::backend::renderer::utils::{CommitCounter, RendererSurfaceStateUserData, SurfaceView};
|
||||
use smithay::output::{Output, WeakOutput};
|
||||
use smithay::reexports::wayland_protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_frame_v1::FailureReason;
|
||||
use smithay::reexports::wayland_server::backend::ObjectId;
|
||||
use smithay::reexports::wayland_server::{Client, DisplayHandle, Resource};
|
||||
use smithay::reexports::wayland_server::protocol::wl_shm;
|
||||
use smithay::utils::{Buffer, Logical, Point, Rectangle, Size, Transform};
|
||||
use smithay::wayland::compositor::{TraversalAction, with_surface_tree_downward};
|
||||
use smithay::wayland::image_capture_source::{ImageCaptureSource, ImageCaptureSourceHandler, OutputCaptureSourceHandler, OutputCaptureSourceState};
|
||||
use smithay::wayland::image_copy_capture::{BufferConstraints, DmabufConstraints, Frame, FrameRef, ImageCopyCaptureHandler, ImageCopyCaptureState, Session, SessionRef};
|
||||
|
||||
use crate::Ferese;
|
||||
|
||||
const MAX_SESSIONS: usize = 16;
|
||||
// Reserve for the maximum eight portal pool buffers, a retained target and a
|
||||
// fallback transfer texture. Imports of the same allocation are not extra pixels.
|
||||
const MAX_STREAM_BYTES: u64 = 512 * 1024 * 1024;
|
||||
fn stream_bytes(size: Size<i32, Buffer>) -> u64 {
|
||||
// The portal rejects DMA strides above this allowance. Driver bookkeeping
|
||||
// and encoder memory are separate from this pixel-storage reservation.
|
||||
let stride = (size.w.max(0) as u64 * 4 + 4095) & !4095;
|
||||
stride
|
||||
.saturating_mul(size.h.max(0) as u64)
|
||||
.saturating_mul(10)
|
||||
}
|
||||
|
||||
pub(super) enum Source {
|
||||
Output(WeakOutput),
|
||||
Window(WindowId),
|
||||
Workspace(super::workspace_capture::WorkspaceSource),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct SourcePaused(pub std::sync::atomic::AtomicBool);
|
||||
|
||||
fn source_active(source: &ImageCaptureSource) -> bool {
|
||||
source
|
||||
.user_data()
|
||||
.get::<SourcePaused>()
|
||||
.is_none_or(|paused| !paused.0.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct SurfaceStamp {
|
||||
id: ObjectId,
|
||||
commit: CommitCounter,
|
||||
view: SurfaceView,
|
||||
scale: i32,
|
||||
transform: Transform,
|
||||
alpha: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct WindowStamp {
|
||||
surfaces: Vec<SurfaceStamp>,
|
||||
geometry: Rectangle<i32, Logical>,
|
||||
scale: f64,
|
||||
epoch: u64,
|
||||
cursor: Option<CursorStamp>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub(super) struct CursorStamp {
|
||||
location: Point<f64, Logical>,
|
||||
geometry: Rectangle<i32, Logical>,
|
||||
image: CursorImageStamp,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum CursorImageStamp {
|
||||
Named(Option<smithay::input::pointer::CursorIcon>, Instant, usize),
|
||||
Surface(Vec<SurfaceStamp>, Point<i32, Logical>),
|
||||
Hidden,
|
||||
}
|
||||
|
||||
fn surface_stamp(
|
||||
root: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
|
||||
crop: Option<Rectangle<i32, Logical>>,
|
||||
) -> Vec<SurfaceStamp> {
|
||||
let mut stamp = Vec::new();
|
||||
with_surface_tree_downward(
|
||||
root,
|
||||
Point::<f64, Logical>::default(),
|
||||
|surface, states, location| {
|
||||
let Some(data) = states.data_map.get::<RendererSurfaceStateUserData>() else {
|
||||
return TraversalAction::SkipChildren;
|
||||
};
|
||||
use smithay::wayland::alpha_modifier::AlphaModifierSurfaceCachedState;
|
||||
let alpha = states
|
||||
.cached_state
|
||||
.get::<AlphaModifierSurfaceCachedState>()
|
||||
.current()
|
||||
.multiplier_f32();
|
||||
let data = data.lock().expect("surface metadata mutex poisoned");
|
||||
let Some(view) = data.view() else {
|
||||
return TraversalAction::SkipChildren;
|
||||
};
|
||||
let location = *location + view.offset.to_f64();
|
||||
if crop.is_none_or(|crop| {
|
||||
Rectangle::new(location, view.dst.to_f64()).overlaps(crop.to_f64())
|
||||
}) {
|
||||
stamp.push(SurfaceStamp {
|
||||
id: surface.id(),
|
||||
commit: data.current_commit(),
|
||||
view,
|
||||
scale: data.buffer_scale(),
|
||||
transform: data.buffer_transform(),
|
||||
alpha,
|
||||
});
|
||||
}
|
||||
// An off-crop parent can have a child offset back into the captured area.
|
||||
TraversalAction::DoChildren(location)
|
||||
},
|
||||
|_, _, _| {},
|
||||
|_, _, _| true,
|
||||
);
|
||||
stamp
|
||||
}
|
||||
|
||||
fn pending_frame_callbacks(
|
||||
root: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
|
||||
) -> bool {
|
||||
let mut pending = false;
|
||||
with_surface_tree_downward(
|
||||
root,
|
||||
(),
|
||||
|_, states, _| {
|
||||
if states
|
||||
.data_map
|
||||
.get::<RendererSurfaceStateUserData>()
|
||||
.is_some_and(|data| {
|
||||
data.lock()
|
||||
.expect("surface metadata mutex poisoned")
|
||||
.view()
|
||||
.is_none()
|
||||
})
|
||||
{
|
||||
return TraversalAction::SkipChildren;
|
||||
}
|
||||
pending |= !states
|
||||
.cached_state
|
||||
.get::<smithay::wayland::compositor::SurfaceAttributes>()
|
||||
.current()
|
||||
.frame_callbacks
|
||||
.is_empty();
|
||||
TraversalAction::DoChildren(())
|
||||
},
|
||||
|_, _, _| {},
|
||||
|_, _, _| true,
|
||||
);
|
||||
pending
|
||||
}
|
||||
|
||||
pub(super) struct Active {
|
||||
pub(super) session: Session,
|
||||
pub(super) pending: Option<Frame>,
|
||||
pub(super) revision: Option<u64>,
|
||||
window_stamp: Option<WindowStamp>,
|
||||
size: Size<i32, Buffer>,
|
||||
dma_node: Option<smithay::backend::drm::DrmNode>,
|
||||
}
|
||||
|
||||
struct InFlight {
|
||||
frame: Frame,
|
||||
session: SessionRef,
|
||||
epoch: u64,
|
||||
fence: RegistrationToken,
|
||||
timeout: RegistrationToken,
|
||||
}
|
||||
|
||||
pub(crate) struct CaptureSessions {
|
||||
copy: ImageCopyCaptureState,
|
||||
outputs: OutputCaptureSourceState,
|
||||
active: Vec<Active>,
|
||||
in_flight: HashMap<u64, InFlight>,
|
||||
next_fence: u64,
|
||||
callback_timer: Option<RegistrationToken>,
|
||||
callback_deadline: Option<Instant>,
|
||||
workspace_callbacks: HashMap<ferese_core::WorkspaceId, Instant>,
|
||||
workspaces:
|
||||
HashMap<(ferese_core::WorkspaceId, bool), super::workspace_capture::WorkspaceCapture>,
|
||||
}
|
||||
|
||||
fn authorized(client: &Client) -> bool {
|
||||
super::screencopy::capture_allowed()
|
||||
&& client
|
||||
.get_data::<crate::state::ClientState>()
|
||||
.is_some_and(|state| {
|
||||
state
|
||||
.capabilities
|
||||
.contains(crate::private_client::ClientCapabilities::WINDOW_CAPTURE)
|
||||
})
|
||||
}
|
||||
|
||||
impl CaptureSessions {
|
||||
pub(super) fn workspace_callback_deadline(
|
||||
&self,
|
||||
id: ferese_core::WorkspaceId,
|
||||
now: Instant,
|
||||
) -> Instant {
|
||||
self.workspace_callbacks.get(&id).copied().unwrap_or(now)
|
||||
}
|
||||
pub(super) fn set_workspace_callback_deadline(
|
||||
&mut self,
|
||||
id: ferese_core::WorkspaceId,
|
||||
deadline: Instant,
|
||||
) {
|
||||
self.workspace_callbacks.insert(id, deadline);
|
||||
}
|
||||
pub(crate) fn invalidate_workspaces(&mut self) {
|
||||
for view in self.workspaces.values_mut() {
|
||||
view.invalidate();
|
||||
}
|
||||
}
|
||||
pub(crate) fn workspace_changed(&mut self, id: ferese_core::WorkspaceId) {
|
||||
for ((workspace, _), view) in &mut self.workspaces {
|
||||
if *workspace == id {
|
||||
view.content_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn workspace_geometry_changed(&mut self, id: ferese_core::WorkspaceId) {
|
||||
for ((workspace, _), view) in &mut self.workspaces {
|
||||
if *workspace == id {
|
||||
view.invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn captured_workspaces(&self) -> Vec<ferese_core::WorkspaceId> {
|
||||
self.workspaces.keys().map(|(id, _)| *id).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn new(display: &DisplayHandle) -> Self {
|
||||
Self {
|
||||
copy: ImageCopyCaptureState::new_with_filter::<Ferese, _>(display, authorized),
|
||||
outputs: OutputCaptureSourceState::new_with_filter::<Ferese, _>(display, authorized),
|
||||
active: Vec::new(),
|
||||
in_flight: HashMap::new(),
|
||||
next_fence: 0,
|
||||
callback_timer: None,
|
||||
callback_deadline: None,
|
||||
workspace_callbacks: HashMap::new(),
|
||||
workspaces: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_sessions(&self) -> bool {
|
||||
self.active.iter().any(|active| {
|
||||
active.pending.is_some()
|
||||
&& matches!(
|
||||
active.session.source().user_data().get::<Source>(),
|
||||
Some(Source::Output(_))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn pending_output(&self, output: &Output, cursor: bool) -> bool {
|
||||
self.active.iter().any(|active| {
|
||||
active.pending.is_some()
|
||||
&& active.session.draw_cursor() == cursor
|
||||
&& matches!(active.session.source().user_data().get::<Source>(),
|
||||
Some(Source::Output(weak)) if weak.upgrade().as_ref() == Some(output))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dma_constraints(renderer: &GlesRenderer) -> Option<Arc<DmabufConstraints>> {
|
||||
struct Cached(Option<Arc<DmabufConstraints>>);
|
||||
renderer
|
||||
.egl_context()
|
||||
.user_data()
|
||||
.get_or_insert_threadsafe(|| Cached(query_dma_constraints(renderer).map(Arc::new)))
|
||||
.0
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn query_dma_constraints(renderer: &GlesRenderer) -> Option<DmabufConstraints> {
|
||||
if !renderer.capabilities().contains(&Capability::ExportFence) {
|
||||
return None;
|
||||
}
|
||||
let display = renderer.egl_context().display();
|
||||
if !display
|
||||
.extensions()
|
||||
.iter()
|
||||
.any(|extension| extension == "EGL_ANDROID_native_fence_sync")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let node = EGLDevice::device_for_display(display)
|
||||
.ok()?
|
||||
.try_get_render_node()
|
||||
.ok()??;
|
||||
let format = smithay::backend::allocator::Format {
|
||||
code: Fourcc::Xrgb8888,
|
||||
modifier: Modifier::Linear,
|
||||
};
|
||||
display
|
||||
.dmabuf_render_formats()
|
||||
.contains(&format)
|
||||
.then_some(DmabufConstraints {
|
||||
node,
|
||||
formats: vec![(Fourcc::Xrgb8888, vec![Modifier::Linear])],
|
||||
})
|
||||
}
|
||||
|
||||
impl ImageCaptureSourceHandler for Ferese {}
|
||||
impl OutputCaptureSourceHandler for Ferese {
|
||||
fn output_capture_source_state(&mut self) -> &mut OutputCaptureSourceState {
|
||||
&mut self.image_capture.outputs
|
||||
}
|
||||
fn output_source_created(&mut self, source: ImageCaptureSource, output: &Output) {
|
||||
source
|
||||
.user_data()
|
||||
.insert_if_missing(|| Source::Output(output.downgrade()));
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageCopyCaptureHandler for Ferese {
|
||||
fn image_copy_capture_state(&mut self) -> &mut ImageCopyCaptureState {
|
||||
&mut self.image_capture.copy
|
||||
}
|
||||
fn capture_constraints(&mut self, source: &ImageCaptureSource) -> Option<BufferConstraints> {
|
||||
let (size, output) = self.capture_source_geometry(source)?;
|
||||
Some(BufferConstraints {
|
||||
size,
|
||||
shm: vec![wl_shm::Format::Xrgb8888],
|
||||
dma: self.capture_dma_constraints(&output).as_deref().cloned(),
|
||||
})
|
||||
}
|
||||
fn new_session(&mut self, session: Session) {
|
||||
if self.image_capture.active.len() >= MAX_SESSIONS {
|
||||
return;
|
||||
}
|
||||
let Some(constraints) = session.current_constraints() else {
|
||||
return;
|
||||
};
|
||||
let reserved: u64 = self
|
||||
.image_capture
|
||||
.active
|
||||
.iter()
|
||||
.map(|active| stream_bytes(active.size))
|
||||
.sum();
|
||||
if stream_bytes(constraints.size) > MAX_STREAM_BYTES.saturating_sub(reserved) {
|
||||
return;
|
||||
}
|
||||
self.image_capture.active.push(Active {
|
||||
session,
|
||||
pending: None,
|
||||
revision: None,
|
||||
window_stamp: None,
|
||||
size: constraints.size,
|
||||
dma_node: constraints.dma.as_ref().map(|dma| dma.node),
|
||||
});
|
||||
}
|
||||
fn frame(&mut self, session: &SessionRef, frame: Frame) {
|
||||
let Some(active) = self
|
||||
.image_capture
|
||||
.active
|
||||
.iter_mut()
|
||||
.find(|active| active.session.as_ref() == *session)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if active.pending.is_some() {
|
||||
frame.fail(FailureReason::Unknown);
|
||||
return;
|
||||
}
|
||||
active.pending = Some(frame);
|
||||
if let Some(Source::Output(weak)) = session.source().user_data().get::<Source>()
|
||||
&& let Some(output) = weak.upgrade()
|
||||
{
|
||||
crate::backends::direct::schedule_capture(self, &[output]);
|
||||
}
|
||||
}
|
||||
|
||||
fn session_destroyed(&mut self, session: SessionRef) {
|
||||
self.image_capture
|
||||
.active
|
||||
.retain(|active| active.session.as_ref() != session);
|
||||
let fences: Vec<_> = self
|
||||
.image_capture
|
||||
.in_flight
|
||||
.iter()
|
||||
.filter_map(|(id, pending)| (pending.session == session).then_some(*id))
|
||||
.collect();
|
||||
for id in fences {
|
||||
self.cancel_capture_fence(id);
|
||||
}
|
||||
}
|
||||
fn frame_aborted(&mut self, frame: FrameRef) {
|
||||
let fences: Vec<_> = self
|
||||
.image_capture
|
||||
.in_flight
|
||||
.iter()
|
||||
.filter_map(|(id, pending)| (pending.frame == frame).then_some(*id))
|
||||
.collect();
|
||||
for id in fences {
|
||||
self.cancel_capture_fence(id);
|
||||
}
|
||||
for active in &mut self.image_capture.active {
|
||||
if active
|
||||
.pending
|
||||
.as_ref()
|
||||
.is_some_and(|pending| *pending == frame)
|
||||
{
|
||||
active.pending.take();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
pub(super) fn capture_cursor_stamp(
|
||||
&self,
|
||||
geometry: Rectangle<i32, Logical>,
|
||||
) -> Option<CursorStamp> {
|
||||
use smithay::input::pointer::CursorImageStatus;
|
||||
let pointer = self.seat.get_pointer()?;
|
||||
let image = match &self.cursor_status {
|
||||
CursorImageStatus::Named(_) => {
|
||||
let (icon, epoch, frame) = self.cursor_animation.capture_revision();
|
||||
CursorImageStamp::Named(icon, epoch, frame)
|
||||
}
|
||||
CursorImageStatus::Surface(surface) => {
|
||||
let hotspot = smithay::wayland::compositor::with_states(surface, |states| {
|
||||
states
|
||||
.data_map
|
||||
.get::<smithay::input::pointer::CursorImageSurfaceData>()
|
||||
.map(|data| data.lock().expect("cursor metadata mutex poisoned").hotspot)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
CursorImageStamp::Surface(surface_stamp(surface, None), hotspot)
|
||||
}
|
||||
CursorImageStatus::Hidden => CursorImageStamp::Hidden,
|
||||
};
|
||||
Some(CursorStamp {
|
||||
location: pointer.current_location(),
|
||||
geometry,
|
||||
image,
|
||||
})
|
||||
}
|
||||
|
||||
fn capture_source_geometry(
|
||||
&self,
|
||||
source: &ImageCaptureSource,
|
||||
) -> Option<(Size<i32, Buffer>, Output)> {
|
||||
if self.session_lock.active() || !source.alive() || !super::screencopy::capture_allowed() {
|
||||
return None;
|
||||
}
|
||||
let (size, output) = match source.user_data().get::<Source>()? {
|
||||
Source::Workspace(view) => {
|
||||
if !view.valid(self) {
|
||||
return None;
|
||||
}
|
||||
(view.size, view.output.clone())
|
||||
}
|
||||
Source::Window(id) => {
|
||||
let info = self.window_capture_info(*id)?;
|
||||
let size = info.geometry.size.to_physical_precise_round(info.scale);
|
||||
(Size::from((size.w, size.h)), info.output)
|
||||
}
|
||||
Source::Output(weak) => {
|
||||
let output = weak.upgrade()?;
|
||||
if !self.output_ids.contains_key(&output) {
|
||||
return None;
|
||||
}
|
||||
let size = output
|
||||
.current_transform()
|
||||
.transform_size(output.current_mode()?.size);
|
||||
(Size::from((size.w, size.h)), output)
|
||||
}
|
||||
};
|
||||
let bytes = u64::try_from(size.w)
|
||||
.ok()?
|
||||
.checked_mul(u64::try_from(size.h).ok()?)?
|
||||
.checked_mul(4)?;
|
||||
if bytes == 0 || bytes > 128 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
Some((size, output))
|
||||
}
|
||||
|
||||
fn capture_dma_constraints(&mut self, output: &Output) -> Option<Arc<DmabufConstraints>> {
|
||||
if let Some(backend) = self.nested_backend.clone() {
|
||||
backend
|
||||
.try_borrow_mut()
|
||||
.ok()
|
||||
.and_then(|mut backend| dma_constraints(backend.renderer()))
|
||||
} else {
|
||||
self.direct_backend
|
||||
.as_mut()
|
||||
.and_then(|backend| backend.capture_renderer(output))
|
||||
.and_then(|renderer| dma_constraints(renderer))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn refresh_capture_source(&mut self, source: &ImageCaptureSource) {
|
||||
let mut outputs = Vec::new();
|
||||
for active in &mut self.image_capture.active {
|
||||
if active.session.source() != *source {
|
||||
continue;
|
||||
}
|
||||
active.revision = None;
|
||||
active.window_stamp = None;
|
||||
if active.pending.is_some()
|
||||
&& let Some(Source::Output(weak)) = source.user_data().get::<Source>()
|
||||
&& let Some(output) = weak.upgrade()
|
||||
&& !outputs.contains(&output)
|
||||
{
|
||||
outputs.push(output);
|
||||
}
|
||||
}
|
||||
if !outputs.is_empty() {
|
||||
crate::backends::direct::schedule_capture(self, &outputs);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_capture_sessions(&mut self) {
|
||||
self.image_capture.copy.cleanup();
|
||||
let mut callback_delay: Option<Duration> = None;
|
||||
// Yield between distinct workspace compositions so input and display
|
||||
// deadlines can run even when several consumers request changed views.
|
||||
let mut workspace_compositions = 1usize;
|
||||
let mut sessions = std::mem::take(&mut self.image_capture.active);
|
||||
let mut workspaces = std::mem::take(&mut self.image_capture.workspaces);
|
||||
let mut reserved: u64 = sessions
|
||||
.iter()
|
||||
.map(|active| stream_bytes(active.size))
|
||||
.sum();
|
||||
sessions.retain_mut(|active| {
|
||||
let Some((size, output)) = self.capture_source_geometry(&active.session.source())
|
||||
else {
|
||||
self.cancel_session_fences(active.session.as_ref());
|
||||
reserved = reserved.saturating_sub(stream_bytes(active.size));
|
||||
return false;
|
||||
};
|
||||
let dma = self.capture_dma_constraints(&output);
|
||||
let dma_node = dma.as_ref().map(|dma| dma.node);
|
||||
if size != active.size || dma_node != active.dma_node {
|
||||
let next_reserved = reserved
|
||||
.saturating_sub(stream_bytes(active.size))
|
||||
.saturating_add(stream_bytes(size));
|
||||
if next_reserved > MAX_STREAM_BYTES {
|
||||
self.cancel_session_fences(active.session.as_ref());
|
||||
reserved = reserved.saturating_sub(stream_bytes(active.size));
|
||||
return false;
|
||||
}
|
||||
reserved = next_reserved;
|
||||
if let Some(Source::Workspace(source)) =
|
||||
active.session.source().user_data().get::<Source>()
|
||||
{
|
||||
// GPU resources belong to the renderer that created them.
|
||||
workspaces.remove(&(source.id, active.session.draw_cursor()));
|
||||
}
|
||||
active.size = size;
|
||||
active.dma_node = dma_node;
|
||||
active.revision = None;
|
||||
active.window_stamp = None;
|
||||
active.session.update_constraints(BufferConstraints {
|
||||
size,
|
||||
shm: vec![wl_shm::Format::Xrgb8888],
|
||||
dma: dma.as_deref().cloned(),
|
||||
});
|
||||
if let Some(frame) = active.pending.take() {
|
||||
frame.fail(FailureReason::BufferConstraints);
|
||||
}
|
||||
}
|
||||
if let Some(Source::Workspace(source)) =
|
||||
active.session.source().user_data().get::<Source>()
|
||||
&& source_active(&active.session.source())
|
||||
{
|
||||
let source = source.clone();
|
||||
let key = (source.id, active.session.draw_cursor());
|
||||
if let Some(frame) = active.pending.take() {
|
||||
let view = workspaces.entry(key).or_insert_with(|| {
|
||||
super::workspace_capture::WorkspaceCapture::new(
|
||||
source.clone(),
|
||||
self.capture_epoch,
|
||||
)
|
||||
});
|
||||
if !view.source.valid(self) {
|
||||
*view = super::workspace_capture::WorkspaceCapture::new(
|
||||
source.clone(),
|
||||
self.capture_epoch,
|
||||
);
|
||||
}
|
||||
if let Some(delay) = super::workspace_capture::process(
|
||||
view,
|
||||
self,
|
||||
active,
|
||||
frame,
|
||||
&mut workspace_compositions,
|
||||
) {
|
||||
callback_delay =
|
||||
Some(callback_delay.map_or(delay, |existing| existing.min(delay)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(Source::Window(id)) = active.session.source().user_data().get::<Source>() {
|
||||
let id = *id;
|
||||
if let Some(frame) = active.pending.take() {
|
||||
let Some(info) = self.window_capture_info(id) else {
|
||||
return false;
|
||||
};
|
||||
let callback_interval = Duration::from_nanos(
|
||||
1_000_000_000_000
|
||||
/ info
|
||||
.output
|
||||
.current_mode()
|
||||
.map(|mode| mode.refresh.max(1000) as u64)
|
||||
.unwrap_or(60_000),
|
||||
);
|
||||
if let Some(toplevel) = info.window.toplevel()
|
||||
&& pending_frame_callbacks(toplevel.wl_surface())
|
||||
{
|
||||
smithay::desktop::utils::send_frames_surface_tree(
|
||||
toplevel.wl_surface(),
|
||||
&info.output,
|
||||
self.start_time.elapsed(),
|
||||
Some(callback_interval),
|
||||
|_, _| None,
|
||||
);
|
||||
}
|
||||
let mut stamp = WindowStamp {
|
||||
surfaces: Vec::new(),
|
||||
geometry: info.geometry,
|
||||
scale: info.scale,
|
||||
epoch: self.capture_epoch,
|
||||
cursor: None,
|
||||
};
|
||||
if let Some(toplevel) = info.window.toplevel() {
|
||||
stamp.surfaces = surface_stamp(toplevel.wl_surface(), Some(info.geometry));
|
||||
}
|
||||
let cursor = active
|
||||
.session
|
||||
.draw_cursor()
|
||||
.then(|| self.window_capture_cursor(id, &info.window, info.geometry))
|
||||
.flatten();
|
||||
if let Some(geometry) = cursor
|
||||
&& let Some(pointer) = self.seat.get_pointer()
|
||||
{
|
||||
use smithay::input::pointer::CursorImageStatus;
|
||||
let image = match &self.cursor_status {
|
||||
CursorImageStatus::Named(_) => {
|
||||
let (icon, epoch, frame) = self.cursor_animation.capture_revision();
|
||||
CursorImageStamp::Named(icon, epoch, frame)
|
||||
}
|
||||
CursorImageStatus::Surface(surface) => {
|
||||
let hotspot =
|
||||
smithay::wayland::compositor::with_states(surface, |states| {
|
||||
states
|
||||
.data_map
|
||||
.get::<smithay::input::pointer::CursorImageSurfaceData>(
|
||||
)
|
||||
.map(|data| {
|
||||
data.lock()
|
||||
.expect("cursor metadata mutex poisoned")
|
||||
.hotspot
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
CursorImageStamp::Surface(surface_stamp(surface, None), hotspot)
|
||||
}
|
||||
CursorImageStatus::Hidden => CursorImageStamp::Hidden,
|
||||
};
|
||||
stamp.cursor = Some(CursorStamp {
|
||||
location: pointer.current_location(),
|
||||
geometry,
|
||||
image,
|
||||
});
|
||||
}
|
||||
if active.window_stamp.as_ref() == Some(&stamp) {
|
||||
active.pending = Some(frame);
|
||||
if info
|
||||
.window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| pending_frame_callbacks(toplevel.wl_surface()))
|
||||
{
|
||||
callback_delay =
|
||||
Some(callback_delay.map_or(callback_interval, |delay| {
|
||||
delay.min(callback_interval)
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
crate::backends::direct::capture::touch_cache(self);
|
||||
let result = if let Some(backend) = self.nested_backend.clone() {
|
||||
backend
|
||||
.try_borrow_mut()
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|mut backend| {
|
||||
crate::render::capture_stream_window(
|
||||
backend.renderer(),
|
||||
self,
|
||||
&info,
|
||||
cursor,
|
||||
&frame.buffer(),
|
||||
)
|
||||
})
|
||||
} else if let Some(mut backend) = self.direct_backend.take() {
|
||||
let result = backend
|
||||
.capture_renderer(&info.output)
|
||||
.ok_or_else(|| "Capture output is unavailable".to_string())
|
||||
.and_then(|renderer| {
|
||||
crate::render::capture_stream_window(
|
||||
renderer,
|
||||
self,
|
||||
&info,
|
||||
cursor,
|
||||
&frame.buffer(),
|
||||
)
|
||||
});
|
||||
self.direct_backend = Some(backend);
|
||||
result
|
||||
} else {
|
||||
Err("Capture renderer is unavailable".into())
|
||||
};
|
||||
active.window_stamp = Some(stamp);
|
||||
self.finish_stream_frame(frame, active.session.as_ref(), result);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
workspaces.retain(|(id, cursor), view| {
|
||||
let matches = |active: &Active| {
|
||||
active.session.draw_cursor() == *cursor
|
||||
&& matches!(active.session.source().user_data().get::<Source>(), Some(Source::Workspace(source)) if source.id == *id)
|
||||
};
|
||||
if !sessions.iter().any(matches) { return false; }
|
||||
let pending = sessions.iter().any(|active| matches(active)
|
||||
&& active.pending.is_some() && source_active(&active.session.source()));
|
||||
if !pending && view.target.is_some() {
|
||||
let idle = view.last_used.elapsed();
|
||||
if idle >= Duration::from_secs(2) {
|
||||
view.release_idle_target();
|
||||
} else {
|
||||
let delay = Duration::from_secs(2) - idle;
|
||||
callback_delay = Some(callback_delay.map_or(delay, |existing| existing.min(delay)));
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
self.image_capture
|
||||
.workspace_callbacks
|
||||
.retain(|id, _| workspaces.keys().any(|(workspace, _)| id == workspace));
|
||||
self.image_capture.workspaces = workspaces;
|
||||
self.image_capture.active = sessions;
|
||||
if let Some(delay) = callback_delay {
|
||||
let deadline = Instant::now() + delay;
|
||||
if self
|
||||
.image_capture
|
||||
.callback_deadline
|
||||
.is_none_or(|previous| deadline < previous)
|
||||
{
|
||||
if let Some(timer) = self.image_capture.callback_timer.take() {
|
||||
self.loop_handle.remove(timer);
|
||||
}
|
||||
self.image_capture.callback_deadline = Some(deadline);
|
||||
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
|
||||
match self
|
||||
.loop_handle
|
||||
.insert_source(Timer::from_duration(delay), |_, _, state| {
|
||||
state.image_capture.callback_timer = None;
|
||||
state.image_capture.callback_deadline = None;
|
||||
TimeoutAction::Drop
|
||||
}) {
|
||||
Ok(token) => self.image_capture.callback_timer = Some(token),
|
||||
Err(error) => {
|
||||
self.image_capture.callback_deadline = None;
|
||||
tracing::warn!(%error, "could not schedule capture callbacks")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(timer) = self.image_capture.callback_timer.take() {
|
||||
self.loop_handle.remove(timer);
|
||||
self.image_capture.callback_deadline = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn process_output_streams(
|
||||
&mut self,
|
||||
renderer: &mut GlesRenderer,
|
||||
texture: &GlesTexture,
|
||||
output: &Output,
|
||||
cursor: bool,
|
||||
revision: u64,
|
||||
) {
|
||||
let mut sessions = std::mem::take(&mut self.image_capture.active);
|
||||
for active in &mut sessions {
|
||||
let matches = matches!(active.session.source().user_data().get::<Source>(), Some(Source::Output(weak)) if weak.upgrade().as_ref() == Some(output));
|
||||
if matches {
|
||||
let size = output.current_transform().transform_size(texture.size());
|
||||
if active.size != Size::from((size.w, size.h)) {
|
||||
if let Some(frame) = active.pending.take() {
|
||||
frame.fail(FailureReason::BufferConstraints);
|
||||
}
|
||||
active.revision = None;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if matches
|
||||
&& active.session.draw_cursor() == cursor
|
||||
&& active.revision != Some(revision)
|
||||
&& let Some(frame) = active.pending.take()
|
||||
{
|
||||
let result = crate::render::capture_stream_texture(
|
||||
renderer,
|
||||
texture,
|
||||
&frame.buffer(),
|
||||
output.current_transform(),
|
||||
false,
|
||||
);
|
||||
active.revision = Some(revision);
|
||||
self.finish_stream_frame(frame, active.session.as_ref(), result);
|
||||
}
|
||||
}
|
||||
self.image_capture.active = sessions;
|
||||
}
|
||||
|
||||
fn cancel_session_fences(&mut self, session: SessionRef) {
|
||||
let fences: Vec<_> = self
|
||||
.image_capture
|
||||
.in_flight
|
||||
.iter()
|
||||
.filter_map(|(id, pending)| (pending.session == session).then_some(*id))
|
||||
.collect();
|
||||
for id in fences {
|
||||
self.cancel_capture_fence(id);
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_capture_fence(&mut self, id: u64) {
|
||||
if let Some(pending) = self.image_capture.in_flight.remove(&id) {
|
||||
self.loop_handle.remove(pending.fence);
|
||||
self.loop_handle.remove(pending.timeout);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish_stream_frame(
|
||||
&mut self,
|
||||
frame: Frame,
|
||||
session: SessionRef,
|
||||
result: Result<smithay::backend::renderer::sync::SyncPoint, String>,
|
||||
) {
|
||||
match result {
|
||||
Ok(sync) if sync.is_reached() => frame.success(
|
||||
Transform::Normal,
|
||||
None,
|
||||
smithay::utils::Clock::<smithay::utils::Monotonic>::new().now(),
|
||||
),
|
||||
Ok(sync) => {
|
||||
let Some(fd) = sync.export() else {
|
||||
frame.fail(FailureReason::Unknown);
|
||||
return;
|
||||
};
|
||||
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
|
||||
use smithay::reexports::calloop::{Interest, Mode, PostAction, generic::Generic};
|
||||
self.image_capture.next_fence = self.image_capture.next_fence.wrapping_add(1);
|
||||
let id = self.image_capture.next_fence;
|
||||
let source = Generic::new(fd, Interest::READ, Mode::OneShot);
|
||||
let fence = match self.loop_handle.insert_source(source, move |_, _, state| {
|
||||
if let Some(pending) = state.image_capture.in_flight.remove(&id) {
|
||||
state.loop_handle.remove(pending.timeout);
|
||||
if pending.epoch == state.capture_epoch
|
||||
&& state
|
||||
.capture_source_geometry(&pending.session.source())
|
||||
.is_some()
|
||||
&& !pending.frame.has_failed()
|
||||
{
|
||||
pending.frame.success(
|
||||
Transform::Normal,
|
||||
None,
|
||||
smithay::utils::Clock::<smithay::utils::Monotonic>::new().now(),
|
||||
);
|
||||
} else {
|
||||
pending.frame.fail(
|
||||
if state
|
||||
.capture_source_geometry(&pending.session.source())
|
||||
.is_some()
|
||||
{
|
||||
FailureReason::BufferConstraints
|
||||
} else {
|
||||
FailureReason::Stopped
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(PostAction::Remove)
|
||||
}) {
|
||||
Ok(token) => token,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "could not register capture fence");
|
||||
frame.fail(FailureReason::Unknown);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let timeout = match self.loop_handle.insert_source(
|
||||
Timer::from_duration(Duration::from_secs(5)),
|
||||
move |_, _, state| {
|
||||
if let Some(pending) = state.image_capture.in_flight.remove(&id) {
|
||||
state.loop_handle.remove(pending.fence);
|
||||
pending.frame.fail(FailureReason::Stopped);
|
||||
state
|
||||
.image_capture
|
||||
.active
|
||||
.retain(|active| active.session.as_ref() != pending.session);
|
||||
}
|
||||
TimeoutAction::Drop
|
||||
},
|
||||
) {
|
||||
Ok(token) => token,
|
||||
Err(error) => {
|
||||
self.loop_handle.remove(fence);
|
||||
tracing::warn!(%error, "could not register capture deadline");
|
||||
frame.fail(FailureReason::Unknown);
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.image_capture.in_flight.insert(
|
||||
id,
|
||||
InFlight {
|
||||
frame,
|
||||
session,
|
||||
epoch: self.capture_epoch,
|
||||
fence,
|
||||
timeout,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "stream capture failed");
|
||||
frame.fail(FailureReason::Unknown);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "workspace_capture_tests.rs"]
|
||||
mod workspace_tests;
|
||||
@@ -1,25 +1,32 @@
|
||||
use smithay::desktop::{PopupKind, WindowSurfaceType, layer_map_for_output};
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
use smithay::utils::{Logical, Rectangle};
|
||||
use smithay::utils::{Point, Size};
|
||||
use smithay::wayland::input_method::{InputMethodHandler, PopupSurface};
|
||||
|
||||
use crate::Ferese;
|
||||
|
||||
impl InputMethodHandler for Ferese {
|
||||
fn new_popup(&mut self, surface: PopupSurface) {
|
||||
if let Err(error) = self.popups.track_popup(PopupKind::InputMethod(surface.clone())) {
|
||||
self.position_input_popup(&surface);
|
||||
if let Err(error) = self
|
||||
.popups
|
||||
.track_popup(PopupKind::InputMethod(surface.clone()))
|
||||
{
|
||||
tracing::warn!(?error, "failed to track input method popup");
|
||||
}
|
||||
crate::backends::direct::render_all(self);
|
||||
crate::backends::direct::render_surface(self, surface.wl_surface());
|
||||
}
|
||||
|
||||
fn dismiss_popup(&mut self, _surface: PopupSurface) {
|
||||
fn dismiss_popup(&mut self, surface: PopupSurface) {
|
||||
let outputs = self.surface_outputs(surface.wl_surface());
|
||||
self.popups.cleanup();
|
||||
crate::backends::direct::render_all(self);
|
||||
crate::backends::direct::render_on(self, &outputs);
|
||||
}
|
||||
|
||||
fn popup_repositioned(&mut self, _surface: PopupSurface) {
|
||||
crate::backends::direct::render_all(self);
|
||||
fn popup_repositioned(&mut self, surface: PopupSurface) {
|
||||
self.position_input_popup(&surface);
|
||||
crate::backends::direct::render_surface(self, surface.wl_surface());
|
||||
}
|
||||
|
||||
fn parent_geometry(&self, parent: &WlSurface) -> Rectangle<i32, Logical> {
|
||||
@@ -33,7 +40,10 @@ impl InputMethodHandler for Ferese {
|
||||
return geometry;
|
||||
}
|
||||
|
||||
let Some(layer) = self.space.layer_for_surface(parent, WindowSurfaceType::TOPLEVEL) else {
|
||||
let Some(layer) = self
|
||||
.space
|
||||
.layer_for_surface(parent, WindowSurfaceType::TOPLEVEL)
|
||||
else {
|
||||
return Rectangle::default();
|
||||
};
|
||||
|
||||
@@ -51,3 +61,95 @@ impl InputMethodHandler for Ferese {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
pub(crate) fn position_committed_input_popup(&mut self, surface: &WlSurface) {
|
||||
if let Some(PopupKind::InputMethod(popup)) = self.popups.find_popup(surface) {
|
||||
self.position_input_popup(&popup);
|
||||
}
|
||||
}
|
||||
|
||||
fn position_input_popup(&self, popup: &PopupSurface) {
|
||||
let Some(parent) = popup.get_parent() else {
|
||||
return;
|
||||
};
|
||||
let parent = self.parent_geometry(&parent.surface);
|
||||
let cursor = popup.text_input_rectangle();
|
||||
let caret = parent.loc + cursor.loc;
|
||||
let output = self
|
||||
.space
|
||||
.outputs()
|
||||
.filter_map(|output| self.space.output_geometry(output))
|
||||
.find(|geometry| geometry.contains(caret))
|
||||
.or_else(|| {
|
||||
self.space
|
||||
.outputs()
|
||||
.find_map(|output| self.space.output_geometry(output))
|
||||
});
|
||||
let Some(output) = output else {
|
||||
return;
|
||||
};
|
||||
let bounds = smithay::desktop::utils::bbox_from_surface_tree(popup.wl_surface(), (0, 0));
|
||||
popup.set_location(input_popup_location(parent, cursor, bounds.size, output));
|
||||
}
|
||||
}
|
||||
|
||||
fn input_popup_location(
|
||||
parent: Rectangle<i32, Logical>,
|
||||
cursor: Rectangle<i32, Logical>,
|
||||
size: Size<i32, Logical>,
|
||||
output: Rectangle<i32, Logical>,
|
||||
) -> Point<i32, Logical> {
|
||||
let caret = parent.loc + cursor.loc;
|
||||
let mut y = caret.y + cursor.size.h;
|
||||
if y + size.h > output.loc.y + output.size.h {
|
||||
y = caret.y - size.h;
|
||||
}
|
||||
let x = caret.x.clamp(
|
||||
output.loc.x,
|
||||
(output.loc.x + output.size.w - size.w).max(output.loc.x),
|
||||
);
|
||||
let y = y.clamp(
|
||||
output.loc.y,
|
||||
(output.loc.y + output.size.h - size.h).max(output.loc.y),
|
||||
);
|
||||
Point::from((x, y)) - parent.loc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn input_popup_uses_cursor_and_flips_at_output_edges() {
|
||||
let output = Rectangle::new((0, 0).into(), (1280, 800).into());
|
||||
let parent = Rectangle::new((400, 36).into(), (880, 560).into());
|
||||
assert_eq!(
|
||||
input_popup_location(
|
||||
parent,
|
||||
Rectangle::new((28, 164).into(), (1, 24).into()),
|
||||
(420, 219).into(),
|
||||
output
|
||||
),
|
||||
Point::from((28, 188))
|
||||
);
|
||||
assert_eq!(
|
||||
input_popup_location(
|
||||
Rectangle::default(),
|
||||
Rectangle::new((1230, 750).into(), (1, 20).into()),
|
||||
(420, 220).into(),
|
||||
output
|
||||
),
|
||||
Point::from((860, 530))
|
||||
);
|
||||
let left = Rectangle::new((-1280, 0).into(), (1280, 800).into());
|
||||
assert_eq!(
|
||||
input_popup_location(
|
||||
Rectangle::default(),
|
||||
Rectangle::new((-10, 780).into(), (1, 20).into()),
|
||||
(420, 220).into(),
|
||||
left
|
||||
),
|
||||
Point::from((-420, 560))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use smithay::desktop::{LayerSurface as DesktopLayerSurface, PopupKind, WindowSurfaceType, layer_map_for_output};
|
||||
use smithay::desktop::{
|
||||
LayerSurface as DesktopLayerSurface, PopupKind, WindowSurfaceType, layer_map_for_output,
|
||||
};
|
||||
use smithay::output::Output;
|
||||
use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
@@ -14,26 +16,45 @@ impl WlrLayerShellHandler for Ferese {
|
||||
&mut self.layer_shell_state
|
||||
}
|
||||
|
||||
fn new_layer_surface(&mut self, surface: LayerSurface, output: Option<WlOutput>, _layer: Layer, namespace: String) {
|
||||
let output = output
|
||||
.as_ref()
|
||||
.and_then(Output::from_resource)
|
||||
.or_else(|| self.space.outputs().next().cloned());
|
||||
fn new_layer_surface(
|
||||
&mut self,
|
||||
surface: LayerSurface,
|
||||
output: Option<WlOutput>,
|
||||
_layer: Layer,
|
||||
namespace: String,
|
||||
) {
|
||||
// A wl_output resource can outlive its registered desktop output.
|
||||
// Explicit requests retain their identity; only unspecified requests
|
||||
// may use the focused/default output policy.
|
||||
let usable = |output: &Output| {
|
||||
self.output_id(output).is_some()
|
||||
&& output.current_mode().is_some()
|
||||
&& self.space.outputs().any(|candidate| candidate == output)
|
||||
};
|
||||
let output = match output {
|
||||
Some(resource) => Output::from_resource(&resource).filter(usable),
|
||||
None => self
|
||||
.focused_output()
|
||||
.filter(|output| usable(output))
|
||||
.cloned()
|
||||
.or_else(|| self.space.outputs().find(|output| usable(output)).cloned()),
|
||||
};
|
||||
let Some(output) = output else {
|
||||
tracing::warn!(%namespace, "layer surface has no available output");
|
||||
surface.send_close();
|
||||
return;
|
||||
};
|
||||
let layer = DesktopLayerSurface::new(surface, namespace.clone());
|
||||
|
||||
if let Err(error) = layer_map_for_output(&output).map_layer(&layer) {
|
||||
tracing::warn!(%namespace, %error, "failed to map layer surface");
|
||||
layer.layer_surface().send_close();
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::debug!(%namespace, output = %output.name(), "mapped layer surface");
|
||||
super::set_surface_tree_output(layer.wl_surface(), &output);
|
||||
self.relayout();
|
||||
crate::backends::direct::render_all(self);
|
||||
self.relayout_on(&[output]);
|
||||
}
|
||||
|
||||
fn new_popup(&mut self, parent: LayerSurface, popup: PopupSurface) {
|
||||
@@ -52,6 +73,13 @@ impl WlrLayerShellHandler for Ferese {
|
||||
}
|
||||
|
||||
fn layer_destroyed(&mut self, surface: LayerSurface) {
|
||||
if self
|
||||
.pending_layer_focus
|
||||
.as_ref()
|
||||
.is_some_and(|(target, _)| target == surface.wl_surface())
|
||||
{
|
||||
self.pending_layer_focus = None;
|
||||
}
|
||||
let Some(layer) = self
|
||||
.space
|
||||
.layer_for_surface(surface.wl_surface(), WindowSurfaceType::TOPLEVEL)
|
||||
@@ -59,61 +87,307 @@ impl WlrLayerShellHandler for Ferese {
|
||||
return;
|
||||
};
|
||||
let restore_focus = layer_has_keyboard_focus(self, &layer);
|
||||
let outputs = self.space.outputs().cloned().collect::<Vec<_>>();
|
||||
let outputs = self
|
||||
.space
|
||||
.outputs()
|
||||
.filter(|output| {
|
||||
layer_map_for_output(output)
|
||||
.layer_geometry(&layer)
|
||||
.is_some()
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for output in outputs {
|
||||
layer_map_for_output(&output).unmap_layer(&layer);
|
||||
for output in &outputs {
|
||||
layer_map_for_output(output).unmap_layer(&layer);
|
||||
}
|
||||
|
||||
self.relayout();
|
||||
self.relayout_on(&outputs);
|
||||
if restore_focus {
|
||||
self.restore_keyboard_focus();
|
||||
}
|
||||
crate::backends::direct::render_all(self);
|
||||
crate::backends::direct::render_on(self, &outputs);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct LayoutState {
|
||||
size: smithay::utils::Size<i32, smithay::utils::Logical>,
|
||||
anchor: smithay::wayland::shell::wlr_layer::Anchor,
|
||||
zone: smithay::wayland::shell::wlr_layer::ExclusiveZone,
|
||||
margin: [i32; 4],
|
||||
layer: Layer,
|
||||
bounds: smithay::utils::Rectangle<i32, smithay::utils::Logical>,
|
||||
}
|
||||
|
||||
impl LayoutState {
|
||||
fn current(layer: &DesktopLayerSurface) -> Self {
|
||||
Self::from_policy(layer.cached_state(), layer.bbox())
|
||||
}
|
||||
|
||||
fn from_policy(
|
||||
policy: smithay::wayland::shell::wlr_layer::LayerSurfaceCachedState,
|
||||
bounds: smithay::utils::Rectangle<i32, smithay::utils::Logical>,
|
||||
) -> Self {
|
||||
Self {
|
||||
size: policy.size,
|
||||
anchor: policy.anchor,
|
||||
zone: policy.exclusive_zone,
|
||||
margin: [
|
||||
policy.margin.top,
|
||||
policy.margin.right,
|
||||
policy.margin.bottom,
|
||||
policy.margin.left,
|
||||
],
|
||||
layer: policy.layer,
|
||||
bounds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) {
|
||||
let Some(layer) = state.space.layer_for_surface(surface, WindowSurfaceType::TOPLEVEL) else {
|
||||
let Some(layer) = state
|
||||
.space
|
||||
.layer_for_surface(surface, WindowSurfaceType::TOPLEVEL)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let outputs = state.space.outputs().cloned().collect::<Vec<_>>();
|
||||
let outputs = state
|
||||
.space
|
||||
.outputs()
|
||||
.filter(|output| {
|
||||
layer_map_for_output(output)
|
||||
.layer_geometry(&layer)
|
||||
.is_some()
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for output in outputs {
|
||||
let mut map = layer_map_for_output(&output);
|
||||
if map.layer_geometry(&layer).is_some() {
|
||||
map.arrange();
|
||||
layer.layer_surface().send_pending_configure();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let policy = layer.cached_state();
|
||||
if !state.session_lock.active
|
||||
&& !state.input_capture.captures(1)
|
||||
&& policy.keyboard_interactivity == KeyboardInteractivity::Exclusive
|
||||
&& matches!(policy.layer, Layer::Top | Layer::Overlay)
|
||||
if !state.layer_mapped_and_visible(&layer)
|
||||
&& state
|
||||
.pending_layer_focus
|
||||
.as_ref()
|
||||
.is_some_and(|(target, _)| target == surface)
|
||||
{
|
||||
state.seat.get_keyboard().expect("seat has a keyboard").set_focus(
|
||||
state,
|
||||
Some(layer.wl_surface().clone()),
|
||||
smithay::utils::SERIAL_COUNTER.next_serial(),
|
||||
);
|
||||
} else if !layer.can_receive_keyboard_focus() && layer_has_keyboard_focus(state, &layer) {
|
||||
state.restore_keyboard_focus();
|
||||
state.pending_layer_focus = None;
|
||||
}
|
||||
let policy = layer.cached_state();
|
||||
let current = LayoutState::current(&layer);
|
||||
let previous = layer.user_data().get_or_insert_threadsafe(|| {
|
||||
std::sync::Mutex::new(None::<(LayoutState, KeyboardInteractivity)>)
|
||||
});
|
||||
let (changed, requested_focus) = {
|
||||
let mut previous = previous
|
||||
.lock()
|
||||
.expect("layer configure state must remain consistent across callbacks");
|
||||
let changed = previous.as_ref().map(|(layout, _)| layout) != Some(¤t);
|
||||
let requested_focus = policy.keyboard_interactivity == KeyboardInteractivity::OnDemand
|
||||
&& previous
|
||||
.as_ref()
|
||||
.is_some_and(|(_, keyboard)| *keyboard == KeyboardInteractivity::None);
|
||||
*previous = Some((current, policy.keyboard_interactivity));
|
||||
(changed, requested_focus)
|
||||
};
|
||||
|
||||
if changed {
|
||||
state.relayout_on(&outputs);
|
||||
}
|
||||
// Configure acknowledgement and keyboard policy are independent of layout.
|
||||
layer.layer_surface().send_pending_configure();
|
||||
|
||||
// Consume the opening interaction once. Later commits, pointer hover, or
|
||||
// another input target must not give a panel a fresh claim to focus.
|
||||
let opening_interaction = if requested_focus
|
||||
&& state
|
||||
.pending_layer_focus
|
||||
.as_ref()
|
||||
.is_some_and(|(target, _)| target == surface)
|
||||
{
|
||||
state
|
||||
.pending_layer_focus
|
||||
.take()
|
||||
.is_some_and(|(_, at)| at.elapsed() <= std::time::Duration::from_secs(5))
|
||||
} else {
|
||||
false
|
||||
};
|
||||
state.reconcile_layer_keyboard_focus();
|
||||
if opening_interaction
|
||||
&& !state
|
||||
.seat
|
||||
.get_keyboard()
|
||||
.is_some_and(|keyboard| keyboard.is_grabbed())
|
||||
&& !state.session_lock.active()
|
||||
&& !state.input_capture.captures(1)
|
||||
&& state.layer_keyboard_eligible(&layer)
|
||||
&& state.exclusive_keyboard_layer().is_none()
|
||||
&& matches!(policy.layer, Layer::Top | Layer::Overlay)
|
||||
&& !layer_has_keyboard_focus(state, &layer)
|
||||
{
|
||||
state
|
||||
.seat
|
||||
.get_keyboard()
|
||||
.expect("seat has a keyboard")
|
||||
.set_focus(
|
||||
state,
|
||||
Some(layer.wl_surface().clone()),
|
||||
smithay::utils::SERIAL_COUNTER.next_serial(),
|
||||
);
|
||||
}
|
||||
|
||||
state.relayout();
|
||||
if let Err(error) = state.display_handle.flush_clients() {
|
||||
tracing::debug!(%error, "failed to flush layer-surface configure");
|
||||
}
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
pub(crate) fn layer_keyboard_eligible(&self, layer: &DesktopLayerSurface) -> bool {
|
||||
layer.can_receive_keyboard_focus() && self.layer_mapped_and_visible(layer)
|
||||
}
|
||||
|
||||
/// Layer-map membership tracks the role and output, not buffer mapping.
|
||||
/// Use the renderer state updated by on_commit_buffer_handler, as for windows.
|
||||
fn layer_mapped_and_visible(&self, layer: &DesktopLayerSurface) -> bool {
|
||||
layer.layer_surface().alive()
|
||||
&& smithay::backend::renderer::utils::with_renderer_surface_state(
|
||||
layer.wl_surface(),
|
||||
|state| state.buffer().is_some(),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
&& self.space.outputs().any(|output| {
|
||||
self.layer_visible_on_output(output, layer.layer())
|
||||
&& layer_map_for_output(output)
|
||||
.layers()
|
||||
.any(|candidate| candidate == layer)
|
||||
})
|
||||
}
|
||||
|
||||
fn keyboard_layer_root(&self) -> Option<WlSurface> {
|
||||
let surface = self.seat.get_keyboard()?.current_focus()?;
|
||||
let root = self
|
||||
.popups
|
||||
.find_popup(&surface)
|
||||
.and_then(|popup| smithay::desktop::find_popup_root_surface(&popup).ok())
|
||||
.unwrap_or(surface);
|
||||
(smithay::wayland::compositor::get_role(&root)
|
||||
== Some(smithay::wayland::shell::wlr_layer::LAYER_SURFACE_ROLE))
|
||||
.then_some(root)
|
||||
}
|
||||
|
||||
pub(crate) fn keyboard_focused_layer(&self) -> Option<DesktopLayerSurface> {
|
||||
self.space
|
||||
.layer_for_surface(&self.keyboard_layer_root()?, WindowSurfaceType::ALL)
|
||||
}
|
||||
|
||||
pub(crate) fn exclusive_keyboard_layer(&self) -> Option<DesktopLayerSurface> {
|
||||
// Overlay outranks Top. Keep the current owner among peers; the protocol
|
||||
// leaves ordering within one layer to the compositor.
|
||||
let focused = self.keyboard_focused_layer();
|
||||
for priority in [Layer::Overlay, Layer::Top] {
|
||||
if let Some(layer) = focused.as_ref().filter(|layer| {
|
||||
layer.layer() == priority
|
||||
&& layer.cached_state().keyboard_interactivity
|
||||
== KeyboardInteractivity::Exclusive
|
||||
&& self.layer_keyboard_eligible(layer)
|
||||
}) {
|
||||
return Some(layer.clone());
|
||||
}
|
||||
for output in self.space.outputs() {
|
||||
let layers = layer_map_for_output(output)
|
||||
.layers_on(priority)
|
||||
.rev()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(layer) = layers.into_iter().find(|layer| {
|
||||
layer.cached_state().keyboard_interactivity == KeyboardInteractivity::Exclusive
|
||||
&& self.layer_keyboard_eligible(layer)
|
||||
}) {
|
||||
return Some(layer.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_layer_keyboard_focus(&mut self) {
|
||||
if self.session_lock.active() || self.input_capture.captures(1) {
|
||||
return;
|
||||
}
|
||||
let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
|
||||
if let Some(root) = self.keyboard_layer_root().filter(|root| {
|
||||
!self.keyboard_focused_layer().is_some_and(|layer| {
|
||||
// A popup owns its keyboard focus independently of the bar's
|
||||
// interactivity policy, but still needs a visible, mapped parent.
|
||||
if keyboard.current_focus().as_ref() != Some(root) {
|
||||
self.layer_mapped_and_visible(&layer)
|
||||
} else {
|
||||
self.layer_keyboard_eligible(&layer)
|
||||
}
|
||||
})
|
||||
}) {
|
||||
// A popup grab cannot keep an ineligible layer alive as input owner.
|
||||
for (popup, _) in smithay::desktop::PopupManager::popups_for_surface(&root) {
|
||||
let _ = smithay::desktop::PopupManager::dismiss_popup(&root, &popup);
|
||||
}
|
||||
keyboard.unset_grab(self);
|
||||
self.restore_keyboard_focus();
|
||||
} else if !keyboard.is_grabbed()
|
||||
&& let Some(layer) = self.exclusive_keyboard_layer()
|
||||
&& !layer_has_keyboard_focus(self, &layer)
|
||||
{
|
||||
keyboard.set_focus(
|
||||
self,
|
||||
Some(layer.wl_surface().clone()),
|
||||
smithay::utils::SERIAL_COUNTER.next_serial(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn layer_has_keyboard_focus(state: &Ferese, layer: &DesktopLayerSurface) -> bool {
|
||||
state
|
||||
.seat
|
||||
.get_keyboard()
|
||||
.and_then(|keyboard| keyboard.current_focus())
|
||||
.and_then(|surface| state.space.layer_for_surface(&surface, WindowSurfaceType::ALL))
|
||||
.keyboard_focused_layer()
|
||||
.is_some_and(|focused| focused == *layer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use smithay::utils::Rectangle;
|
||||
use smithay::wayland::shell::wlr_layer::{Anchor, ExclusiveZone, LayerSurfaceCachedState};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_arrangement_and_mapping_changes_require_layout() {
|
||||
let policy = LayerSurfaceCachedState::default();
|
||||
let bounds = Rectangle::from_size((800, 32).into());
|
||||
let original = LayoutState::from_policy(policy, bounds);
|
||||
assert_eq!(original, LayoutState::from_policy(policy, bounds));
|
||||
|
||||
let mut keyboard = policy;
|
||||
keyboard.keyboard_interactivity = KeyboardInteractivity::Exclusive;
|
||||
assert_eq!(original, LayoutState::from_policy(keyboard, bounds));
|
||||
|
||||
for mutate in [
|
||||
|p: &mut LayerSurfaceCachedState| p.size.w += 1,
|
||||
|p: &mut LayerSurfaceCachedState| p.anchor = Anchor::TOP,
|
||||
|p: &mut LayerSurfaceCachedState| p.exclusive_zone = ExclusiveZone::Exclusive(32),
|
||||
|p: &mut LayerSurfaceCachedState| p.margin.left += 1,
|
||||
|p: &mut LayerSurfaceCachedState| p.layer = Layer::Overlay,
|
||||
] {
|
||||
let mut changed = policy;
|
||||
mutate(&mut changed);
|
||||
assert_ne!(original, LayoutState::from_policy(changed, bounds));
|
||||
}
|
||||
assert_ne!(
|
||||
original,
|
||||
LayoutState::from_policy(policy, Rectangle::from_size((800, 33).into()))
|
||||
);
|
||||
assert_ne!(
|
||||
original,
|
||||
LayoutState::from_policy(policy, Rectangle::default())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod output_tests;
|
||||
@@ -0,0 +1,321 @@
|
||||
use super::*;
|
||||
use crate::state::{ClientState, dispatch_test_client as dispatch, test_client_request as request};
|
||||
use smithay::output::{Mode, PhysicalProperties, Subpixel};
|
||||
use smithay::reexports::calloop::EventLoop;
|
||||
use smithay::reexports::wayland_protocols_wlr::layer_shell::v1::server::zwlr_layer_shell_v1::ZwlrLayerShellV1;
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
use smithay::reexports::wayland_server::protocol::wl_compositor::WlCompositor;
|
||||
use std::io::Read;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn fixture() -> (Ferese, EventLoop<'static, Ferese>) {
|
||||
let mut events = EventLoop::try_new().unwrap();
|
||||
let state = crate::startup_tests::state(&mut events);
|
||||
(state, events)
|
||||
}
|
||||
|
||||
fn output(state: &mut Ferese, name: &str, x: i32) -> Output {
|
||||
let output = Output::new(
|
||||
name.into(),
|
||||
PhysicalProperties {
|
||||
serial_number: String::new(),
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
output.change_current_state(
|
||||
Some(Mode {
|
||||
size: (800, 600).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
state.space.map_output(&output, (x, 0));
|
||||
state.register_output(&output, name.into());
|
||||
output
|
||||
}
|
||||
|
||||
fn read(wire: &mut UnixStream) -> Vec<Vec<u32>> {
|
||||
wire.set_nonblocking(true).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
assert_eq!(
|
||||
wire.read_to_end(&mut bytes).unwrap_err().kind(),
|
||||
std::io::ErrorKind::WouldBlock
|
||||
);
|
||||
wire.set_nonblocking(false).unwrap();
|
||||
let mut messages = Vec::new();
|
||||
let mut offset = 0;
|
||||
while offset < bytes.len() {
|
||||
let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap());
|
||||
let len = (header >> 16) as usize;
|
||||
messages.push(
|
||||
bytes[offset..offset + len]
|
||||
.chunks_exact(4)
|
||||
.map(|word| u32::from_ne_bytes(word.try_into().unwrap()))
|
||||
.collect(),
|
||||
);
|
||||
offset += len;
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
struct Client {
|
||||
client: smithay::reexports::wayland_server::Client,
|
||||
wire: UnixStream,
|
||||
compositor: WlCompositor,
|
||||
shell: ZwlrLayerShellV1,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn new(state: &mut Ferese) -> Self {
|
||||
let (server, mut wire) = UnixStream::pair().unwrap();
|
||||
let client = state
|
||||
.display_handle
|
||||
.insert_client(server, Arc::new(ClientState::default()))
|
||||
.unwrap();
|
||||
let compositor = client
|
||||
.create_resource::<WlCompositor, smithay::wayland::GlobalData, Ferese>(
|
||||
&state.display_handle,
|
||||
6,
|
||||
smithay::wayland::GlobalData,
|
||||
)
|
||||
.unwrap();
|
||||
let shell = client
|
||||
.create_resource::<ZwlrLayerShellV1, smithay::wayland::GlobalData, Ferese>(
|
||||
&state.display_handle,
|
||||
4,
|
||||
smithay::wayland::GlobalData,
|
||||
)
|
||||
.unwrap();
|
||||
request(&mut wire, 1, 1, &[2], None); // Registry.
|
||||
request(&mut wire, compositor.id().protocol_id(), 0, &[3], None);
|
||||
// Keep client IDs contiguous: 5 is the optional wl_output, 6 the role.
|
||||
request(&mut wire, compositor.id().protocol_id(), 0, &[4], None);
|
||||
Self {
|
||||
client,
|
||||
wire,
|
||||
compositor,
|
||||
shell,
|
||||
}
|
||||
}
|
||||
|
||||
fn bind_output(
|
||||
&mut self,
|
||||
state: &mut Ferese,
|
||||
events: &mut EventLoop<'static, Ferese>,
|
||||
output: &Output,
|
||||
) -> smithay::reexports::wayland_server::backend::GlobalId {
|
||||
let global = output.create_global::<Ferese>(&state.display_handle);
|
||||
dispatch(events, state);
|
||||
let names = read(&mut self.wire)
|
||||
.into_iter()
|
||||
.filter(|message| {
|
||||
message[0] == 2
|
||||
&& message[1] & 0xffff == 0
|
||||
&& message[3] == 10
|
||||
&& message.get(4) == Some(&u32::from_ne_bytes(*b"wl_o"))
|
||||
})
|
||||
.map(|message| message[2])
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(names.len(), 1);
|
||||
request(
|
||||
&mut self.wire,
|
||||
2,
|
||||
0,
|
||||
&[
|
||||
names[0],
|
||||
10,
|
||||
u32::from_ne_bytes(*b"wl_o"),
|
||||
u32::from_ne_bytes(*b"utpu"),
|
||||
u32::from_ne_bytes([b't', 0, 0, 0]),
|
||||
4,
|
||||
5,
|
||||
],
|
||||
None,
|
||||
);
|
||||
dispatch(events, state);
|
||||
read(&mut self.wire);
|
||||
global
|
||||
}
|
||||
|
||||
fn create_layer(
|
||||
&mut self,
|
||||
state: &mut Ferese,
|
||||
events: &mut EventLoop<'static, Ferese>,
|
||||
output: u32,
|
||||
) -> Vec<Vec<u32>> {
|
||||
if output == 0 {
|
||||
// Reserve the output slot with a spare surface for unspecified requests.
|
||||
request(
|
||||
&mut self.wire,
|
||||
self.compositor.id().protocol_id(),
|
||||
0,
|
||||
&[5],
|
||||
None,
|
||||
);
|
||||
}
|
||||
request(
|
||||
&mut self.wire,
|
||||
self.shell.id().protocol_id(),
|
||||
0,
|
||||
&[6, 3, output, 2, 4, u32::from_ne_bytes(*b"bar\0")],
|
||||
None,
|
||||
);
|
||||
request(&mut self.wire, 6, 0, &[64, 48], None);
|
||||
request(&mut self.wire, 3, 6, &[], None);
|
||||
dispatch(events, state);
|
||||
read(&mut self.wire)
|
||||
}
|
||||
|
||||
fn mapped_on(&self, output: &Output) -> bool {
|
||||
layer_map_for_output(output).layers().any(|layer| {
|
||||
layer
|
||||
.wl_surface()
|
||||
.client()
|
||||
.is_some_and(|client| client.id() == self.client.id())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_closed_without_configure(messages: &[Vec<u32>]) {
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|message| message[0] == 6 && message[1] & 0xffff == 1),
|
||||
"layer must receive closed"
|
||||
);
|
||||
assert!(
|
||||
!messages
|
||||
.iter()
|
||||
.any(|message| message[0] == 6 && message[1] & 0xffff == 0),
|
||||
"closed layers must not wait for configure"
|
||||
);
|
||||
assert!(
|
||||
!messages
|
||||
.iter()
|
||||
.any(|message| message[0] == 1 && message[1] & 0xffff == 0),
|
||||
"no client protocol error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_outputs_close_new_layer_roles() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"handlers::layer_shell::output_tests::zero_outputs_close_new_layer_roles",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let (mut state, mut events) = fixture();
|
||||
let mut client = Client::new(&mut state);
|
||||
assert_closed_without_configure(&client.create_layer(&mut state, &mut events, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_explicit_output_closes_instead_of_retargeting() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"handlers::layer_shell::output_tests::stale_explicit_output_closes_instead_of_retargeting",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let (mut state, mut events) = fixture();
|
||||
let stale = output(&mut state, "stale", 0);
|
||||
let survivor = output(&mut state, "survivor", 800);
|
||||
let mut client = Client::new(&mut state);
|
||||
let global = client.bind_output(&mut state, &mut events, &stale);
|
||||
state.unregister_output(&stale);
|
||||
state.display_handle.remove_global::<Ferese>(global);
|
||||
dispatch(&mut events, &mut state);
|
||||
read(&mut client.wire);
|
||||
let messages = client.create_layer(&mut state, &mut events, 5);
|
||||
assert_closed_without_configure(&messages);
|
||||
assert!(!client.mapped_on(&stale));
|
||||
assert!(
|
||||
!client.mapped_on(&survivor),
|
||||
"explicit requests must not silently move to another output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unspecified_output_uses_focused_registered_output() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"handlers::layer_shell::output_tests::unspecified_output_uses_focused_registered_output",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let (mut state, mut events) = fixture();
|
||||
let first = output(&mut state, "first", 0);
|
||||
let focused = output(&mut state, "focused", 800);
|
||||
state.focus_output_at((820., 20.).into());
|
||||
assert_eq!(state.focused_output(), Some(&focused));
|
||||
let mut client = Client::new(&mut state);
|
||||
let messages = client.create_layer(&mut state, &mut events, 0);
|
||||
assert!(client.mapped_on(&focused));
|
||||
assert!(!client.mapped_on(&first));
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|message| message[0] == 6 && message[1] & 0xffff == 0),
|
||||
"valid layer receives configure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_explicit_output_is_preserved() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"handlers::layer_shell::output_tests::valid_explicit_output_is_preserved",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let (mut state, mut events) = fixture();
|
||||
let explicit = output(&mut state, "explicit", 0);
|
||||
let focused = output(&mut state, "focused", 800);
|
||||
state.focus_output_at((820., 20.).into());
|
||||
let mut client = Client::new(&mut state);
|
||||
client.bind_output(&mut state, &mut events, &explicit);
|
||||
let messages = client.create_layer(&mut state, &mut events, 5);
|
||||
assert!(client.mapped_on(&explicit));
|
||||
assert!(!client.mapped_on(&focused));
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|message| message[0] == 6 && message[1] & 0xffff == 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_output_is_not_a_creation_fallback() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"handlers::layer_shell::output_tests::unregistered_output_is_not_a_creation_fallback",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let (mut state, mut events) = fixture();
|
||||
let ghost = Output::new(
|
||||
"ghost".into(),
|
||||
PhysicalProperties {
|
||||
serial_number: String::new(),
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
ghost.change_current_state(
|
||||
Some(Mode {
|
||||
size: (800, 600).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
state.space.map_output(&ghost, (0, 0));
|
||||
let mut client = Client::new(&mut state);
|
||||
assert_closed_without_configure(&client.create_layer(&mut state, &mut events, 0));
|
||||
assert!(!client.mapped_on(&ghost));
|
||||
}
|
||||
@@ -1,15 +1,24 @@
|
||||
mod activation;
|
||||
pub(crate) mod activation;
|
||||
mod compositor;
|
||||
mod dispatch;
|
||||
pub(crate) mod gamma_control;
|
||||
pub(crate) mod image_capture;
|
||||
mod input_method;
|
||||
mod layer_shell;
|
||||
pub(crate) mod screencopy;
|
||||
pub(crate) mod screenshot;
|
||||
pub(crate) mod screenshot_worker;
|
||||
pub(crate) mod security_context;
|
||||
#[cfg(test)]
|
||||
mod toplevel_icon_tests;
|
||||
mod virtual_keyboard;
|
||||
mod virtual_pointer;
|
||||
pub(crate) mod window_capture;
|
||||
mod xdg_shell;
|
||||
|
||||
use smithay::desktop::{PopupManager, layer_map_for_output};
|
||||
use smithay::input::pointer::{CursorImageStatus, PointerHandle};
|
||||
use smithay::input::tablet::TabletSeatHandler;
|
||||
use smithay::input::{Seat, SeatHandler, SeatState};
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
@@ -22,15 +31,16 @@ use smithay::wayland::keyboard_shortcuts_inhibit::{
|
||||
KeyboardShortcutsInhibitorSeat,
|
||||
};
|
||||
use smithay::wayland::output::OutputHandler;
|
||||
use smithay::wayland::pointer_constraints::{PointerConstraint, PointerConstraintsHandler, with_pointer_constraint};
|
||||
use smithay::wayland::pointer_constraints::{
|
||||
PointerConstraint, PointerConstraintsHandler, with_pointer_constraint,
|
||||
};
|
||||
use smithay::wayland::selection::SelectionHandler;
|
||||
use smithay::wayland::selection::data_device::{
|
||||
ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler, set_data_device_focus,
|
||||
DataDeviceHandler, DataDeviceState, WaylandDndGrabHandler, set_data_device_focus,
|
||||
};
|
||||
use smithay::wayland::selection::primary_selection::{
|
||||
PrimarySelectionHandler, PrimarySelectionState, set_primary_focus,
|
||||
};
|
||||
use smithay::wayland::tablet_manager::TabletSeatHandler;
|
||||
use smithay::wayland::xdg_foreign::{XdgForeignHandler, XdgForeignState};
|
||||
use smithay::wayland::xdg_toplevel_icon::XdgToplevelIconHandler;
|
||||
|
||||
@@ -46,12 +56,24 @@ impl SeatHandler for Ferese {
|
||||
}
|
||||
|
||||
fn cursor_image(&mut self, _seat: &Seat<Self>, image: CursorImageStatus) {
|
||||
if let CursorImageStatus::Named(icon) = &image {
|
||||
self.named_cursors
|
||||
.entry(*icon)
|
||||
.or_insert_with(|| crate::cursor::load_named_cursor(&self.cursor_theme, *icon));
|
||||
if let CursorImageStatus::Named(icon) = &image
|
||||
&& !self.named_cursors.contains_key(icon)
|
||||
{
|
||||
self.cursor_loader
|
||||
.request(*icon, self.cursor_animation.buffer_scale);
|
||||
}
|
||||
self.cursor_animation.reset |= match (&self.cursor_status, &image) {
|
||||
(CursorImageStatus::Named(previous), CursorImageStatus::Named(next)) => {
|
||||
previous != next
|
||||
}
|
||||
(CursorImageStatus::Hidden, CursorImageStatus::Hidden) => false,
|
||||
(CursorImageStatus::Surface(previous), CursorImageStatus::Surface(next)) => {
|
||||
previous != next
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
self.cursor_status = image;
|
||||
self.update_capture_cursor_privacy();
|
||||
// Pointer focus transitions invoke this with the pointer lock held.
|
||||
// render_all -> cursor_elements -> current_location would try to
|
||||
// acquire that same lock, freezing the entire compositor thread.
|
||||
@@ -59,14 +81,18 @@ impl SeatHandler for Ferese {
|
||||
}
|
||||
|
||||
fn focus_changed(&mut self, seat: &Seat<Self>, focused: Option<&WlSurface>) {
|
||||
if let Some(id) = focused.and_then(|surface| {
|
||||
self.windows.ids().iter().find_map(|(window, id)| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == surface)
|
||||
.then_some(*id)
|
||||
})
|
||||
}) {
|
||||
self.super_tap.cancel();
|
||||
self.pending_layer_focus = None;
|
||||
// Smithay has already sent enter. last_enter uses a separate mutex;
|
||||
// querying current_focus here would reenter the held keyboard lock.
|
||||
if let Some(serial) = seat
|
||||
.get_keyboard()
|
||||
.and_then(|keyboard| keyboard.last_enter())
|
||||
{
|
||||
self.record_activation_input(serial, focused.cloned());
|
||||
}
|
||||
|
||||
if let Some(id) = focused.and_then(|surface| self.windows.id_for_surface(surface)) {
|
||||
self.focus_history.record(id);
|
||||
}
|
||||
if let Some(inhibitor) = self.active_shortcuts_inhibitor.take()
|
||||
@@ -74,7 +100,9 @@ impl SeatHandler for Ferese {
|
||||
{
|
||||
inhibitor.inactivate();
|
||||
}
|
||||
if let Some(inhibitor) = focused.and_then(|surface| seat.keyboard_shortcuts_inhibitor_for_surface(surface)) {
|
||||
if let Some(inhibitor) =
|
||||
focused.and_then(|surface| seat.keyboard_shortcuts_inhibitor_for_surface(surface))
|
||||
{
|
||||
inhibitor.activate();
|
||||
self.active_shortcuts_inhibitor = Some(inhibitor);
|
||||
}
|
||||
@@ -86,6 +114,7 @@ impl SeatHandler for Ferese {
|
||||
}
|
||||
|
||||
impl OutputHandler for Ferese {}
|
||||
impl smithay::wayland::text_input::TextInputActivation for Ferese {}
|
||||
|
||||
impl FractionalScaleHandler for Ferese {
|
||||
fn new_fractional_scale(&mut self, surface: WlSurface) {
|
||||
@@ -103,7 +132,7 @@ impl IdleInhibitHandler for Ferese {
|
||||
fn inhibit(&mut self, surface: WlSurface) {
|
||||
let count = self.idle_inhibitors.entry(surface).or_default();
|
||||
*count = count.saturating_add(1);
|
||||
self.idle_notifier_state.set_is_inhibited(true);
|
||||
self.refresh_idle_inhibition();
|
||||
}
|
||||
|
||||
fn uninhibit(&mut self, surface: WlSurface) {
|
||||
@@ -123,9 +152,13 @@ impl KeyboardShortcutsInhibitHandler for Ferese {
|
||||
}
|
||||
|
||||
fn new_inhibitor(&mut self, inhibitor: KeyboardShortcutsInhibitor) {
|
||||
let focused = self.seat.get_keyboard().and_then(|keyboard| keyboard.current_focus());
|
||||
let focused = self
|
||||
.seat
|
||||
.get_keyboard()
|
||||
.and_then(|keyboard| keyboard.current_focus());
|
||||
|
||||
if focused.as_ref() == Some(inhibitor.wl_surface()) {
|
||||
self.super_tap.cancel();
|
||||
inhibitor.activate();
|
||||
self.active_shortcuts_inhibitor = Some(inhibitor);
|
||||
}
|
||||
@@ -139,6 +172,38 @@ impl KeyboardShortcutsInhibitHandler for Ferese {
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
pub(crate) fn surface_outputs(&self, surface: &WlSurface) -> Vec<smithay::output::Output> {
|
||||
use smithay::desktop::{WindowSurfaceType, find_popup_root_surface};
|
||||
use smithay::wayland::compositor::get_parent;
|
||||
let mut root = surface.clone();
|
||||
while let Some(parent) = get_parent(&root) {
|
||||
root = parent;
|
||||
}
|
||||
|
||||
if let Some(popup) = self.popups.find_popup(&root)
|
||||
&& let Ok(parent) = find_popup_root_surface(&popup)
|
||||
{
|
||||
root = parent;
|
||||
}
|
||||
|
||||
let window = self.windows.id_for_surface(&root);
|
||||
self.space
|
||||
.outputs()
|
||||
.filter(|output| {
|
||||
window.is_some_and(|id| self.window_belongs_to_output(id, output))
|
||||
|| layer_map_for_output(output)
|
||||
.layer_for_surface(&root, WindowSurfaceType::ALL)
|
||||
.is_some()
|
||||
|| self
|
||||
.session_lock
|
||||
.surfaces
|
||||
.get(output)
|
||||
.is_some_and(|lock| lock.wl_surface() == &root)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn update_surface_preferences(&self, surface: &WlSurface) {
|
||||
use smithay::desktop::{WindowSurfaceType, find_popup_root_surface};
|
||||
use smithay::wayland::compositor::get_parent;
|
||||
@@ -152,12 +217,7 @@ impl Ferese {
|
||||
{
|
||||
root = parent;
|
||||
}
|
||||
let window = self.windows.ids().iter().find_map(|(window, id)| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == &root)
|
||||
.then_some(*id)
|
||||
});
|
||||
let window = self.windows.id_for_surface(&root);
|
||||
let output = self.space.outputs().find(|output| {
|
||||
window.is_some_and(|id| self.window_belongs_to_output(id, output))
|
||||
|| layer_map_for_output(output)
|
||||
@@ -176,7 +236,9 @@ impl Ferese {
|
||||
let output = output
|
||||
.or_else(|| self.focused_output())
|
||||
.or_else(|| self.space.outputs().next());
|
||||
with_states(surface, |states| set_surface_preferences(surface, states, output));
|
||||
with_states(surface, |states| {
|
||||
set_surface_preferences(surface, states, output)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,9 +248,18 @@ fn set_surface_preferences(
|
||||
output: Option<&smithay::output::Output>,
|
||||
) {
|
||||
let scale = output.map_or(1.0, |output| output.current_scale().fractional_scale());
|
||||
let transform = output.map_or(smithay::utils::Transform::Normal, |output| output.current_transform());
|
||||
smithay::wayland::compositor::send_surface_state(surface, states, scale.ceil() as i32, transform);
|
||||
with_fractional_scale(states, |surface_scale| surface_scale.set_preferred_scale(scale));
|
||||
let transform = output.map_or(smithay::utils::Transform::Normal, |output| {
|
||||
output.current_transform()
|
||||
});
|
||||
smithay::wayland::compositor::send_surface_state(
|
||||
surface,
|
||||
states,
|
||||
scale.ceil() as i32,
|
||||
transform,
|
||||
);
|
||||
with_fractional_scale(states, |surface_scale| {
|
||||
surface_scale.set_preferred_scale(scale)
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn set_surface_tree_output(surface: &WlSurface, output: &smithay::output::Output) {
|
||||
@@ -245,31 +316,70 @@ impl SelectionHandler for Ferese {
|
||||
type SelectionUserData = ();
|
||||
}
|
||||
|
||||
impl ClientDndGrabHandler for Ferese {}
|
||||
impl ServerDndGrabHandler for Ferese {}
|
||||
impl TabletSeatHandler for Ferese {}
|
||||
impl smithay::input::dnd::DndGrabHandler for Ferese {}
|
||||
|
||||
impl WaylandDndGrabHandler for Ferese {
|
||||
fn dnd_requested<S: smithay::input::dnd::Source>(
|
||||
&mut self,
|
||||
source: S,
|
||||
_icon: Option<WlSurface>,
|
||||
seat: Seat<Self>,
|
||||
serial: smithay::utils::Serial,
|
||||
type_: smithay::input::dnd::GrabType,
|
||||
) {
|
||||
use smithay::input::dnd::{DnDGrab, GrabType};
|
||||
match type_ {
|
||||
GrabType::Pointer => {
|
||||
let pointer = seat.get_pointer().expect("validated pointer drag");
|
||||
let start = pointer.grab_start_data().expect("validated pointer grab");
|
||||
pointer.set_grab(
|
||||
self,
|
||||
DnDGrab::new_pointer(&self.display_handle, start, source, seat),
|
||||
serial,
|
||||
smithay::input::pointer::Focus::Keep,
|
||||
);
|
||||
}
|
||||
GrabType::Touch => {
|
||||
let touch = seat.get_touch().expect("validated touch drag");
|
||||
let start = touch.grab_start_data().expect("validated touch grab");
|
||||
touch.set_grab(
|
||||
self,
|
||||
DnDGrab::new_touch(&self.display_handle, start, source, seat),
|
||||
serial,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl TabletSeatHandler for Ferese {
|
||||
type ToolFocus = WlSurface;
|
||||
}
|
||||
|
||||
impl DataDeviceHandler for Ferese {
|
||||
fn data_device_state(&self) -> &DataDeviceState {
|
||||
&self.data_device_state
|
||||
fn data_device_state(&mut self) -> &mut DataDeviceState {
|
||||
&mut self.data_device_state
|
||||
}
|
||||
}
|
||||
|
||||
impl PrimarySelectionHandler for Ferese {
|
||||
fn primary_selection_state(&self) -> &PrimarySelectionState {
|
||||
&self.primary_selection_state
|
||||
fn primary_selection_state(&mut self) -> &mut PrimarySelectionState {
|
||||
&mut self.primary_selection_state
|
||||
}
|
||||
}
|
||||
|
||||
impl smithay::wayland::selection::wlr_data_control::DataControlHandler for Ferese {
|
||||
fn data_control_state(&self) -> &smithay::wayland::selection::wlr_data_control::DataControlState {
|
||||
&self.wlr_data_control_state
|
||||
fn data_control_state(
|
||||
&mut self,
|
||||
) -> &mut smithay::wayland::selection::wlr_data_control::DataControlState {
|
||||
&mut self.wlr_data_control_state
|
||||
}
|
||||
}
|
||||
|
||||
impl smithay::wayland::selection::ext_data_control::DataControlHandler for Ferese {
|
||||
fn data_control_state(&self) -> &smithay::wayland::selection::ext_data_control::DataControlState {
|
||||
&self.ext_data_control_state
|
||||
fn data_control_state(
|
||||
&mut self,
|
||||
) -> &mut smithay::wayland::selection::ext_data_control::DataControlState {
|
||||
&mut self.ext_data_control_state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,32 +391,4 @@ impl XdgForeignHandler for Ferese {
|
||||
|
||||
impl XdgToplevelIconHandler for Ferese {}
|
||||
|
||||
smithay::delegate_compositor!(Ferese);
|
||||
smithay::delegate_alpha_modifier!(Ferese);
|
||||
smithay::delegate_cursor_shape!(Ferese);
|
||||
smithay::delegate_data_device!(Ferese);
|
||||
smithay::delegate_dmabuf!(Ferese);
|
||||
smithay::delegate_fractional_scale!(Ferese);
|
||||
smithay::delegate_idle_inhibit!(Ferese);
|
||||
smithay::delegate_idle_notify!(Ferese);
|
||||
smithay::delegate_input_method_manager!(Ferese);
|
||||
smithay::delegate_keyboard_shortcuts_inhibit!(Ferese);
|
||||
smithay::delegate_layer_shell!(Ferese);
|
||||
smithay::delegate_output!(Ferese);
|
||||
smithay::delegate_pointer_constraints!(Ferese);
|
||||
smithay::delegate_presentation!(Ferese);
|
||||
smithay::delegate_primary_selection!(Ferese);
|
||||
smithay::delegate_relative_pointer!(Ferese);
|
||||
smithay::delegate_seat!(Ferese);
|
||||
smithay::delegate_shm!(Ferese);
|
||||
smithay::delegate_single_pixel_buffer!(Ferese);
|
||||
smithay::delegate_text_input_manager!(Ferese);
|
||||
smithay::delegate_viewporter!(Ferese);
|
||||
smithay::delegate_xdg_activation!(Ferese);
|
||||
smithay::delegate_xdg_decoration!(Ferese);
|
||||
smithay::delegate_xdg_foreign!(Ferese);
|
||||
smithay::delegate_xdg_shell!(Ferese);
|
||||
smithay::delegate_xdg_toplevel_icon!(Ferese);
|
||||
|
||||
smithay::delegate_data_control!(Ferese);
|
||||
smithay::delegate_ext_data_control!(Ferese);
|
||||
pub(crate) mod workspace_capture;
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -11,7 +12,9 @@ use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::{
|
||||
};
|
||||
use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer;
|
||||
use smithay::reexports::wayland_server::protocol::wl_shm;
|
||||
use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource};
|
||||
use smithay::reexports::wayland_server::{
|
||||
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
|
||||
};
|
||||
use smithay::utils::{Buffer, Logical, Rectangle, Size};
|
||||
use smithay::wayland::shm::with_buffer_contents_mut;
|
||||
|
||||
@@ -20,6 +23,8 @@ use crate::Ferese;
|
||||
|
||||
const BYTES_PER_PIXEL: usize = 4;
|
||||
|
||||
type DamageHistory = Arc<Mutex<HashMap<(Output, bool), u64>>>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct FrameData {
|
||||
pub(super) output: Option<Output>,
|
||||
@@ -27,6 +32,7 @@ pub(crate) struct FrameData {
|
||||
pub(super) overlay_cursor: bool,
|
||||
pub(super) used: Mutex<bool>,
|
||||
pub(super) snapshot: Mutex<Option<super::window_capture::Snapshot>>,
|
||||
pub(super) history: Option<DamageHistory>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -36,6 +42,7 @@ pub(crate) struct PendingScreencopy {
|
||||
region: Rectangle<i32, Buffer>,
|
||||
overlay_cursor: bool,
|
||||
with_damage: bool,
|
||||
history: Option<DamageHistory>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -49,6 +56,7 @@ pub(crate) enum CaptureSink {
|
||||
part: usize,
|
||||
complete: PartSender,
|
||||
published: AtomicBool,
|
||||
permit: Option<super::screenshot::BudgetPermit>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -59,6 +67,7 @@ impl CaptureSink {
|
||||
part,
|
||||
complete,
|
||||
published: AtomicBool::new(false),
|
||||
permit: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,12 +82,14 @@ impl CaptureSink {
|
||||
part,
|
||||
complete,
|
||||
published,
|
||||
permit,
|
||||
} => {
|
||||
if !published.swap(true, Ordering::AcqRel) {
|
||||
let _ = complete.send(PartOutcome {
|
||||
request: *request,
|
||||
part: *part,
|
||||
result: Err("Screenshot part failed".to_string()),
|
||||
permit: permit.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -93,6 +104,7 @@ impl CaptureSink {
|
||||
part,
|
||||
complete,
|
||||
published,
|
||||
permit,
|
||||
} = self
|
||||
else {
|
||||
return false;
|
||||
@@ -105,12 +117,20 @@ impl CaptureSink {
|
||||
request: *request,
|
||||
part: *part,
|
||||
result: Ok(buffer),
|
||||
permit: permit.clone(),
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl PendingScreencopy {
|
||||
pub(crate) fn with_permit(mut self, permit: Option<super::screenshot::BudgetPermit>) -> Self {
|
||||
if let CaptureSink::Owned { permit: stored, .. } = &mut self.sink {
|
||||
*stored = permit;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn owned(
|
||||
request: u64,
|
||||
part: usize,
|
||||
@@ -125,6 +145,7 @@ impl PendingScreencopy {
|
||||
// The compositor-owned path deliberately omits the cursor.
|
||||
overlay_cursor: false,
|
||||
with_damage: false,
|
||||
history: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,17 +165,21 @@ impl PendingScreencopy {
|
||||
|
||||
pub(crate) fn capture_allowed() -> bool {
|
||||
static ALLOWED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ALLOWED.get_or_init(|| capture_opted_in(std::env::var_os("FERESE_ENABLE_SCREENCOPY").as_deref()))
|
||||
*ALLOWED
|
||||
.get_or_init(|| capture_opted_in(std::env::var_os("FERESE_ENABLE_SCREENCOPY").as_deref()))
|
||||
}
|
||||
|
||||
fn capture_opted_in(value: Option<&std::ffi::OsStr>) -> bool {
|
||||
value.is_some_and(|value| value == "1")
|
||||
}
|
||||
|
||||
pub(crate) fn init_global(display: &DisplayHandle) {
|
||||
pub(crate) fn init_global(
|
||||
display: &DisplayHandle,
|
||||
loop_handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>,
|
||||
) {
|
||||
if capture_allowed() {
|
||||
display.create_global::<Ferese, ZwlrScreencopyManagerV1, ()>(3, ());
|
||||
super::window_capture::init_global(display);
|
||||
super::window_capture::init_global(display, loop_handle);
|
||||
tracing::info!("authorized screencopy is enabled for this session");
|
||||
}
|
||||
}
|
||||
@@ -168,17 +193,17 @@ impl GlobalDispatch<ZwlrScreencopyManagerV1, ()> for Ferese {
|
||||
_global_data: &(),
|
||||
data_init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
data_init.init(resource, ());
|
||||
data_init.init(resource, DamageHistory::default());
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZwlrScreencopyManagerV1, ()> for Ferese {
|
||||
impl Dispatch<ZwlrScreencopyManagerV1, DamageHistory> for Ferese {
|
||||
fn request(
|
||||
_state: &mut Self,
|
||||
_client: &Client,
|
||||
_manager: &ZwlrScreencopyManagerV1,
|
||||
request: zwlr_screencopy_manager_v1::Request,
|
||||
_data: &(),
|
||||
history: &DamageHistory,
|
||||
_display: &DisplayHandle,
|
||||
data_init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
@@ -188,7 +213,14 @@ impl Dispatch<ZwlrScreencopyManagerV1, ()> for Ferese {
|
||||
overlay_cursor,
|
||||
output,
|
||||
} => {
|
||||
create_frame(data_init, frame, output, None, overlay_cursor != 0);
|
||||
create_frame(
|
||||
data_init,
|
||||
frame,
|
||||
output,
|
||||
None,
|
||||
overlay_cursor != 0,
|
||||
history.clone(),
|
||||
);
|
||||
}
|
||||
zwlr_screencopy_manager_v1::Request::CaptureOutputRegion {
|
||||
frame,
|
||||
@@ -205,6 +237,7 @@ impl Dispatch<ZwlrScreencopyManagerV1, ()> for Ferese {
|
||||
output,
|
||||
Some(Rectangle::new((x, y).into(), (width, height).into())),
|
||||
overlay_cursor != 0,
|
||||
history.clone(),
|
||||
);
|
||||
}
|
||||
zwlr_screencopy_manager_v1::Request::Destroy => {}
|
||||
@@ -231,7 +264,10 @@ impl Dispatch<ZwlrScreencopyFrameV1, Arc<FrameData>> for Ferese {
|
||||
state.queue_screencopy(frame, data, buffer, true);
|
||||
}
|
||||
zwlr_screencopy_frame_v1::Request::Destroy => {
|
||||
data.snapshot.lock().unwrap().take();
|
||||
data.snapshot
|
||||
.lock()
|
||||
.expect("surface rendering state must remain consistent across callbacks")
|
||||
.take();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
@@ -246,12 +282,20 @@ impl Ferese {
|
||||
buffer: WlBuffer,
|
||||
with_damage: bool,
|
||||
) {
|
||||
if data.snapshot.lock().unwrap().is_some() {
|
||||
if data
|
||||
.snapshot
|
||||
.lock()
|
||||
.expect("surface rendering state must remain consistent across callbacks")
|
||||
.is_some()
|
||||
{
|
||||
super::window_capture::copy_snapshot(self, frame, data, &buffer);
|
||||
return;
|
||||
}
|
||||
let mut used = data.used.lock().unwrap();
|
||||
if self.session_lock.active {
|
||||
let mut used = data
|
||||
.used
|
||||
.lock()
|
||||
.expect("surface rendering state must remain consistent across callbacks");
|
||||
if self.session_lock.active() {
|
||||
frame.failed();
|
||||
return;
|
||||
}
|
||||
@@ -277,17 +321,39 @@ impl Ferese {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(history) = &data.history {
|
||||
history
|
||||
.lock()
|
||||
.expect("capture damage history mutex poisoned")
|
||||
.retain(|(output, _), _| self.output_ids.contains_key(output));
|
||||
}
|
||||
self.pending_screencopies.push(PendingScreencopy {
|
||||
sink: CaptureSink::Shm {
|
||||
frame: frame.clone(),
|
||||
buffer,
|
||||
},
|
||||
output,
|
||||
output: output.clone(),
|
||||
region: data.region,
|
||||
overlay_cursor: data.overlay_cursor,
|
||||
with_damage,
|
||||
history: data.history.clone(),
|
||||
});
|
||||
crate::backends::direct::schedule_capture(self, &[output]);
|
||||
}
|
||||
|
||||
pub(crate) fn prune_screencopies(&mut self) {
|
||||
self.pending_screencopies.retain(|capture| {
|
||||
if let CaptureSink::Shm { frame, buffer } = &capture.sink {
|
||||
if !frame.is_alive() {
|
||||
return false;
|
||||
}
|
||||
if !buffer.is_alive() {
|
||||
frame.failed();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
crate::backends::direct::render_all(self);
|
||||
}
|
||||
|
||||
pub(crate) fn process_screencopies<R>(
|
||||
@@ -300,12 +366,25 @@ impl Ferese {
|
||||
where
|
||||
R: ExportMem,
|
||||
{
|
||||
self.process_screencopies_with_revision(renderer, framebuffer, output, overlay_cursor, None)
|
||||
}
|
||||
|
||||
pub(crate) fn process_screencopies_with_revision<R: ExportMem>(
|
||||
&mut self,
|
||||
renderer: &mut R,
|
||||
framebuffer: &R::Framebuffer<'_>,
|
||||
output: &Output,
|
||||
overlay_cursor: bool,
|
||||
revision: Option<u64>,
|
||||
) -> bool {
|
||||
let mut remaining = Vec::new();
|
||||
let mut framebuffer_binding_changed = false;
|
||||
let captures = std::mem::take(&mut self.pending_screencopies);
|
||||
|
||||
for capture in captures {
|
||||
if &capture.output != output || !cursor_overlay_matches(capture.overlay_cursor, overlay_cursor) {
|
||||
if &capture.output != output
|
||||
|| !cursor_overlay_matches(capture.overlay_cursor, overlay_cursor)
|
||||
{
|
||||
remaining.push(capture);
|
||||
continue;
|
||||
}
|
||||
@@ -315,9 +394,33 @@ impl Ferese {
|
||||
continue;
|
||||
}
|
||||
|
||||
let key = (output.clone(), overlay_cursor);
|
||||
if capture.history.is_some() && revision.is_none() {
|
||||
remaining.push(capture);
|
||||
continue;
|
||||
}
|
||||
if capture.with_damage {
|
||||
let previous = capture.history.as_ref().and_then(|history| {
|
||||
history
|
||||
.lock()
|
||||
.expect("capture damage history mutex poisoned")
|
||||
.get(&key)
|
||||
.copied()
|
||||
});
|
||||
if revision.is_none() || previous == revision {
|
||||
remaining.push(capture);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
framebuffer_binding_changed = true;
|
||||
if complete_capture(renderer, framebuffer, &capture, self.start_time.elapsed()).is_err() {
|
||||
if complete_capture(renderer, framebuffer, &capture, self.start_time.elapsed()).is_err()
|
||||
{
|
||||
capture.fail();
|
||||
} else if let (Some(history), Some(revision)) = (&capture.history, revision) {
|
||||
history
|
||||
.lock()
|
||||
.expect("capture damage history mutex poisoned")
|
||||
.insert(key, revision);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,9 +429,26 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub(crate) fn has_pending_screencopy(&self, output: &Output, overlay_cursor: bool) -> bool {
|
||||
self.pending_screencopies
|
||||
.iter()
|
||||
.any(|capture| capture.output == *output && cursor_overlay_matches(capture.overlay_cursor, overlay_cursor))
|
||||
self.image_capture.pending_output(output, overlay_cursor)
|
||||
|| self.pending_screencopies.iter().any(|capture| {
|
||||
capture.output == *output
|
||||
&& cursor_overlay_matches(capture.overlay_cursor, overlay_cursor)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn fail_screencopies(&mut self, output: &Output, overlay_cursor: bool) {
|
||||
let mut remaining = Vec::new();
|
||||
for capture in std::mem::take(&mut self.pending_screencopies) {
|
||||
if capture.output == *output
|
||||
&& cursor_overlay_matches(capture.overlay_cursor, overlay_cursor)
|
||||
{
|
||||
capture.sink.fail();
|
||||
} else {
|
||||
remaining.push(capture);
|
||||
}
|
||||
}
|
||||
|
||||
self.pending_screencopies = remaining;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,6 +462,7 @@ fn create_frame(
|
||||
wl_output: smithay::reexports::wayland_server::protocol::wl_output::WlOutput,
|
||||
requested: Option<Rectangle<i32, Logical>>,
|
||||
overlay_cursor: bool,
|
||||
history: DamageHistory,
|
||||
) {
|
||||
let Some(output) = Output::from_resource(&wl_output) else {
|
||||
let frame = data_init.init(
|
||||
@@ -352,6 +473,7 @@ fn create_frame(
|
||||
overlay_cursor,
|
||||
used: Mutex::new(true),
|
||||
snapshot: Mutex::new(None),
|
||||
history: Some(history.clone()),
|
||||
}),
|
||||
);
|
||||
frame.failed();
|
||||
@@ -366,6 +488,7 @@ fn create_frame(
|
||||
overlay_cursor,
|
||||
used: Mutex::new(true),
|
||||
snapshot: Mutex::new(None),
|
||||
history: Some(history.clone()),
|
||||
}),
|
||||
);
|
||||
frame.failed();
|
||||
@@ -379,6 +502,7 @@ fn create_frame(
|
||||
overlay_cursor,
|
||||
used: Mutex::new(false),
|
||||
snapshot: Mutex::new(None),
|
||||
history: Some(history.clone()),
|
||||
});
|
||||
let frame = data_init.init(frame, data);
|
||||
|
||||
@@ -393,7 +517,10 @@ fn create_frame(
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_region(output: &Output, requested: Option<Rectangle<i32, Logical>>) -> Option<Rectangle<i32, Buffer>> {
|
||||
fn capture_region(
|
||||
output: &Output,
|
||||
requested: Option<Rectangle<i32, Logical>>,
|
||||
) -> Option<Rectangle<i32, Buffer>> {
|
||||
let mode = output.current_mode()?;
|
||||
let scale = output.current_scale().fractional_scale();
|
||||
let transform = output.current_transform();
|
||||
@@ -418,20 +545,30 @@ pub(crate) fn capture_region_for_geometry(
|
||||
transform: smithay::utils::Transform,
|
||||
requested: Option<Rectangle<i32, Logical>>,
|
||||
) -> Option<Rectangle<i32, Buffer>> {
|
||||
let buffer_bounds = Rectangle::from_size(Size::<i32, Buffer>::from((mode_size.w, mode_size.h)));
|
||||
if mode_size.w <= 0 || mode_size.h <= 0 {
|
||||
return None;
|
||||
}
|
||||
// Full-output captures must match the framebuffer exactly. Rounding the
|
||||
// logical size and scaling it back can add a pixel at fractional scales.
|
||||
if requested.is_none() {
|
||||
return Some(buffer_bounds);
|
||||
}
|
||||
let logical_size = output_logical_size(mode_size, scale, transform);
|
||||
let output_region = Rectangle::from_size(logical_size);
|
||||
let logical_region = requested.unwrap_or(output_region).intersection(output_region)?;
|
||||
let logical_region = requested
|
||||
.unwrap_or(output_region)
|
||||
.intersection(output_region)?;
|
||||
|
||||
if logical_region.size.w <= 0 || logical_region.size.h <= 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
logical_region
|
||||
.to_f64()
|
||||
.to_buffer(scale, transform, &logical_size.to_f64())
|
||||
.to_i32_round(),
|
||||
)
|
||||
logical_region
|
||||
.to_f64()
|
||||
.to_buffer(scale, transform, &logical_size.to_f64())
|
||||
.to_i32_round()
|
||||
.intersection(buffer_bounds)
|
||||
}
|
||||
|
||||
pub(super) fn valid_shm_buffer(buffer: &WlBuffer, expected: Size<i32, Buffer>) -> bool {
|
||||
@@ -472,7 +609,9 @@ where
|
||||
tracing::warn!(?error, "failed to map output framebuffer copy");
|
||||
})?;
|
||||
let expected = capture.region.size;
|
||||
let stride = (expected.w as usize).checked_mul(BYTES_PER_PIXEL).ok_or(())?;
|
||||
let stride = (expected.w as usize)
|
||||
.checked_mul(BYTES_PER_PIXEL)
|
||||
.ok_or(())?;
|
||||
let bytes = stride.checked_mul(expected.h as usize).ok_or(())?;
|
||||
if source.len() < bytes {
|
||||
return Err(());
|
||||
@@ -530,7 +669,11 @@ where
|
||||
frame.flags(zwlr_screencopy_frame_v1::Flags::empty());
|
||||
|
||||
let seconds = timestamp.as_secs();
|
||||
frame.ready((seconds >> 32) as u32, seconds as u32, timestamp.subsec_nanos());
|
||||
frame.ready(
|
||||
(seconds >> 32) as u32,
|
||||
seconds as u32,
|
||||
timestamp.subsec_nanos(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -559,7 +702,8 @@ mod tests {
|
||||
let mode = Size::<i32, Physical>::from((1_920, 1_080));
|
||||
let requested = Rectangle::new((-10, 20).into(), (110, 50).into());
|
||||
|
||||
let region = capture_region_for_geometry(mode, 2.0, Transform::Normal, Some(requested)).unwrap();
|
||||
let region =
|
||||
capture_region_for_geometry(mode, 2.0, Transform::Normal, Some(requested)).unwrap();
|
||||
|
||||
assert_eq!(region, Rectangle::new((0, 40).into(), (200, 100).into()));
|
||||
}
|
||||
@@ -569,7 +713,9 @@ mod tests {
|
||||
let mode = Size::<i32, Physical>::from((1_920, 1_080));
|
||||
let requested = Rectangle::new((1_000, 600).into(), (100, 100).into());
|
||||
|
||||
assert!(capture_region_for_geometry(mode, 2.0, Transform::Normal, Some(requested),).is_none());
|
||||
assert!(
|
||||
capture_region_for_geometry(mode, 2.0, Transform::Normal, Some(requested),).is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,7 @@ const FILE_MODE: u32 = 0o600;
|
||||
pub(crate) struct Job {
|
||||
pub(crate) request: u64,
|
||||
pub(crate) frames: Vec<OutputFrame>,
|
||||
pub(crate) _permit: Option<super::screenshot::BudgetPermit>,
|
||||
}
|
||||
|
||||
pub(crate) struct Encoded {
|
||||
@@ -53,18 +54,13 @@ impl Worker {
|
||||
|
||||
fn run(receiver: Receiver<Job>, results: channel::SyncSender<Encoded>) {
|
||||
while let Ok(job) = receiver.recv() {
|
||||
let result = encode(&job);
|
||||
let request = job.request;
|
||||
let result = encode(job);
|
||||
// This send cannot block. Each request produces exactly one Encode, so at
|
||||
// most QUEUE_CAPACITY jobs can be queued while one more is being encoded,
|
||||
// and RESULT_QUEUE_CAPACITY is asserted to exceed that. Blocking here
|
||||
// would stall the worker with no way for the loop to recover it.
|
||||
if results
|
||||
.send(Encoded {
|
||||
request: job.request,
|
||||
result,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
if results.send(Encoded { request, result }).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -102,8 +98,10 @@ pub(crate) fn sweep_stale_files() {
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(job: &Job) -> Result<PathBuf, String> {
|
||||
fn encode(job: Job) -> Result<PathBuf, String> {
|
||||
let canvas = compose(&job.frames)?;
|
||||
drop(job);
|
||||
|
||||
let png = encode_png(&canvas)?;
|
||||
write_private_file(&png)
|
||||
}
|
||||
@@ -115,46 +113,80 @@ fn encode_png(canvas: &Canvas) -> Result<Vec<u8>, String> {
|
||||
if width == 0 || height == 0 {
|
||||
return Err("Screenshot is empty".into());
|
||||
}
|
||||
let image = image::RgbaImage::from_raw(width, height, canvas.pixels.clone())
|
||||
.ok_or("Screenshot buffer does not match its dimensions")?;
|
||||
let image =
|
||||
image::ImageBuffer::<image::Rgba<u8>, _>::from_raw(width, height, canvas.pixels.as_slice())
|
||||
.ok_or("Screenshot buffer does not match its dimensions")?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
let encoder = image::codecs::png::PngEncoder::new_with_quality(
|
||||
std::io::Cursor::new(&mut out),
|
||||
image::codecs::png::CompressionType::Fast,
|
||||
image::codecs::png::FilterType::Adaptive,
|
||||
);
|
||||
image::ImageEncoder::write_image(encoder, image.as_raw(), width, height, image::ExtendedColorType::Rgba8)
|
||||
.map_err(|error| format!("Could not encode screenshot: {error}"))?;
|
||||
image::ImageEncoder::write_image(
|
||||
encoder,
|
||||
image.as_raw(),
|
||||
width,
|
||||
height,
|
||||
image::ExtendedColorType::Rgba8,
|
||||
)
|
||||
.map_err(|error| format!("Could not encode screenshot: {error}"))?;
|
||||
tracing::debug!(encoded = out.len(), "encoded screenshot");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// The client is told to open this path and unlink it immediately, so the
|
||||
// directory is ours, private, and files are created with the final mode.
|
||||
struct StagedFile {
|
||||
path: PathBuf,
|
||||
keep: bool,
|
||||
}
|
||||
|
||||
impl Drop for StagedFile {
|
||||
fn drop(&mut self) {
|
||||
if !self.keep {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_private_file(png: &[u8]) -> Result<PathBuf, String> {
|
||||
let directory = private_directory()?;
|
||||
for attempt in 0..64u32 {
|
||||
let path = directory.join(format!("shot-{}-{attempt}.png", std::process::id()));
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true).mode(FILE_MODE);
|
||||
match options.open(&path) {
|
||||
Ok(mut file) => {
|
||||
use io::Write;
|
||||
file.write_all(png)
|
||||
.and_then(|()| file.sync_all())
|
||||
.map_err(|error| format!("Could not write screenshot: {error}"))?;
|
||||
return Ok(path);
|
||||
}
|
||||
write_private_file_in(&private_directory()?, png, |file, bytes| {
|
||||
use io::Write;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()
|
||||
})
|
||||
}
|
||||
|
||||
fn write_private_file_in(
|
||||
directory: &std::path::Path,
|
||||
png: &[u8],
|
||||
write: impl FnOnce(&mut fs::File, &[u8]) -> io::Result<()>,
|
||||
) -> Result<PathBuf, String> {
|
||||
static NEXT_FILE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let (mut file, mut staged) = loop {
|
||||
let sequence = NEXT_FILE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let path = directory.join(format!("shot-{}-{sequence}.png", std::process::id()));
|
||||
match fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(FILE_MODE)
|
||||
.open(&path)
|
||||
{
|
||||
Ok(file) => break (file, StagedFile { path, keep: false }),
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(format!("Could not write screenshot: {error}")),
|
||||
}
|
||||
}
|
||||
Err("Could not find an unused screenshot filename".into())
|
||||
};
|
||||
|
||||
write(&mut file, png).map_err(|error| format!("Could not write screenshot: {error}"))?;
|
||||
staged.keep = true;
|
||||
Ok(std::mem::take(&mut staged.path))
|
||||
}
|
||||
|
||||
fn private_directory() -> Result<PathBuf, String> {
|
||||
let runtime =
|
||||
std::env::var_os("XDG_RUNTIME_DIR").ok_or("XDG_RUNTIME_DIR is not set, so screenshots cannot be staged")?;
|
||||
let runtime = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.ok_or("XDG_RUNTIME_DIR is not set, so screenshots cannot be staged")?;
|
||||
let root = PathBuf::from(runtime);
|
||||
let directory = root.join("ferese-screenshots");
|
||||
match fs::symlink_metadata(&directory) {
|
||||
@@ -163,13 +195,16 @@ fn private_directory() -> Result<PathBuf, String> {
|
||||
}
|
||||
Ok(metadata) => {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// SAFETY: geteuid has no pointer arguments or caller-side preconditions.
|
||||
if !metadata.is_dir() || metadata.uid() != unsafe { libc::geteuid() } {
|
||||
return Err("Refusing to stage screenshots in an untrusted directory".into());
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
fs::create_dir(&directory)
|
||||
.and_then(|()| fs::set_permissions(&directory, fs::Permissions::from_mode(DIR_MODE)))
|
||||
.and_then(|()| {
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(DIR_MODE))
|
||||
})
|
||||
.map_err(|error| format!("Could not create screenshot directory: {error}"))?;
|
||||
}
|
||||
Err(error) => return Err(format!("Could not inspect screenshot directory: {error}")),
|
||||
@@ -209,8 +244,13 @@ mod tests {
|
||||
let job = Job {
|
||||
request: 1,
|
||||
frames: vec![frame(4, 3)],
|
||||
_permit: None,
|
||||
};
|
||||
let canvas = compose(&job.frames).unwrap();
|
||||
let mut canvas = compose(&job.frames).unwrap();
|
||||
for (index, pixel) in canvas.pixels.chunks_exact_mut(4).enumerate() {
|
||||
pixel[..3].copy_from_slice(&[index as u8, 137, 241]);
|
||||
}
|
||||
|
||||
let png = encode_png(&canvas).expect("png");
|
||||
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n", "png signature");
|
||||
|
||||
@@ -218,17 +258,45 @@ mod tests {
|
||||
.expect("decodes back")
|
||||
.to_rgba8();
|
||||
assert_eq!(decoded.dimensions(), (4, 3));
|
||||
assert_eq!(
|
||||
decoded.as_raw(),
|
||||
&canvas.pixels,
|
||||
"encoding changed the pixel data"
|
||||
);
|
||||
// The opaque alpha we force is preserved through the round trip.
|
||||
assert_eq!(decoded.get_pixel(0, 0)[3], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_has_no_fixed_filename_capacity_and_cleans_partial_writes() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
for _ in 0..100 {
|
||||
write_private_file_in(directory.path(), b"png", |file, bytes| {
|
||||
use io::Write;
|
||||
file.write_all(bytes)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 100);
|
||||
let result = write_private_file_in(directory.path(), b"partial", |file, bytes| {
|
||||
use io::Write;
|
||||
file.write_all(bytes)?;
|
||||
Err(io::Error::other("injected write/sync failure"))
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_files_are_owner_only() {
|
||||
let png = b"\x89PNG\r\n\x1a\nnot really a png".to_vec();
|
||||
let path = write_private_file(&png).expect("staged");
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, FILE_MODE, "staged screenshots are not group/world readable");
|
||||
assert_eq!(
|
||||
mode, FILE_MODE,
|
||||
"staged screenshots are not group/world readable"
|
||||
);
|
||||
fs::remove_file(&path).unwrap();
|
||||
}
|
||||
|
||||
@@ -242,7 +310,10 @@ mod tests {
|
||||
fs::write(&other, b"not a screenshot").unwrap();
|
||||
|
||||
sweep_stale_files();
|
||||
assert!(fresh.exists(), "a freshly staged screenshot is still in use");
|
||||
assert!(
|
||||
fresh.exists(),
|
||||
"a freshly staged screenshot is still in use"
|
||||
);
|
||||
assert!(other.exists(), "unrelated files are left alone");
|
||||
|
||||
fs::remove_file(&fresh).unwrap();
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use smithay::reexports::wayland_server::Client;
|
||||
use smithay::wayland::security_context::{
|
||||
SecurityContext, SecurityContextHandler, SecurityContextListenerSource,
|
||||
};
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::state::ClientState;
|
||||
|
||||
pub(crate) fn unrestricted_client(client: &Client) -> bool {
|
||||
client
|
||||
.get_data::<ClientState>()
|
||||
.is_some_and(|state| state.security_context.is_none())
|
||||
}
|
||||
|
||||
impl SecurityContextHandler for Ferese {
|
||||
fn context_created(&mut self, source: SecurityContextListenerSource, context: SecurityContext) {
|
||||
if let Err(error) = self
|
||||
.loop_handle
|
||||
.insert_source(source, move |stream, _, state| {
|
||||
let data = ClientState {
|
||||
security_context: Some(context.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(error) = state.display_handle.insert_client(stream, Arc::new(data)) {
|
||||
tracing::warn!(%error, "failed to register sandboxed Wayland client");
|
||||
}
|
||||
})
|
||||
{
|
||||
tracing::warn!(%error, "failed to register security context listener");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::Arc;
|
||||
|
||||
use smithay::reexports::calloop::EventLoop;
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_wm_base::XdgWmBase;
|
||||
use smithay::reexports::wayland_protocols::xdg::toplevel_icon::v1::server::xdg_toplevel_icon_manager_v1::XdgToplevelIconManagerV1;
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
use smithay::reexports::wayland_server::protocol::{wl_compositor::WlCompositor, wl_shm::WlShm};
|
||||
use smithay::wayland::GlobalData;
|
||||
use smithay::wayland::shell::xdg::XdgWmBaseUserData;
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::state::{ClientState, dispatch_test_client, test_client_request as request};
|
||||
|
||||
// Replay the native JetBrains lifecycle: assign an icon, destroy the icon
|
||||
// object, then free its SHM buffer. The next sync must still reach the client.
|
||||
fn icon_cleanup_messages(destroy_icon: bool) -> Vec<(u32, u16, Vec<u32>)> {
|
||||
let mut events = EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
let (server, mut wire) = UnixStream::pair().unwrap();
|
||||
let client = state
|
||||
.display_handle
|
||||
.insert_client(server, Arc::new(ClientState::default()))
|
||||
.unwrap();
|
||||
let compositor = client
|
||||
.create_resource::<WlCompositor, GlobalData, Ferese>(&state.display_handle, 6, GlobalData)
|
||||
.unwrap();
|
||||
let shell = client
|
||||
.create_resource::<XdgWmBase, XdgWmBaseUserData, Ferese>(
|
||||
&state.display_handle,
|
||||
6,
|
||||
Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let shm = client
|
||||
.create_resource::<WlShm, GlobalData, Ferese>(&state.display_handle, 1, GlobalData)
|
||||
.unwrap();
|
||||
let manager = client
|
||||
.create_resource::<XdgToplevelIconManagerV1, GlobalData, Ferese>(
|
||||
&state.display_handle,
|
||||
1,
|
||||
GlobalData,
|
||||
)
|
||||
.unwrap();
|
||||
request(&mut wire, compositor.id().protocol_id(), 0, &[2], None);
|
||||
request(&mut wire, shell.id().protocol_id(), 2, &[3, 2], None);
|
||||
request(&mut wire, 3, 1, &[4], None);
|
||||
let mut file = tempfile::tempfile().unwrap();
|
||||
file.write_all(&0xff112233u32.to_ne_bytes().repeat(32 * 32))
|
||||
.unwrap();
|
||||
request(
|
||||
&mut wire,
|
||||
shm.id().protocol_id(),
|
||||
0,
|
||||
&[5, 32 * 32 * 4],
|
||||
Some(file.as_raw_fd()),
|
||||
);
|
||||
request(&mut wire, 5, 0, &[6, 0, 32, 32, 128, 0], None);
|
||||
request(&mut wire, manager.id().protocol_id(), 1, &[7], None);
|
||||
request(&mut wire, 7, 2, &[6, 1], None);
|
||||
request(&mut wire, manager.id().protocol_id(), 2, &[4, 7], None);
|
||||
if destroy_icon {
|
||||
request(&mut wire, 7, 0, &[], None);
|
||||
}
|
||||
request(&mut wire, 6, 0, &[], None);
|
||||
request(&mut wire, 1, 0, &[8], None);
|
||||
dispatch_test_client(&mut events, &mut state);
|
||||
wire.set_nonblocking(true).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
let _ = wire.read_to_end(&mut bytes);
|
||||
let mut messages = Vec::new();
|
||||
let mut offset = 0;
|
||||
while offset < bytes.len() {
|
||||
let object = u32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap());
|
||||
let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap());
|
||||
let size = (header >> 16) as usize;
|
||||
let arguments = bytes[offset + 8..offset + size]
|
||||
.chunks_exact(4)
|
||||
.map(|word| u32::from_ne_bytes(word.try_into().unwrap()))
|
||||
.collect();
|
||||
messages.push((object, header as u16, arguments));
|
||||
offset += size;
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroying_an_assigned_icon_before_its_buffer_keeps_the_client_connected() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"handlers::toplevel_icon_tests::destroying_an_assigned_icon_before_its_buffer_keeps_the_client_connected",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let messages = icon_cleanup_messages(true);
|
||||
assert!(
|
||||
!messages
|
||||
.iter()
|
||||
.any(|(object, opcode, _)| *object == 1 && *opcode == 0)
|
||||
);
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|(object, opcode, _)| *object == 8 && *opcode == 0),
|
||||
"valid icon cleanup disconnected the client before its sync reply: {messages:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroying_a_buffer_while_its_icon_is_alive_still_reports_no_buffer() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"handlers::toplevel_icon_tests::destroying_a_buffer_while_its_icon_is_alive_still_reports_no_buffer",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let messages = icon_cleanup_messages(false);
|
||||
assert!(
|
||||
messages.iter().any(|(object, opcode, args)| {
|
||||
*object == 1 && *opcode == 0 && args.first() == Some(&7) && args.get(1) == Some(&3)
|
||||
}),
|
||||
"destroying a live icon's buffer must report the protocol error: {messages:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use smithay::backend::input::{Event, InputEvent, KeyboardKeyEvent};
|
||||
use smithay::utils::SERIAL_COUNTER;
|
||||
use smithay::wayland::input_method::InputMethodSeat;
|
||||
use smithay::wayland::virtual_keyboard::{
|
||||
VirtualKeyboardBackend, VirtualKeyboardHandler, VirtualKeyboardSpecialEvent,
|
||||
};
|
||||
|
||||
use crate::Ferese;
|
||||
|
||||
impl VirtualKeyboardHandler for Ferese {
|
||||
fn process_virtual_keyboard_event(&mut self, event: InputEvent<VirtualKeyboardBackend>) {
|
||||
let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
|
||||
if self.session_lock.active() {
|
||||
// Synthetic input never enters a lock surface. Still reconcile
|
||||
// keys from a departing/previously active auxiliary device.
|
||||
let device = match &event {
|
||||
InputEvent::Keyboard { event } => Some(event.device()),
|
||||
InputEvent::DeviceRemoved { device } => Some(device.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(device) = device {
|
||||
self.super_tap.cancel();
|
||||
keyboard.release_source(self, device.source());
|
||||
}
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
InputEvent::DeviceAdded { device }
|
||||
| InputEvent::Special(VirtualKeyboardSpecialEvent::KeymapChanged { device }) => {
|
||||
if let Some(keymap) = device.keymap() {
|
||||
let current = keyboard.with_xkb_state(self, |context| {
|
||||
context.xkb().lock().unwrap().keymap_as_string()
|
||||
});
|
||||
if current != *keymap
|
||||
&& let Err(error) =
|
||||
keyboard.set_keymap_from_string(self, keymap.to_string())
|
||||
{
|
||||
tracing::warn!(%error, "invalid virtual keyboard keymap");
|
||||
}
|
||||
}
|
||||
}
|
||||
InputEvent::Keyboard { event } => {
|
||||
let device = event.device();
|
||||
let grab_client = self.seat.input_method().keyboard_grab_client();
|
||||
if grab_client.is_some() && grab_client == device.client() {
|
||||
// An IME forwards keys it did not consume. The physical path
|
||||
// already updated XKB and compositor shortcuts: forwarding
|
||||
// through that path again would recurse into the IME grab.
|
||||
keyboard.input_forward_bypassing_grab(
|
||||
self,
|
||||
event.key_code(),
|
||||
event.state(),
|
||||
SERIAL_COUNTER.next_serial(),
|
||||
event.time(),
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
self.process_input_event_from_source::<VirtualKeyboardBackend>(
|
||||
InputEvent::Keyboard { event },
|
||||
device.source(),
|
||||
);
|
||||
}
|
||||
}
|
||||
InputEvent::Special(VirtualKeyboardSpecialEvent::Modifiers {
|
||||
device,
|
||||
mods_depressed,
|
||||
mods_latched,
|
||||
mods_locked,
|
||||
group,
|
||||
}) => {
|
||||
let grab_client = self.seat.input_method().keyboard_grab_client();
|
||||
if grab_client.is_some() && grab_client == device.client() {
|
||||
// The physical path already owns these modifiers. An
|
||||
// asynchronous IME echo must not restore an old mask after
|
||||
// the physical release has arrived.
|
||||
return;
|
||||
}
|
||||
keyboard.with_xkb_state(self, |mut context| {
|
||||
context.set_modifier_mask(mods_depressed, mods_latched, mods_locked, group)
|
||||
});
|
||||
}
|
||||
InputEvent::DeviceRemoved { device } => {
|
||||
self.super_tap.cancel();
|
||||
keyboard.release_source(self, device.source());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use crate::Ferese;
|
||||
use smithay::backend::input::InputEvent;
|
||||
use smithay::wayland::virtual_pointer::{VirtualPointerBackend, VirtualPointerHandler};
|
||||
|
||||
impl VirtualPointerHandler for Ferese {
|
||||
fn process_virtual_pointer_event(&mut self, event: InputEvent<VirtualPointerBackend>) {
|
||||
// The global exists only with the explicit test-pointer environment
|
||||
// switch. Synthetic pointers must never enter a real lock surface.
|
||||
if !self.session_lock.active() {
|
||||
self.process_input_event(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,16 @@ use std::sync::{Arc, Mutex};
|
||||
use ferese_layout::WindowId;
|
||||
use ferese_protocols::window_capture::v1::server::ferese_window_capture_manager_v1 as manager;
|
||||
use ferese_protocols::window_capture::v1::server::ferese_window_capture_manager_v1::FereseWindowCaptureManagerV1;
|
||||
use smithay::backend::renderer::utils::{RendererSurfaceStateUserData, SurfaceView};
|
||||
use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1 as frame;
|
||||
use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1::ZwlrScreencopyFrameV1;
|
||||
use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer;
|
||||
use smithay::reexports::wayland_server::protocol::wl_shm;
|
||||
use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource};
|
||||
use smithay::utils::Rectangle;
|
||||
use smithay::reexports::wayland_server::{
|
||||
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
|
||||
};
|
||||
use smithay::utils::{Logical, Point, Rectangle};
|
||||
use smithay::wayland::compositor::{TraversalAction, with_surface_tree_downward};
|
||||
use smithay::wayland::shm::with_buffer_contents_mut;
|
||||
|
||||
use super::screencopy::FrameData;
|
||||
@@ -19,6 +23,40 @@ use crate::Ferese;
|
||||
const MAX_BYTES: usize = 128 * 1024 * 1024;
|
||||
const MAX_FRAMES: usize = 8;
|
||||
|
||||
fn capture_scale(output_scale: f64, buffer_scale: i32, view: SurfaceView) -> f64 {
|
||||
if view.dst.w <= 0 || view.dst.h <= 0 {
|
||||
return output_scale;
|
||||
}
|
||||
let width = view.src.size.w * f64::from(buffer_scale);
|
||||
let height = view.src.size.h * f64::from(buffer_scale);
|
||||
let logical_width = f64::from(view.dst.w);
|
||||
let logical_height = f64::from(view.dst.h);
|
||||
let matches_native = |scale: f64| {
|
||||
(logical_width * scale).round() == width && (logical_height * scale).round() == height
|
||||
};
|
||||
// Fractional clients round their backing dimensions independently. Taking
|
||||
// the larger density can add a pixel on the other axis and filter every
|
||||
// row or column, even though the client already rendered at this scale.
|
||||
if matches_native(output_scale) {
|
||||
return output_scale;
|
||||
}
|
||||
let scale = output_scale
|
||||
.max(width / logical_width)
|
||||
.max(height / logical_height);
|
||||
if matches_native(scale) {
|
||||
return scale;
|
||||
}
|
||||
// Prefer a common density whose rounded dimensions preserve both axes.
|
||||
// Stay strictly below the upper rounding boundary, including FP error.
|
||||
let upper = ((width + 0.5) / logical_width).min((height + 0.5) / logical_height);
|
||||
let adjusted = upper * (1.0 - 8.0 * f64::EPSILON);
|
||||
if adjusted >= output_scale && matches_native(adjusted) {
|
||||
adjusted
|
||||
} else {
|
||||
scale
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Budget {
|
||||
bytes: usize,
|
||||
@@ -33,8 +71,13 @@ struct Permit {
|
||||
|
||||
impl Permit {
|
||||
fn reserve(budget: &Arc<Mutex<Budget>>, bytes: usize) -> Option<Self> {
|
||||
let mut available = budget.lock().unwrap();
|
||||
if bytes == 0 || bytes > MAX_BYTES.saturating_sub(available.bytes) || available.frames >= MAX_FRAMES {
|
||||
let mut available = budget
|
||||
.lock()
|
||||
.expect("surface rendering state must remain consistent across callbacks");
|
||||
if bytes == 0
|
||||
|| bytes > MAX_BYTES.saturating_sub(available.bytes)
|
||||
|| available.frames >= MAX_FRAMES
|
||||
{
|
||||
return None;
|
||||
}
|
||||
available.bytes += bytes;
|
||||
@@ -48,7 +91,10 @@ impl Permit {
|
||||
|
||||
impl Drop for Permit {
|
||||
fn drop(&mut self) {
|
||||
let mut budget = self.budget.lock().unwrap();
|
||||
let mut budget = self
|
||||
.budget
|
||||
.lock()
|
||||
.expect("surface rendering state must remain consistent across callbacks");
|
||||
budget.bytes -= self.bytes;
|
||||
budget.frames -= 1;
|
||||
}
|
||||
@@ -57,18 +103,31 @@ impl Drop for Permit {
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Snapshot {
|
||||
id: WindowId,
|
||||
epoch: u64,
|
||||
logical_size: (u32, u32),
|
||||
pixels: CaptureBuffer,
|
||||
_permit: Permit,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Global {
|
||||
budget: Arc<Mutex<Budget>>,
|
||||
worker: std::sync::mpsc::SyncSender<Conversion>,
|
||||
}
|
||||
|
||||
struct Conversion {
|
||||
snapshot: Snapshot,
|
||||
frame: ZwlrScreencopyFrameV1,
|
||||
manager: FereseWindowCaptureManagerV1,
|
||||
}
|
||||
|
||||
pub(crate) fn is_portal(stream: &std::os::unix::net::UnixStream) -> bool {
|
||||
use std::os::fd::AsRawFd;
|
||||
let mut credentials = libc::ucred { pid: 0, uid: 0, gid: 0 };
|
||||
let mut credentials = libc::ucred {
|
||||
pid: 0,
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
};
|
||||
let mut length = std::mem::size_of_val(&credentials) as libc::socklen_t;
|
||||
// SAFETY: the connected socket is live and both out pointers cover their
|
||||
// initialized stack allocations. SO_PEERCRED identifies the connecting process.
|
||||
@@ -86,23 +145,110 @@ pub(crate) fn is_portal(stream: &std::os::unix::net::UnixStream) -> bool {
|
||||
}
|
||||
let expected = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|dir| dir.join("xdg-desktop-portal-ferese")))
|
||||
.and_then(|exe| {
|
||||
exe.parent()
|
||||
.map(|dir| dir.join("xdg-desktop-portal-ferese"))
|
||||
})
|
||||
.and_then(|exe| std::fs::metadata(exe).ok());
|
||||
let actual = std::fs::metadata(format!("/proc/{}/exe", credentials.pid)).ok();
|
||||
matches!((expected, actual), (Some(expected), Some(actual)) if expected.dev() == actual.dev() && expected.ino() == actual.ino())
|
||||
}
|
||||
|
||||
pub(super) fn init_global(display: &DisplayHandle) {
|
||||
display.create_global::<Ferese, FereseWindowCaptureManagerV1, Global>(1, Global { budget: Arc::default() });
|
||||
pub(super) fn init_global(
|
||||
display: &DisplayHandle,
|
||||
loop_handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>,
|
||||
) {
|
||||
use smithay::reexports::calloop::channel;
|
||||
let (worker, requests) = std::sync::mpsc::sync_channel::<Conversion>(MAX_FRAMES);
|
||||
let (results, source) = channel::sync_channel::<Conversion>(MAX_FRAMES);
|
||||
let Ok(token) = loop_handle.insert_source(source, |event, _, state| {
|
||||
if let channel::Event::Msg(result) = event {
|
||||
finish_conversion(state, result);
|
||||
}
|
||||
}) else {
|
||||
tracing::warn!("window capture result source could not be registered");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(error) = std::thread::Builder::new()
|
||||
.name("ferese-capture".into())
|
||||
.spawn(move || {
|
||||
while let Ok(mut job) = requests.recv() {
|
||||
if job.frame.is_alive() {
|
||||
crate::render::convert_window_pixels(&mut job.snapshot.pixels.pixels);
|
||||
}
|
||||
// Permits cap the total number of queued, working and completed
|
||||
// frames at MAX_FRAMES, so the result queue cannot fill here.
|
||||
if results.send(job).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
{
|
||||
loop_handle.remove(token);
|
||||
tracing::warn!(%error, "window capture worker could not be started");
|
||||
return;
|
||||
}
|
||||
|
||||
display.create_global::<Ferese, FereseWindowCaptureManagerV1, Global>(
|
||||
3,
|
||||
Global {
|
||||
budget: Arc::default(),
|
||||
worker,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn finish_conversion(state: &Ferese, result: Conversion) {
|
||||
let Conversion {
|
||||
snapshot,
|
||||
frame,
|
||||
manager,
|
||||
} = result;
|
||||
let Some(data) = frame.data::<Arc<FrameData>>() else {
|
||||
return;
|
||||
};
|
||||
if !frame.is_alive()
|
||||
|| *data
|
||||
.used
|
||||
.lock()
|
||||
.expect("surface rendering state must remain consistent across callbacks")
|
||||
{
|
||||
return;
|
||||
}
|
||||
if state.session_lock.active()
|
||||
|| !super::screencopy::capture_allowed()
|
||||
|| !state.capture_window_allowed(snapshot.id, snapshot.epoch)
|
||||
{
|
||||
*data
|
||||
.used
|
||||
.lock()
|
||||
.expect("surface rendering state must remain consistent across callbacks") = true;
|
||||
frame.failed();
|
||||
return;
|
||||
}
|
||||
|
||||
let size = (snapshot.pixels.width as u32, snapshot.pixels.height as u32);
|
||||
let logical_size = snapshot.logical_size;
|
||||
*data
|
||||
.snapshot
|
||||
.lock()
|
||||
.expect("surface rendering state must remain consistent across callbacks") = Some(snapshot);
|
||||
if manager.is_alive() {
|
||||
manager.geometry(&frame, logical_size.0, logical_size.1);
|
||||
}
|
||||
frame.buffer(wl_shm::Format::Argb8888, size.0, size.1, size.0 * 4);
|
||||
}
|
||||
|
||||
impl GlobalDispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
|
||||
fn can_view(client: Client, _: &Global) -> bool {
|
||||
client.get_data::<crate::state::ClientState>().is_some_and(|state| {
|
||||
state
|
||||
.capabilities
|
||||
.contains(crate::private_client::ClientCapabilities::WINDOW_CAPTURE)
|
||||
})
|
||||
client
|
||||
.get_data::<crate::state::ClientState>()
|
||||
.is_some_and(|state| {
|
||||
state
|
||||
.capabilities
|
||||
.contains(crate::private_client::ClientCapabilities::WINDOW_CAPTURE)
|
||||
})
|
||||
}
|
||||
|
||||
fn bind(
|
||||
@@ -113,20 +259,104 @@ impl GlobalDispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
|
||||
global: &Global,
|
||||
data_init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
data_init.init(resource, global.budget.clone());
|
||||
data_init.init(resource, global.clone());
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<FereseWindowCaptureManagerV1, Arc<Mutex<Budget>>> for Ferese {
|
||||
impl Dispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
|
||||
fn request(
|
||||
state: &mut Self,
|
||||
_: &Client,
|
||||
manager: &FereseWindowCaptureManagerV1,
|
||||
request: manager::Request,
|
||||
budget: &Arc<Mutex<Budget>>,
|
||||
global: &Global,
|
||||
_: &DisplayHandle,
|
||||
data_init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
if let manager::Request::CreateWorkspaceSource {
|
||||
workspace_hi,
|
||||
workspace_lo,
|
||||
source,
|
||||
} = request
|
||||
{
|
||||
use smithay::wayland::image_capture_source::{
|
||||
ImageCaptureSource, ImageCaptureSourceData,
|
||||
};
|
||||
let id =
|
||||
ferese_core::WorkspaceId((u64::from(workspace_hi) << 32) | u64::from(workspace_lo));
|
||||
let capture = ImageCaptureSource::new();
|
||||
let view = super::workspace_capture::WorkspaceSource::new(state, id);
|
||||
if let Some(view) = &view {
|
||||
capture
|
||||
.user_data()
|
||||
.insert_if_missing(|| super::image_capture::Source::Workspace(view.clone()));
|
||||
}
|
||||
let resource = data_init.init(
|
||||
source,
|
||||
ImageCaptureSourceData {
|
||||
source: capture.clone(),
|
||||
},
|
||||
);
|
||||
capture.add_instance(&resource);
|
||||
if let Some(view) = view {
|
||||
manager.source_geometry(
|
||||
&resource,
|
||||
view.geometry.size.w as u32,
|
||||
view.geometry.size.h as u32,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let manager::Request::SetSourceActive { source, active } = request {
|
||||
if let Some(source) =
|
||||
smithay::wayland::image_capture_source::ImageCaptureSource::from_resource(&source)
|
||||
{
|
||||
source
|
||||
.user_data()
|
||||
.get_or_insert_threadsafe(super::image_capture::SourcePaused::default)
|
||||
.0
|
||||
.store(active != 1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let manager::Request::RefreshSource { source } = request {
|
||||
if let Some(source) =
|
||||
smithay::wayland::image_capture_source::ImageCaptureSource::from_resource(&source)
|
||||
{
|
||||
state.refresh_capture_source(&source);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let manager::Request::CreateSource {
|
||||
window_hi,
|
||||
window_lo,
|
||||
source,
|
||||
} = request
|
||||
{
|
||||
use smithay::wayland::image_capture_source::{
|
||||
ImageCaptureSource, ImageCaptureSourceData,
|
||||
};
|
||||
let id = WindowId((u64::from(window_hi) << 32) | u64::from(window_lo));
|
||||
let capture = ImageCaptureSource::new();
|
||||
capture
|
||||
.user_data()
|
||||
.insert_if_missing(|| super::image_capture::Source::Window(id));
|
||||
let resource = data_init.init(
|
||||
source,
|
||||
ImageCaptureSourceData {
|
||||
source: capture.clone(),
|
||||
},
|
||||
);
|
||||
capture.add_instance(&resource);
|
||||
if let Some(info) = state.window_capture_info(id) {
|
||||
manager.source_geometry(
|
||||
&resource,
|
||||
info.geometry.size.w as u32,
|
||||
info.geometry.size.h as u32,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let manager::Request::CaptureWindow {
|
||||
window_hi,
|
||||
window_lo,
|
||||
@@ -137,13 +367,12 @@ impl Dispatch<FereseWindowCaptureManagerV1, Arc<Mutex<Budget>>> for Ferese {
|
||||
let id = WindowId((u64::from(window_hi) << 32) | u64::from(window_lo));
|
||||
let mut busy = false;
|
||||
let snapshot = (overlay_cursor <= 1)
|
||||
.then(|| state.window_snapshot(id, overlay_cursor == 1, budget, &mut busy))
|
||||
.then(|| state.window_snapshot(id, overlay_cursor == 1, &global.budget, &mut busy))
|
||||
.flatten();
|
||||
let size = snapshot
|
||||
.as_ref()
|
||||
.map(|snapshot| (snapshot.pixels.width, snapshot.pixels.height))
|
||||
.unwrap_or((1, 1));
|
||||
let logical_size = snapshot.as_ref().map(|snapshot| snapshot.logical_size);
|
||||
let failed = snapshot.is_none();
|
||||
let resource = data_init.init(
|
||||
frame,
|
||||
@@ -152,7 +381,8 @@ impl Dispatch<FereseWindowCaptureManagerV1, Arc<Mutex<Budget>>> for Ferese {
|
||||
region: Rectangle::from_size(size.into()),
|
||||
overlay_cursor: false,
|
||||
used: Mutex::new(failed),
|
||||
snapshot: Mutex::new(snapshot),
|
||||
snapshot: Mutex::new(None),
|
||||
history: None,
|
||||
}),
|
||||
);
|
||||
if failed {
|
||||
@@ -160,29 +390,41 @@ impl Dispatch<FereseWindowCaptureManagerV1, Arc<Mutex<Budget>>> for Ferese {
|
||||
manager.busy(&resource);
|
||||
}
|
||||
resource.failed();
|
||||
} else {
|
||||
let logical_size = logical_size.unwrap();
|
||||
manager.geometry(&resource, logical_size.0, logical_size.1);
|
||||
resource.buffer(
|
||||
wl_shm::Format::Argb8888,
|
||||
size.0 as u32,
|
||||
size.1 as u32,
|
||||
size.0 as u32 * 4,
|
||||
);
|
||||
} else if global
|
||||
.worker
|
||||
.try_send(Conversion {
|
||||
snapshot: snapshot
|
||||
.expect("conversion is queued only after obtaining a capture snapshot"),
|
||||
frame: resource.clone(),
|
||||
manager: manager.clone(),
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
*resource
|
||||
.data::<Arc<FrameData>>()
|
||||
.expect("capture frame resources are initialized with their FrameData")
|
||||
.used
|
||||
.lock()
|
||||
.expect("surface rendering state must remain consistent across callbacks") =
|
||||
true;
|
||||
manager.busy(&resource);
|
||||
resource.failed();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct WindowInfo {
|
||||
pub window: smithay::desktop::Window,
|
||||
pub output: smithay::output::Output,
|
||||
pub geometry: Rectangle<i32, Logical>,
|
||||
pub scale: f64,
|
||||
pub bytes: usize,
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
fn window_snapshot(
|
||||
&mut self,
|
||||
id: WindowId,
|
||||
overlay_cursor: bool,
|
||||
budget: &Arc<Mutex<Budget>>,
|
||||
busy: &mut bool,
|
||||
) -> Option<Snapshot> {
|
||||
if self.session_lock.active || !super::screencopy::capture_allowed() {
|
||||
pub(super) fn window_capture_info(&self, id: WindowId) -> Option<WindowInfo> {
|
||||
if self.session_lock.active() || !super::screencopy::capture_allowed() {
|
||||
return None;
|
||||
}
|
||||
let window = self
|
||||
@@ -192,6 +434,9 @@ impl Ferese {
|
||||
.find(|(_, candidate)| **candidate == id)?
|
||||
.0
|
||||
.clone();
|
||||
if self.capture_protected(&window) {
|
||||
return None;
|
||||
}
|
||||
let workspace = self.workspaces.workspace_for_window(id)?;
|
||||
let owner = self.output_workspaces.output_for_workspace(workspace)?;
|
||||
let output = self
|
||||
@@ -201,8 +446,36 @@ impl Ferese {
|
||||
.0
|
||||
.clone();
|
||||
let geometry = window.geometry();
|
||||
let scale = output.current_scale().fractional_scale();
|
||||
let size: smithay::utils::Size<i32, smithay::utils::Physical> = geometry.size.to_physical_precise_round(scale);
|
||||
let output_scale = output.current_scale().fractional_scale();
|
||||
// A low-density parent can contain higher-density content. Match the
|
||||
// rendered surface tree, excluding buffers outside the captured geometry.
|
||||
let mut scale = output_scale;
|
||||
if let Some(toplevel) = window.toplevel() {
|
||||
with_surface_tree_downward(
|
||||
toplevel.wl_surface(),
|
||||
Point::<f64, Logical>::default(),
|
||||
|_, states, location| {
|
||||
let Some(data) = states.data_map.get::<RendererSurfaceStateUserData>() else {
|
||||
return TraversalAction::SkipChildren;
|
||||
};
|
||||
let data = data
|
||||
.lock()
|
||||
.expect("Smithay surface metadata must remain consistent across callbacks");
|
||||
let Some(view) = data.view() else {
|
||||
return TraversalAction::SkipChildren;
|
||||
};
|
||||
let location = *location + view.offset.to_f64();
|
||||
if Rectangle::new(location, view.dst.to_f64()).overlaps(geometry.to_f64()) {
|
||||
scale = capture_scale(scale, data.buffer_scale(), view);
|
||||
}
|
||||
TraversalAction::DoChildren(location)
|
||||
},
|
||||
|_, _, _| {},
|
||||
|_, _, _| true,
|
||||
);
|
||||
}
|
||||
let size: smithay::utils::Size<i32, smithay::utils::Physical> =
|
||||
geometry.size.to_physical_precise_round(scale);
|
||||
let bytes = usize::try_from(size.w)
|
||||
.ok()?
|
||||
.checked_mul(usize::try_from(size.h).ok()?)?
|
||||
@@ -210,19 +483,31 @@ impl Ferese {
|
||||
if bytes == 0 || bytes > MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
let Some(permit) = Permit::reserve(budget, bytes) else {
|
||||
*busy = true;
|
||||
return None;
|
||||
};
|
||||
let cursor = if overlay_cursor && !self.overview.is_presenting() {
|
||||
Some(WindowInfo {
|
||||
window,
|
||||
output,
|
||||
geometry,
|
||||
scale,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn window_capture_cursor(
|
||||
&self,
|
||||
id: WindowId,
|
||||
window: &smithay::desktop::Window,
|
||||
geometry: Rectangle<i32, Logical>,
|
||||
) -> Option<Rectangle<i32, Logical>> {
|
||||
if !self.overview.is_presenting() && !self.capture_cursor_protected() {
|
||||
self.seat.get_pointer().and_then(|pointer| {
|
||||
let location = pointer.current_location();
|
||||
if self.window_under_visual(location).as_ref() != Some(&window) {
|
||||
if self.window_under_visual(location).as_ref() != Some(window) {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = self.inverse_presented_window_point(id, location.x, location.y)?;
|
||||
let surface_point = smithay::utils::Point::from((x, y)) + geometry.loc.to_f64();
|
||||
let (surface, _) = window.surface_under(surface_point, smithay::desktop::WindowSurfaceType::ALL)?;
|
||||
let (surface, _) = window
|
||||
.surface_under(surface_point, smithay::desktop::WindowSurfaceType::ALL)?;
|
||||
if pointer.current_focus().as_ref() != Some(&surface) {
|
||||
return None;
|
||||
}
|
||||
@@ -233,7 +518,30 @@ impl Ferese {
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn window_snapshot(
|
||||
&mut self,
|
||||
id: WindowId,
|
||||
overlay_cursor: bool,
|
||||
budget: &Arc<Mutex<Budget>>,
|
||||
busy: &mut bool,
|
||||
) -> Option<Snapshot> {
|
||||
let WindowInfo {
|
||||
window,
|
||||
output,
|
||||
geometry,
|
||||
scale,
|
||||
bytes,
|
||||
} = self.window_capture_info(id)?;
|
||||
let Some(permit) = Permit::reserve(budget, bytes) else {
|
||||
*busy = true;
|
||||
return None;
|
||||
};
|
||||
let cursor = overlay_cursor
|
||||
.then(|| self.window_capture_cursor(id, &window, geometry))
|
||||
.flatten();
|
||||
let result = if let Some(backend) = self.nested_backend.clone() {
|
||||
let mut backend = backend.try_borrow_mut().ok()?;
|
||||
crate::render::capture_window_frame(
|
||||
@@ -245,7 +553,7 @@ impl Ferese {
|
||||
)
|
||||
} else {
|
||||
let mut backend = self.direct_backend.take()?;
|
||||
let result = backend.capture_window_frame(self, &window, &output, cursor);
|
||||
let result = backend.capture_window_frame(self, &window, &output, cursor, scale);
|
||||
self.direct_backend = Some(backend);
|
||||
result
|
||||
};
|
||||
@@ -264,6 +572,7 @@ impl Ferese {
|
||||
}
|
||||
Some(Snapshot {
|
||||
id,
|
||||
epoch: self.capture_epoch,
|
||||
logical_size: (geometry.size.w as u32, geometry.size.h as u32),
|
||||
pixels,
|
||||
_permit: permit,
|
||||
@@ -271,20 +580,36 @@ impl Ferese {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn copy_snapshot(state: &Ferese, resource: &ZwlrScreencopyFrameV1, data: &FrameData, buffer: &WlBuffer) {
|
||||
let mut used = data.used.lock().unwrap();
|
||||
pub(super) fn copy_snapshot(
|
||||
state: &Ferese,
|
||||
resource: &ZwlrScreencopyFrameV1,
|
||||
data: &FrameData,
|
||||
buffer: &WlBuffer,
|
||||
) {
|
||||
let mut used = data
|
||||
.used
|
||||
.lock()
|
||||
.expect("surface rendering state must remain consistent across callbacks");
|
||||
if *used {
|
||||
resource.post_error(frame::Error::AlreadyUsed, "window capture frame has already been used");
|
||||
resource.post_error(
|
||||
frame::Error::AlreadyUsed,
|
||||
"window capture frame has already been used",
|
||||
);
|
||||
return;
|
||||
}
|
||||
*used = true;
|
||||
let Some(snapshot) = data.snapshot.lock().unwrap().take() else {
|
||||
let Some(snapshot) = data
|
||||
.snapshot
|
||||
.lock()
|
||||
.expect("surface rendering state must remain consistent across callbacks")
|
||||
.take()
|
||||
else {
|
||||
resource.failed();
|
||||
return;
|
||||
};
|
||||
if state.session_lock.active
|
||||
if state.session_lock.active()
|
||||
|| !super::screencopy::capture_allowed()
|
||||
|| !state.windows.ids().values().any(|id| *id == snapshot.id)
|
||||
|| !state.capture_window_allowed(snapshot.id, snapshot.epoch)
|
||||
{
|
||||
resource.failed();
|
||||
return;
|
||||
@@ -307,7 +632,11 @@ pub(super) fn copy_snapshot(state: &Ferese, resource: &ZwlrScreencopyFrameV1, da
|
||||
// SAFETY: Smithay owns the writable mapping, validated format/stride and
|
||||
// checked offset cover the complete snapshot; the source is separately owned.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(snapshot.pixels.pixels.as_ptr(), destination.add(offset), bytes);
|
||||
std::ptr::copy_nonoverlapping(
|
||||
snapshot.pixels.pixels.as_ptr(),
|
||||
destination.add(offset),
|
||||
bytes,
|
||||
);
|
||||
}
|
||||
true
|
||||
})
|
||||
@@ -326,9 +655,165 @@ pub(super) fn copy_snapshot(state: &Ferese, resource: &ZwlrScreencopyFrameV1, da
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sharing_preserves_backing_density_at_fractional_display_scales() {
|
||||
let integer = SurfaceView {
|
||||
src: Rectangle::from_size((640., 480.).into()),
|
||||
dst: (640, 480).into(),
|
||||
offset: (0, 0).into(),
|
||||
};
|
||||
let viewport = SurfaceView {
|
||||
src: Rectangle::from_size((1280., 960.).into()),
|
||||
..integer
|
||||
};
|
||||
for output_scale in [1., 1.25, 1.5, 1.75, 2.] {
|
||||
assert_eq!(capture_scale(output_scale, 2, integer), 2.);
|
||||
assert_eq!(capture_scale(output_scale, 1, viewport), 2.);
|
||||
}
|
||||
assert_eq!(capture_scale(2.5, 2, integer), 2.5);
|
||||
let fractional = SurfaceView {
|
||||
src: Rectangle::from_size((1120., 840.).into()),
|
||||
..integer
|
||||
};
|
||||
assert_eq!(capture_scale(1.25, 1, fractional), 1.75);
|
||||
assert_eq!(capture_scale(1.75, 1, fractional), 1.75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_uses_the_viewport_crop_instead_of_the_whole_buffer() {
|
||||
let cropped = SurfaceView {
|
||||
src: Rectangle::new((320., 240.).into(), (640., 480.).into()),
|
||||
dst: (640, 480).into(),
|
||||
offset: (0, 0).into(),
|
||||
};
|
||||
assert_eq!(capture_scale(1.75, 1, cropped), 1.75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_preserves_independently_rounded_fractional_buffers() {
|
||||
for (width, height) in [(3819., 2091.), (3819., 2092.)] {
|
||||
let view = SurfaceView {
|
||||
src: Rectangle::from_size((width, height).into()),
|
||||
dst: (2182, 1195).into(),
|
||||
offset: (0, 0).into(),
|
||||
};
|
||||
for output_scale in [1.25, 1.5, 1.75] {
|
||||
let scale = capture_scale(output_scale, 1, view);
|
||||
assert!(scale >= output_scale);
|
||||
assert_eq!((2182. * scale).round(), width);
|
||||
assert_eq!((1195. * scale).round(), height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercise both asynchronous publication boundaries using real resources.
|
||||
pub(crate) fn check_deferred_snapshot(
|
||||
state: &Ferese,
|
||||
window: &smithay::desktop::Window,
|
||||
epoch: u64,
|
||||
allowed: bool,
|
||||
) {
|
||||
let client = window.toplevel().unwrap().wl_surface().client().unwrap();
|
||||
let id = state.windows.ids()[window];
|
||||
let budget = Arc::default();
|
||||
let snapshot = || Snapshot {
|
||||
id,
|
||||
epoch,
|
||||
logical_size: (64, 48),
|
||||
pixels: CaptureBuffer {
|
||||
width: 64,
|
||||
height: 48,
|
||||
stride: 256,
|
||||
pixels: vec![0; 64 * 48 * 4],
|
||||
},
|
||||
_permit: Permit::reserve(&budget, 64 * 48 * 4).unwrap(),
|
||||
};
|
||||
let frame_data = || {
|
||||
Arc::new(FrameData {
|
||||
output: None,
|
||||
region: Rectangle::from_size((64, 48).into()),
|
||||
overlay_cursor: false,
|
||||
used: Mutex::new(false),
|
||||
snapshot: Mutex::new(None),
|
||||
history: None,
|
||||
})
|
||||
};
|
||||
let (worker, _requests) = std::sync::mpsc::sync_channel(1);
|
||||
let manager = client
|
||||
.create_resource::<FereseWindowCaptureManagerV1, Global, Ferese>(
|
||||
&state.display_handle,
|
||||
1,
|
||||
Global {
|
||||
budget: budget.clone(),
|
||||
worker,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let data = frame_data();
|
||||
let frame = client
|
||||
.create_resource::<ZwlrScreencopyFrameV1, Arc<FrameData>, Ferese>(
|
||||
&state.display_handle,
|
||||
3,
|
||||
data.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
finish_conversion(
|
||||
state,
|
||||
Conversion {
|
||||
snapshot: snapshot(),
|
||||
frame,
|
||||
manager,
|
||||
},
|
||||
);
|
||||
assert_eq!(data.snapshot.lock().unwrap().is_some(), allowed);
|
||||
assert_eq!(*data.used.lock().unwrap(), !allowed);
|
||||
|
||||
let data = frame_data();
|
||||
*data.snapshot.lock().unwrap() = Some(snapshot());
|
||||
let frame = client
|
||||
.create_resource::<ZwlrScreencopyFrameV1, Arc<FrameData>, Ferese>(
|
||||
&state.display_handle,
|
||||
3,
|
||||
data.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let buffer = client
|
||||
.object_from_protocol_id::<WlBuffer>(&state.display_handle, 6)
|
||||
.unwrap();
|
||||
let before = with_buffer_contents_mut(&buffer, |ptr, _, layout| {
|
||||
// SAFETY: the fixture allocates and validates this 64x48 SHM buffer.
|
||||
unsafe {
|
||||
std::slice::from_raw_parts(ptr.add(layout.offset as usize), 64 * 48 * 4).to_vec()
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
copy_snapshot(state, &frame, &data, &buffer);
|
||||
with_buffer_contents_mut(&buffer, |ptr, _, layout| {
|
||||
// SAFETY: same fixture-owned buffer as above.
|
||||
let after =
|
||||
unsafe { std::slice::from_raw_parts(ptr.add(layout.offset as usize), 64 * 48 * 4) };
|
||||
if allowed {
|
||||
assert!(after.iter().all(|byte| *byte == 0));
|
||||
} else {
|
||||
assert_eq!(after, before, "rejected snapshot wrote private pixels");
|
||||
}
|
||||
// Restore the client's colored buffer for subsequent renderer checks.
|
||||
// SAFETY: before was copied from this checked SHM layout, so the destination has
|
||||
// before.len() writable bytes and does not overlap the owned backup.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
before.as_ptr(),
|
||||
ptr.add(layout.offset as usize),
|
||||
before.len(),
|
||||
);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outstanding_snapshots_release_both_limits_when_dropped() {
|
||||
let budget = Arc::default();
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
//! Workspace capture owns a view, not a second desktop. Targets exist only for
|
||||
//! subscribed sources; surface commits invalidate only their owning workspace.
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ferese_core::WorkspaceId;
|
||||
use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture};
|
||||
use smithay::output::Output;
|
||||
use smithay::utils::{Buffer, Logical, Rectangle, Size, Transform};
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::backends::direct::capture::CaptureTarget;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct WorkspaceSource {
|
||||
pub id: WorkspaceId,
|
||||
pub output: Output,
|
||||
pub render_output: Output,
|
||||
native_size: Size<i32, smithay::utils::Physical>,
|
||||
pub geometry: Rectangle<i32, Logical>,
|
||||
pub size: Size<i32, Buffer>,
|
||||
scale: f64,
|
||||
transform: Transform,
|
||||
}
|
||||
|
||||
impl WorkspaceSource {
|
||||
pub fn new(state: &Ferese, id: WorkspaceId) -> Option<Self> {
|
||||
state.workspaces.workspace(id)?;
|
||||
let owner = state.output_workspaces.output_for_workspace(id)?;
|
||||
let output = state
|
||||
.output_ids
|
||||
.iter()
|
||||
.find(|(_, id)| **id == owner)?
|
||||
.0
|
||||
.clone();
|
||||
let native_size = output.current_mode()?.size;
|
||||
let geometry = state.space.output_geometry(&output)?;
|
||||
if native_size.w <= 0 || native_size.h <= 0 || geometry.size.w <= 0 || geometry.size.h <= 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let scale = output.current_scale().fractional_scale();
|
||||
let (size, capture_scale) = capture_size(geometry.size, scale);
|
||||
// A render descriptor only: never registered, mapped, advertised, or
|
||||
// attached to input/layout. It gives shaders a bounded upright canvas.
|
||||
let render_output = Output::new(
|
||||
format!("workspace-capture-{}", id.0),
|
||||
smithay::output::PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: smithay::output::Subpixel::Unknown,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
serial_number: String::new(),
|
||||
},
|
||||
);
|
||||
render_output.change_current_state(
|
||||
Some(smithay::output::Mode {
|
||||
size: (size.w, size.h).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
Some(Transform::Normal),
|
||||
Some(smithay::output::Scale::Fractional(capture_scale)),
|
||||
None,
|
||||
);
|
||||
render_output
|
||||
.user_data()
|
||||
.insert_if_missing(|| CaptureOutputId(owner));
|
||||
Some(Self {
|
||||
id,
|
||||
output: output.clone(),
|
||||
render_output,
|
||||
geometry,
|
||||
size,
|
||||
native_size,
|
||||
scale,
|
||||
transform: output.current_transform(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn valid(&self, state: &Ferese) -> bool {
|
||||
state.workspaces.workspace(self.id).is_some()
|
||||
&& state.output_id(&self.output).is_some_and(|owner| {
|
||||
state.output_workspaces.output_for_workspace(self.id) == Some(owner)
|
||||
})
|
||||
&& state.space.output_geometry(&self.output) == Some(self.geometry)
|
||||
&& self
|
||||
.output
|
||||
.current_mode()
|
||||
.is_some_and(|mode| mode.size == self.native_size)
|
||||
&& self.output.current_scale().fractional_scale() == self.scale
|
||||
&& self.output.current_transform() == self.transform
|
||||
}
|
||||
|
||||
pub fn cursor_visible(&self, state: &Ferese) -> bool {
|
||||
!state.overview.is_presenting()
|
||||
&& !state.input_capture.active()
|
||||
&& state
|
||||
.output_id(&self.output)
|
||||
.is_some_and(|output| state.workspace_capture_visible(self.id, output))
|
||||
&& state.seat.get_pointer().is_some_and(|pointer| {
|
||||
self.geometry
|
||||
.contains(pointer.current_location().to_i32_floor())
|
||||
})
|
||||
&& !state.capture_cursor_protected()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct WorkspaceCapture {
|
||||
pub source: WorkspaceSource,
|
||||
pub content: u64,
|
||||
pub prepared: Option<u64>,
|
||||
pub cursor: Option<super::image_capture::CursorStamp>,
|
||||
pub epoch: u64,
|
||||
pub next_frame: Instant,
|
||||
pub callbacks_checked: Option<u64>,
|
||||
pub callbacks_pending: bool,
|
||||
pub target: Option<CaptureTarget>,
|
||||
pub resources: crate::render::RenderResources,
|
||||
pub scene: Option<crate::state::FrameScene>,
|
||||
pub scene_dirty: bool,
|
||||
pub last_used: Instant,
|
||||
trace: bool,
|
||||
preparations: u64,
|
||||
compositions: u64,
|
||||
last_revision: Option<u64>,
|
||||
}
|
||||
|
||||
impl WorkspaceCapture {
|
||||
pub fn new(source: WorkspaceSource, epoch: u64) -> Self {
|
||||
Self {
|
||||
source,
|
||||
content: 0,
|
||||
prepared: None,
|
||||
cursor: None,
|
||||
epoch,
|
||||
next_frame: Instant::now(),
|
||||
callbacks_checked: None,
|
||||
callbacks_pending: false,
|
||||
target: None,
|
||||
resources: Default::default(),
|
||||
scene: None,
|
||||
scene_dirty: true,
|
||||
last_used: Instant::now(),
|
||||
trace: std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some(),
|
||||
preparations: 0,
|
||||
compositions: 0,
|
||||
last_revision: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalidate(&mut self) {
|
||||
self.content_changed();
|
||||
self.scene_dirty = true;
|
||||
}
|
||||
|
||||
pub fn release_idle_target(&mut self) {
|
||||
self.target = None;
|
||||
self.resources = Default::default();
|
||||
self.scene = None;
|
||||
self.prepared = None;
|
||||
if self.trace {
|
||||
tracing::info!(target: "ferese::render", workspace = self.source.id.0, "released idle workspace capture target");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content_changed(&mut self) {
|
||||
self.content = self.content.wrapping_add(1);
|
||||
}
|
||||
|
||||
fn callbacks(&self, state: &Ferese, send: bool) -> bool {
|
||||
let (Some(scene), Some(target)) = (&self.scene, &self.target) else {
|
||||
return false;
|
||||
};
|
||||
let mut pending = false;
|
||||
for id in scene.windows.keys() {
|
||||
let Some(window) = state
|
||||
.windows
|
||||
.window(*id)
|
||||
.filter(|window| !state.capture_protected(window))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut visible = false;
|
||||
window.with_surfaces(|surface, _| visible |= target.surface_visible(surface));
|
||||
if !visible {
|
||||
continue;
|
||||
}
|
||||
// Normal display callbacks may already have drained these. Capture
|
||||
// never generates presentation feedback or changes scanout ownership.
|
||||
if send {
|
||||
window.send_frame(
|
||||
&self.source.output,
|
||||
state.start_time.elapsed(),
|
||||
Some(interval()),
|
||||
|_, _| None,
|
||||
);
|
||||
}
|
||||
window.with_surfaces(|_, states| {
|
||||
pending |= !states
|
||||
.cached_state
|
||||
.get::<smithay::wayland::compositor::SurfaceAttributes>()
|
||||
.current()
|
||||
.frame_callbacks
|
||||
.is_empty();
|
||||
});
|
||||
}
|
||||
pending
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
&mut self,
|
||||
state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
cursor: bool,
|
||||
) -> Result<(GlesTexture, u64), String> {
|
||||
if self.epoch != state.capture_epoch {
|
||||
self.resources = Default::default();
|
||||
self.target = None;
|
||||
self.epoch = state.capture_epoch;
|
||||
self.callbacks_checked = None;
|
||||
}
|
||||
if self.scene_dirty || self.scene.is_none() {
|
||||
let scene = state.sample_workspace_frame(self.source.id, &self.source.output);
|
||||
let members = state
|
||||
.workspaces
|
||||
.workspace(self.source.id)
|
||||
.into_iter()
|
||||
.flat_map(|workspace| {
|
||||
workspace
|
||||
.layout
|
||||
.window_ids()
|
||||
.chain(workspace.floating.iter().copied())
|
||||
})
|
||||
.collect();
|
||||
self.resources.retain_workspace_windows(&members);
|
||||
self.scene = Some(scene);
|
||||
self.scene_dirty = false;
|
||||
}
|
||||
let scene = self.scene.as_ref().expect("capture scene sampled");
|
||||
let elements = crate::render::capture_workspace_elements(
|
||||
state,
|
||||
renderer,
|
||||
&self.source,
|
||||
cursor,
|
||||
scene,
|
||||
&mut self.resources,
|
||||
);
|
||||
let result = crate::backends::direct::capture::render_capture_with_revision(
|
||||
renderer,
|
||||
&mut self.target,
|
||||
&self.source.render_output,
|
||||
&elements,
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string());
|
||||
if let Err(error) = result {
|
||||
self.target = None;
|
||||
self.prepared = None;
|
||||
return Err(error);
|
||||
}
|
||||
self.preparations += 1;
|
||||
self.prepared = Some(self.content);
|
||||
let target = self
|
||||
.target
|
||||
.as_ref()
|
||||
.expect("successful capture has a target");
|
||||
let image = target.image();
|
||||
if self.last_revision != Some(image.1) {
|
||||
self.compositions += 1;
|
||||
}
|
||||
self.last_revision = Some(image.1);
|
||||
Ok(image)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WorkspaceCapture {
|
||||
fn drop(&mut self) {
|
||||
if self.trace {
|
||||
tracing::info!(target: "ferese::render", workspace = self.source.id.0, preparations = self.preparations, compositions = self.compositions, "workspace capture outcomes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The compositor enforces a ceiling even for a client that bypasses PipeWire's
|
||||
/// pacing. A shared deadline is used for both hidden-client callbacks and frames.
|
||||
pub(super) fn interval() -> Duration {
|
||||
static INTERVAL: std::sync::OnceLock<Duration> = std::sync::OnceLock::new();
|
||||
*INTERVAL.get_or_init(|| {
|
||||
let fps = std::env::var("FERESE_CAPTURE_MAX_FPS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(60)
|
||||
.clamp(1, 240);
|
||||
Duration::from_nanos(1_000_000_000 / fps)
|
||||
})
|
||||
}
|
||||
|
||||
/// Shader cache identity; this does not grant the descriptor a desktop output.
|
||||
pub(crate) struct CaptureOutputId(pub ferese_core::OutputId);
|
||||
|
||||
fn capture_size(logical: Size<i32, Logical>, native_scale: f64) -> (Size<i32, Buffer>, f64) {
|
||||
let (width, height) = if logical.w >= logical.h {
|
||||
(1920.0, 1080.0)
|
||||
} else {
|
||||
(1080.0, 1920.0)
|
||||
};
|
||||
let scale = native_scale
|
||||
.min(width / f64::from(logical.w.max(1)))
|
||||
.min(height / f64::from(logical.h.max(1)));
|
||||
(
|
||||
(
|
||||
(f64::from(logical.w) * scale).round().max(1.0) as i32,
|
||||
(f64::from(logical.h) * scale).round().max(1.0) as i32,
|
||||
)
|
||||
.into(),
|
||||
scale,
|
||||
)
|
||||
}
|
||||
|
||||
fn with_renderer<T>(
|
||||
state: &mut Ferese,
|
||||
output: &Output,
|
||||
render: impl FnOnce(&mut Ferese, &mut GlesRenderer) -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
if let Some(backend) = state.nested_backend.clone() {
|
||||
let mut backend = backend
|
||||
.try_borrow_mut()
|
||||
.map_err(|error| error.to_string())?;
|
||||
render(state, backend.renderer())
|
||||
} else if let Some(mut backend) = state.direct_backend.take() {
|
||||
let result = backend
|
||||
.capture_renderer(output)
|
||||
.ok_or_else(|| "Capture output is unavailable".to_string())
|
||||
.and_then(|renderer| render(state, renderer));
|
||||
state.direct_backend = Some(backend);
|
||||
result
|
||||
} else {
|
||||
Err("Capture renderer is unavailable".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process(
|
||||
view: &mut WorkspaceCapture,
|
||||
state: &mut Ferese,
|
||||
active: &mut super::image_capture::Active,
|
||||
frame: smithay::wayland::image_copy_capture::Frame,
|
||||
compositions_left: &mut usize,
|
||||
) -> Option<Duration> {
|
||||
use smithay::reexports::wayland_protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_frame_v1::FailureReason;
|
||||
let now = Instant::now();
|
||||
view.last_used = now;
|
||||
let cursor = active.session.draw_cursor() && view.source.cursor_visible(state);
|
||||
let cursor_stamp = cursor
|
||||
.then(|| state.capture_cursor_stamp(view.source.geometry))
|
||||
.flatten();
|
||||
let changed = view.prepared != Some(view.content)
|
||||
|| view.epoch != state.capture_epoch
|
||||
|| view.cursor != cursor_stamp;
|
||||
let interval = super::workspace_capture::interval();
|
||||
let mut retry = None;
|
||||
let image = if changed && (now < view.next_frame || *compositions_left == 0) {
|
||||
retry = Some(
|
||||
view.next_frame
|
||||
.saturating_duration_since(now)
|
||||
.max(Duration::from_millis(1)),
|
||||
);
|
||||
None
|
||||
} else if changed {
|
||||
*compositions_left -= 1;
|
||||
let output = view.source.output.clone();
|
||||
let result = with_renderer(state, &output, |state, renderer| {
|
||||
view.render(state, renderer, cursor)
|
||||
});
|
||||
match result {
|
||||
Ok(image) => {
|
||||
view.cursor = cursor_stamp;
|
||||
view.next_frame = now + interval;
|
||||
Some(image)
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "workspace capture failed");
|
||||
frame.fail(FailureReason::Unknown);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view.target.as_ref().map(|target| target.image())
|
||||
};
|
||||
if let Some((texture, revision)) =
|
||||
image.filter(|(_, revision)| active.revision != Some(*revision))
|
||||
{
|
||||
crate::backends::direct::capture::touch_cache(state);
|
||||
let result = with_renderer(state, &view.source.output, |_, renderer| {
|
||||
crate::render::capture_stream_texture(
|
||||
renderer,
|
||||
&texture,
|
||||
&frame.buffer(),
|
||||
Transform::Normal,
|
||||
false,
|
||||
)
|
||||
});
|
||||
active.revision = result.as_ref().ok().map(|_| revision);
|
||||
state.finish_stream_frame(frame, active.session.as_ref(), result);
|
||||
} else {
|
||||
active.pending = Some(frame);
|
||||
}
|
||||
|
||||
let deadline = state
|
||||
.image_capture
|
||||
.workspace_callback_deadline(view.source.id, now);
|
||||
if view.prepared == Some(view.content)
|
||||
&& (view.callbacks_checked != Some(view.content)
|
||||
|| (view.callbacks_pending && now >= deadline))
|
||||
{
|
||||
view.callbacks_pending = view.callbacks(state, now >= deadline);
|
||||
view.callbacks_checked = Some(view.content);
|
||||
if now >= deadline {
|
||||
state
|
||||
.image_capture
|
||||
.set_workspace_callback_deadline(view.source.id, now + interval);
|
||||
}
|
||||
}
|
||||
if view.callbacks_pending {
|
||||
let delay = state
|
||||
.image_capture
|
||||
.workspace_callback_deadline(view.source.id, now)
|
||||
.saturating_duration_since(now);
|
||||
retry = Some(retry.map_or(delay, |existing| existing.min(delay)));
|
||||
}
|
||||
retry
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workspace_canvas_is_bounded_and_preserves_fractional_and_portrait_geometry() {
|
||||
for (logical, native, expected) in [
|
||||
((1920, 1080), 2.0, (1920, 1080)),
|
||||
((1080, 1920), 2.0, (1080, 1920)),
|
||||
((800, 600), 1.25, (1000, 750)),
|
||||
((3440, 1440), 1.0, (1920, 804)),
|
||||
] {
|
||||
let (size, scale) = capture_size(logical.into(), native);
|
||||
assert_eq!(size, expected.into());
|
||||
assert!(scale <= native);
|
||||
assert!(size.w as u64 * size.h as u64 <= 1920 * 1080);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
use super::super::workspace_capture::{WorkspaceCapture, WorkspaceSource};
|
||||
use super::*;
|
||||
use smithay::output::{Mode, PhysicalProperties, Scale, Subpixel};
|
||||
|
||||
fn fixture() -> (
|
||||
smithay::reexports::calloop::EventLoop<'static, Ferese>,
|
||||
Ferese,
|
||||
Output,
|
||||
) {
|
||||
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
|
||||
let mut config = crate::config::Config::default().runtime_config().unwrap();
|
||||
config.animations_enabled = false;
|
||||
config.wallpaper.path = None;
|
||||
let mut state = Ferese::new(
|
||||
&mut events,
|
||||
smithay::reexports::wayland_server::Display::new().unwrap(),
|
||||
config,
|
||||
)
|
||||
.unwrap();
|
||||
let output = Output::new(
|
||||
"workspace-test".into(),
|
||||
PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
serial_number: String::new(),
|
||||
},
|
||||
);
|
||||
output.change_current_state(
|
||||
Some(Mode {
|
||||
size: (1600, 1200).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
None,
|
||||
Some(Scale::Fractional(2.0)),
|
||||
None,
|
||||
);
|
||||
state.space.map_output(&output, (0, 0));
|
||||
state.register_output(&output, "workspace-test".into());
|
||||
(events, state, output)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_capture_samples_hidden_layout_without_switching_or_configuring_clients() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"handlers::image_capture::workspace_tests::workspace_capture_samples_hidden_layout_without_switching_or_configuring_clients",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let (mut events, mut state, output) = fixture();
|
||||
let (window, _client) = crate::state::test_window(&mut state, &mut events, 0xffff0000);
|
||||
let id = state.windows.ids()[&window];
|
||||
let workspace = state.workspaces.workspace_for_window(id).unwrap();
|
||||
let source = WorkspaceSource::new(&state, workspace).unwrap();
|
||||
state.switch_workspace(2);
|
||||
let (private, _private_client) = crate::state::test_window(&mut state, &mut events, 0xff00ff00);
|
||||
let private_id = state.windows.ids()[&private];
|
||||
let focused = state.focused_window;
|
||||
let active = state.workspaces.active_id();
|
||||
let geometry = *state.windows.geometry(&id).unwrap();
|
||||
let sample = state.sample_workspace_frame(workspace, &output);
|
||||
assert!(sample.windows.contains_key(&id));
|
||||
assert!(!sample.windows.contains_key(&private_id));
|
||||
assert!(!sample.overview.is_presenting());
|
||||
assert!(sample.capture_tabs.is_some());
|
||||
assert_eq!(state.workspaces.active_id(), active);
|
||||
assert_eq!(state.focused_window, focused);
|
||||
assert_eq!(*state.windows.geometry(&id).unwrap(), geometry);
|
||||
assert!(state.space.element_location(&window).is_none());
|
||||
assert!(source.valid(&state));
|
||||
// The descriptor is never part of the desktop or advertised to clients.
|
||||
assert!(state.output_id(&source.render_output).is_none());
|
||||
assert!(state.space.output_geometry(&source.render_output).is_none());
|
||||
// A newly mapped hidden client need not have acquired a display sample.
|
||||
state.windows.record_mut(id).unwrap().geometry = None;
|
||||
assert!(
|
||||
state
|
||||
.sample_workspace_frame(workspace, &output)
|
||||
.windows
|
||||
.contains_key(&id)
|
||||
);
|
||||
assert!(state.windows.record(id).unwrap().geometry.is_none());
|
||||
output.change_current_state(None, None, Some(Scale::Fractional(1.5)), None);
|
||||
assert!(!source.valid(&state));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_commits_invalidate_only_subscribed_source_and_allocate_no_targets() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"handlers::image_capture::workspace_tests::workspace_commits_invalidate_only_subscribed_source_and_allocate_no_targets",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let (mut events, mut state, _) = fixture();
|
||||
let (first, _first_client) = crate::state::test_window(&mut state, &mut events, 0xffff0000);
|
||||
let first_id = state.windows.ids()[&first];
|
||||
let workspace = state.workspaces.workspace_for_window(first_id).unwrap();
|
||||
let source = WorkspaceSource::new(&state, workspace).unwrap();
|
||||
state.switch_workspace(2);
|
||||
let (second, _second_client) = crate::state::test_window(&mut state, &mut events, 0xff00ff00);
|
||||
for cursor in [false, true] {
|
||||
state.image_capture.workspaces.insert(
|
||||
(workspace, cursor),
|
||||
WorkspaceCapture::new(source.clone(), state.capture_epoch),
|
||||
);
|
||||
}
|
||||
crate::backends::direct::render_surface(&mut state, second.toplevel().unwrap().wl_surface());
|
||||
for view in state.image_capture.workspaces.values() {
|
||||
assert_eq!(view.content, 0);
|
||||
assert!(view.target.is_none());
|
||||
}
|
||||
crate::backends::direct::render_surface(&mut state, first.toplevel().unwrap().wl_surface());
|
||||
for view in state.image_capture.workspaces.values() {
|
||||
assert_eq!(view.content, 1);
|
||||
}
|
||||
state
|
||||
.image_capture
|
||||
.workspace_callbacks
|
||||
.insert(workspace, Instant::now());
|
||||
state.refresh_capture_sessions();
|
||||
assert!(state.image_capture.workspaces.is_empty());
|
||||
assert!(state.image_capture.workspace_callbacks.is_empty());
|
||||
assert!(state.image_capture.callback_timer.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_admission_accounts_for_the_pool_and_transfer_targets() {
|
||||
assert_eq!(stream_bytes((1920, 1080).into()), 8192 * 1080 * 10);
|
||||
assert!(stream_bytes((3840, 2160).into()) < MAX_STREAM_BYTES);
|
||||
assert!(stream_bytes((i32::MAX, i32::MAX).into()) > MAX_STREAM_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_tab_metadata_invalidates_only_owning_capture() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"handlers::image_capture::workspace_tests::workspace_tab_metadata_invalidates_only_owning_capture",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
use smithay::wayland::compositor::with_states;
|
||||
use smithay::wayland::shell::xdg::{XdgShellHandler, XdgToplevelSurfaceData};
|
||||
|
||||
for hidden in [false, true] {
|
||||
for title in [false, true] {
|
||||
let (mut events, mut state, output) = fixture();
|
||||
let (first, _first_client) =
|
||||
crate::state::test_window(&mut state, &mut events, 0xffff0000);
|
||||
state.toggle_column_tabbed();
|
||||
let (_second, _second_client) =
|
||||
crate::state::test_window(&mut state, &mut events, 0xff00ff00);
|
||||
state.consume_focused_window();
|
||||
let workspace = state.workspaces.active_id();
|
||||
let scene = state.sample_workspace_frame(workspace, &output);
|
||||
assert!(!scene.capture_tabs.as_ref().unwrap().is_empty());
|
||||
assert!(state.space.element_location(&first).is_none());
|
||||
|
||||
state.switch_workspace(2);
|
||||
let (_other, _other_client) =
|
||||
crate::state::test_window(&mut state, &mut events, 0xff0000ff);
|
||||
let other_workspace = state.workspaces.active_id();
|
||||
if !hidden {
|
||||
state.switch_workspace(1);
|
||||
}
|
||||
for id in [workspace, other_workspace] {
|
||||
for cursor in [false, true] {
|
||||
let mut view = WorkspaceCapture::new(
|
||||
WorkspaceSource::new(&state, id).unwrap(),
|
||||
state.capture_epoch,
|
||||
);
|
||||
view.prepared = Some(view.content);
|
||||
view.scene = Some(state.sample_workspace_frame(id, &output));
|
||||
view.scene_dirty = false;
|
||||
state.image_capture.workspaces.insert((id, cursor), view);
|
||||
}
|
||||
}
|
||||
let epoch = state.capture_epoch;
|
||||
let top = first.toplevel().unwrap();
|
||||
with_states(top.wl_surface(), |states| {
|
||||
let mut metadata = states
|
||||
.data_map
|
||||
.get::<XdgToplevelSurfaceData>()
|
||||
.unwrap()
|
||||
.lock()
|
||||
.unwrap();
|
||||
if title {
|
||||
metadata.title = Some("Updated tab title".into());
|
||||
} else {
|
||||
metadata.title = None;
|
||||
metadata.app_id = Some("org.test.UpdatedApp".into());
|
||||
}
|
||||
});
|
||||
if title {
|
||||
state.title_changed(top.clone());
|
||||
} else {
|
||||
state.app_id_changed(top.clone());
|
||||
}
|
||||
assert_eq!(state.capture_epoch, epoch);
|
||||
for cursor in [false, true] {
|
||||
let view = &state.image_capture.workspaces[&(workspace, cursor)];
|
||||
assert_ne!(
|
||||
view.prepared,
|
||||
Some(view.content),
|
||||
"tab metadata must invalidate its capture (hidden={hidden}, title={title})"
|
||||
);
|
||||
assert!(
|
||||
!view.scene_dirty,
|
||||
"metadata must not force a layout resample"
|
||||
);
|
||||
let other = &state.image_capture.workspaces[&(other_workspace, cursor)];
|
||||
assert_eq!(other.prepared, Some(other.content));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,25 @@
|
||||
use smithay::desktop::{
|
||||
PopupKeyboardGrab, PopupKind, PopupManager, PopupPointerGrab, Space, Window, WindowSurfaceType,
|
||||
find_popup_root_surface, get_popup_toplevel_coords,
|
||||
PopupKeyboardGrab, PopupKind, PopupPointerGrab, Window, WindowSurfaceType, find_popup_root_surface,
|
||||
get_popup_toplevel_coords,
|
||||
};
|
||||
use smithay::input::Seat;
|
||||
use smithay::input::pointer::{Focus, GrabStartData};
|
||||
use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode;
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::State as ToplevelState;
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
use smithay::reexports::wayland_server::protocol::wl_seat;
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, Resource, backend::ClientId};
|
||||
use smithay::utils::{Logical, Point, Rectangle, Serial};
|
||||
use smithay::wayland::compositor::with_states;
|
||||
use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler;
|
||||
use smithay::wayland::shell::xdg::{
|
||||
PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState, XdgToplevelSurfaceData,
|
||||
PopupSurface, PositionerState, SurfaceCachedState, ToplevelSurface, XdgShellHandler, XdgShellState,
|
||||
XdgShellSurfaceUserData, XdgToplevelSurfaceData,
|
||||
};
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab, handle_resize_commit};
|
||||
use crate::grabs::ResizeEdge;
|
||||
|
||||
impl XdgShellHandler for Ferese {
|
||||
fn xdg_shell_state(&mut self) -> &mut XdgShellState {
|
||||
@@ -30,47 +32,87 @@ impl XdgShellHandler for Ferese {
|
||||
// discoverable, but do not mutate/focus the layout until its initial
|
||||
// commit supplies the metadata needed to resolve placement rules.
|
||||
self.space.map_element(window, (0, 0), false);
|
||||
self.invalidate_window_stacking();
|
||||
}
|
||||
|
||||
fn title_changed(&mut self, surface: ToplevelSurface) {
|
||||
self.refresh_capture_privacy();
|
||||
if self.windows.refresh_metadata(surface.wl_surface()) {
|
||||
if let Some(workspace) = self
|
||||
.windows
|
||||
.id_for_surface(surface.wl_surface())
|
||||
.and_then(|id| self.workspaces.workspace_for_window(id))
|
||||
{
|
||||
self.image_capture.workspace_changed(workspace);
|
||||
}
|
||||
self.refresh_idle_inhibition();
|
||||
self.send_shell_snapshots();
|
||||
// Ordinary titles belong to the shell; only compositor-drawn labels
|
||||
// need output damage. Privacy changes already invalidate above.
|
||||
let tab_title = self
|
||||
.windows
|
||||
.id_for_surface(surface.wl_surface())
|
||||
.is_some_and(|id| {
|
||||
self.workspaces
|
||||
.workspace_for_window(id)
|
||||
.is_some_and(|workspace| {
|
||||
self.visible_tab_bars(workspace)
|
||||
.any(|bar| bar.group.windows.contains(&id))
|
||||
})
|
||||
});
|
||||
if self.overview.is_presenting() || tab_title {
|
||||
for output in self.surface_outputs(surface.wl_surface()) {
|
||||
self.defer_output_redraw(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn app_id_changed(&mut self, surface: ToplevelSurface) {
|
||||
self.refresh_capture_privacy();
|
||||
if self.windows.refresh_metadata(surface.wl_surface()) {
|
||||
if let Some(workspace) = self
|
||||
.windows
|
||||
.id_for_surface(surface.wl_surface())
|
||||
.and_then(|id| self.workspaces.workspace_for_window(id))
|
||||
{
|
||||
self.image_capture.workspace_changed(workspace);
|
||||
}
|
||||
self.refresh_idle_inhibition();
|
||||
self.send_shell_snapshots();
|
||||
for output in self.surface_outputs(surface.wl_surface()) {
|
||||
self.defer_output_redraw(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_changed(&mut self, surface: ToplevelSurface) {
|
||||
let Some(parent) = surface.parent() else {
|
||||
return;
|
||||
};
|
||||
let parent = self.windows.ids().iter().find_map(|(window, id)| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == &parent)
|
||||
.then_some(*id)
|
||||
});
|
||||
self.refresh_capture_privacy();
|
||||
self.refresh_idle_inhibition();
|
||||
let parent = surface
|
||||
.parent()
|
||||
.and_then(|parent| self.windows.id_for_surface(&parent));
|
||||
let child = self
|
||||
.space
|
||||
.elements()
|
||||
.find(|window| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
|
||||
.windows
|
||||
.id_for_surface(surface.wl_surface())
|
||||
.and_then(|id| self.windows.window(id))
|
||||
.or_else(|| {
|
||||
self.space.elements().find(|window| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
|
||||
})
|
||||
})
|
||||
.cloned();
|
||||
|
||||
if let (Some(parent), Some(child)) = (parent, child) {
|
||||
self.make_window_transient(&child, parent);
|
||||
if let Some(child) = child {
|
||||
self.update_window_parent(&child, parent);
|
||||
}
|
||||
}
|
||||
|
||||
fn toplevel_destroyed(&mut self, surface: ToplevelSurface) {
|
||||
// Hidden workspace windows are managed but unmapped from Space.
|
||||
let Some(window) = self
|
||||
.windows
|
||||
.ids()
|
||||
.keys()
|
||||
.chain(self.space.elements())
|
||||
.find(|window| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
|
||||
})
|
||||
.cloned()
|
||||
else {
|
||||
let Some(window) = self.window_for_surface(surface.wl_surface()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -84,7 +126,12 @@ impl XdgShellHandler for Ferese {
|
||||
}
|
||||
}
|
||||
|
||||
fn reposition_request(&mut self, surface: PopupSurface, positioner: PositionerState, token: u32) {
|
||||
fn reposition_request(
|
||||
&mut self,
|
||||
surface: PopupSurface,
|
||||
positioner: PositionerState,
|
||||
token: u32,
|
||||
) {
|
||||
surface.with_pending_state(|state| {
|
||||
state.geometry = positioner.get_geometry();
|
||||
state.positioner = positioner;
|
||||
@@ -94,7 +141,7 @@ impl XdgShellHandler for Ferese {
|
||||
}
|
||||
|
||||
fn move_request(&mut self, surface: ToplevelSurface, seat: wl_seat::WlSeat, serial: Serial) {
|
||||
if self.session_lock.active {
|
||||
if self.session_lock.active() {
|
||||
return;
|
||||
}
|
||||
let Some(seat) = Seat::from_resource(&seat) else {
|
||||
@@ -118,26 +165,7 @@ impl XdgShellHandler for Ferese {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(initial_rect) = self.visual_rect_for_window(&window) else {
|
||||
return;
|
||||
};
|
||||
if !self.is_floating_window(&window) {
|
||||
return;
|
||||
}
|
||||
pointer.set_grab(
|
||||
self,
|
||||
MoveSurfaceGrab {
|
||||
start_data,
|
||||
window,
|
||||
initial_location: initial_rect.loc,
|
||||
initial_size: initial_rect.size,
|
||||
finished: false,
|
||||
snap_x: Default::default(),
|
||||
snap_y: Default::default(),
|
||||
},
|
||||
serial,
|
||||
Focus::Clear,
|
||||
);
|
||||
self.begin_window_interaction(&pointer, window, start_data, serial, None);
|
||||
}
|
||||
|
||||
fn resize_request(
|
||||
@@ -147,7 +175,7 @@ impl XdgShellHandler for Ferese {
|
||||
serial: Serial,
|
||||
edges: smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge,
|
||||
) {
|
||||
if self.session_lock.active {
|
||||
if self.session_lock.active() {
|
||||
return;
|
||||
}
|
||||
let Some(seat) = Seat::from_resource(&seat) else {
|
||||
@@ -171,27 +199,19 @@ impl XdgShellHandler for Ferese {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(rect) = self.visual_rect_for_window(&window) else {
|
||||
return;
|
||||
};
|
||||
if !self.is_floating_window(&window) {
|
||||
return;
|
||||
if edges != xdg_toplevel::ResizeEdge::None {
|
||||
self.begin_window_interaction(
|
||||
&pointer,
|
||||
window,
|
||||
start_data,
|
||||
serial,
|
||||
Some(ResizeEdge::from(edges)),
|
||||
);
|
||||
}
|
||||
pointer.set_grab(
|
||||
self,
|
||||
ResizeSurfaceGrab::new(start_data, window, ResizeEdge::from(edges), rect),
|
||||
serial,
|
||||
Focus::Clear,
|
||||
);
|
||||
}
|
||||
|
||||
fn maximize_request(&mut self, surface: ToplevelSurface) {
|
||||
if let Some(id) = self.windows.ids().iter().find_map(|(window, id)| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
|
||||
.then_some(*id)
|
||||
}) {
|
||||
if let Some(id) = self.windows.id_for_surface(surface.wl_surface()) {
|
||||
self.set_window_maximized(id, true);
|
||||
} else {
|
||||
surface.with_pending_state(|state| state.states.set(ToplevelState::Maximized));
|
||||
@@ -199,12 +219,7 @@ impl XdgShellHandler for Ferese {
|
||||
}
|
||||
|
||||
fn unmaximize_request(&mut self, surface: ToplevelSurface) {
|
||||
if let Some(id) = self.windows.ids().iter().find_map(|(window, id)| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
|
||||
.then_some(*id)
|
||||
}) {
|
||||
if let Some(id) = self.windows.id_for_surface(surface.wl_surface()) {
|
||||
self.set_window_maximized(id, false);
|
||||
} else {
|
||||
surface.with_pending_state(|state| state.states.unset(ToplevelState::Maximized));
|
||||
@@ -216,12 +231,7 @@ impl XdgShellHandler for Ferese {
|
||||
surface: ToplevelSurface,
|
||||
_output: Option<smithay::reexports::wayland_server::protocol::wl_output::WlOutput>,
|
||||
) {
|
||||
if let Some(window) = self.windows.ids().iter().find_map(|(window, id)| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
|
||||
.then_some(*id)
|
||||
}) {
|
||||
if let Some(window) = self.windows.id_for_surface(surface.wl_surface()) {
|
||||
self.set_window_fullscreen(window, true);
|
||||
} else {
|
||||
surface.with_pending_state(|state| state.states.set(ToplevelState::Fullscreen));
|
||||
@@ -229,12 +239,7 @@ impl XdgShellHandler for Ferese {
|
||||
}
|
||||
|
||||
fn unfullscreen_request(&mut self, surface: ToplevelSurface) {
|
||||
if let Some(window) = self.windows.ids().iter().find_map(|(window, id)| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
|
||||
.then_some(*id)
|
||||
}) {
|
||||
if let Some(window) = self.windows.id_for_surface(surface.wl_surface()) {
|
||||
self.set_window_fullscreen(window, false);
|
||||
} else {
|
||||
surface.with_pending_state(|state| state.states.unset(ToplevelState::Fullscreen));
|
||||
@@ -242,7 +247,7 @@ impl XdgShellHandler for Ferese {
|
||||
}
|
||||
|
||||
fn grab(&mut self, surface: PopupSurface, seat: wl_seat::WlSeat, serial: Serial) {
|
||||
if self.session_lock.active || self.input_capture.active() {
|
||||
if self.session_lock.active() || self.input_capture.active() {
|
||||
return;
|
||||
}
|
||||
let Some(seat) = Seat::from_resource(&seat) else {
|
||||
@@ -261,7 +266,12 @@ impl XdgShellHandler for Ferese {
|
||||
};
|
||||
|
||||
if let Some(pointer) = seat.get_pointer() {
|
||||
pointer.set_grab(self, PopupPointerGrab::new(&popup_grab), serial, Focus::Keep);
|
||||
pointer.set_grab(
|
||||
self,
|
||||
PopupPointerGrab::new(&popup_grab),
|
||||
serial,
|
||||
Focus::Keep,
|
||||
);
|
||||
}
|
||||
if let Some(keyboard) = seat.get_keyboard() {
|
||||
keyboard.set_grab(self, PopupKeyboardGrab::new(&popup_grab), serial);
|
||||
@@ -273,14 +283,21 @@ impl XdgShellHandler for Ferese {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_grab(seat: &Seat<Ferese>, surface: &WlSurface, serial: Serial) -> Option<GrabStartData<Ferese>> {
|
||||
fn check_grab(
|
||||
seat: &Seat<Ferese>,
|
||||
surface: &WlSurface,
|
||||
serial: Serial,
|
||||
) -> Option<GrabStartData<Ferese>> {
|
||||
let pointer = seat.get_pointer()?;
|
||||
if !pointer.has_grab(serial) {
|
||||
return None;
|
||||
}
|
||||
let start_data = pointer.grab_start_data()?;
|
||||
let (focus, _) = start_data.focus.as_ref()?;
|
||||
focus.id().same_client_as(&surface.id()).then_some(start_data)
|
||||
focus
|
||||
.id()
|
||||
.same_client_as(&surface.id())
|
||||
.then_some(start_data)
|
||||
}
|
||||
|
||||
impl XdgDecorationHandler for Ferese {
|
||||
@@ -319,24 +336,36 @@ pub(crate) fn initial_configure_sent(toplevel: &ToplevelSurface) -> bool {
|
||||
states
|
||||
.data_map
|
||||
.get::<XdgToplevelSurfaceData>()
|
||||
.unwrap()
|
||||
.expect("an XDG toplevel must retain its Smithay surface data")
|
||||
.lock()
|
||||
.unwrap()
|
||||
.expect("Smithay surface state must remain consistent across callbacks")
|
||||
.initial_configure_sent
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle_commit(popups: &mut PopupManager, space: &mut Space<Window>, surface: &WlSurface) {
|
||||
handle_resize_commit(surface);
|
||||
if let Some(window) = space
|
||||
.elements()
|
||||
.find(|window| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == surface)
|
||||
})
|
||||
.cloned()
|
||||
{
|
||||
pub(crate) fn validate_size_constraints(toplevel: &ToplevelSurface) -> bool {
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
|
||||
use smithay::wayland::shell::xdg::SurfaceCachedState;
|
||||
|
||||
let valid = with_states(toplevel.wl_surface(), |states| {
|
||||
let mut cached = states.cached_state.get::<SurfaceCachedState>();
|
||||
let state = cached.current();
|
||||
let valid_axis = |min: i32, max: i32| min >= 0 && max >= 0 && (max == 0 || min <= max);
|
||||
valid_axis(state.min_size.w, state.max_size.w)
|
||||
&& valid_axis(state.min_size.h, state.max_size.h)
|
||||
});
|
||||
if !valid {
|
||||
toplevel.xdg_toplevel().post_error(
|
||||
xdg_toplevel::Error::InvalidSize,
|
||||
"invalid minimum/maximum window size",
|
||||
);
|
||||
}
|
||||
|
||||
valid
|
||||
}
|
||||
|
||||
pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) {
|
||||
if let Some(window) = state.window_for_surface(surface) {
|
||||
let configured = with_states(surface, |states| {
|
||||
states
|
||||
.data_map
|
||||
@@ -354,11 +383,13 @@ pub fn handle_commit(popups: &mut PopupManager, space: &mut Space<Window>, surfa
|
||||
}
|
||||
}
|
||||
|
||||
popups.commit(surface);
|
||||
if let Some(PopupKind::Xdg(popup)) = popups.find_popup(surface)
|
||||
state.popups.commit(surface);
|
||||
if let Some(PopupKind::Xdg(popup)) = state.popups.find_popup(surface)
|
||||
&& !popup.is_initial_configure_sent()
|
||||
{
|
||||
popup.send_configure().expect("initial popup configure is valid");
|
||||
popup
|
||||
.send_configure()
|
||||
.expect("initial popup configure is valid");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,14 +422,9 @@ pub fn apply_initial_window_rules(state: &mut Ferese, window: &Window) {
|
||||
})
|
||||
});
|
||||
if initial {
|
||||
let parent = toplevel.parent().and_then(|parent| {
|
||||
state.windows.ids().iter().find_map(|(candidate, id)| {
|
||||
candidate
|
||||
.toplevel()
|
||||
.is_some_and(|surface| surface.wl_surface() == &parent)
|
||||
.then_some(*id)
|
||||
})
|
||||
});
|
||||
let parent = toplevel
|
||||
.parent()
|
||||
.and_then(|parent| state.windows.id_for_surface(&parent));
|
||||
state.add_rule_placed_window(window.clone(), app_id.as_deref(), title.as_deref(), parent);
|
||||
}
|
||||
state.apply_initial_window_rules(window, app_id.as_deref(), title.as_deref(), transient);
|
||||
@@ -419,10 +445,11 @@ impl Ferese {
|
||||
let Ok(root) = find_popup_root_surface(&PopupKind::Xdg(popup.clone())) else {
|
||||
return;
|
||||
};
|
||||
let window = self
|
||||
.space
|
||||
.elements()
|
||||
.find(|window| window.toplevel().is_some_and(|toplevel| toplevel.wl_surface() == &root));
|
||||
let window = self.space.elements().find(|window| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == &root)
|
||||
});
|
||||
let geometry = (|| {
|
||||
if let Some(window) = window {
|
||||
let id = *self.windows.ids().get(window)?;
|
||||
@@ -436,11 +463,14 @@ impl Ferese {
|
||||
|
||||
Some((output_geometry, location))
|
||||
} else {
|
||||
let layer = self.space.layer_for_surface(&root, WindowSurfaceType::TOPLEVEL)?;
|
||||
let layer = self
|
||||
.space
|
||||
.layer_for_surface(&root, WindowSurfaceType::TOPLEVEL)?;
|
||||
|
||||
self.space.outputs().find_map(|output| {
|
||||
let output_geometry = self.space.output_geometry(output)?;
|
||||
let layer_geometry = smithay::desktop::layer_map_for_output(output).layer_geometry(&layer)?;
|
||||
let layer_geometry =
|
||||
smithay::desktop::layer_map_for_output(output).layer_geometry(&layer)?;
|
||||
|
||||
Some((output_geometry, output_geometry.loc + layer_geometry.loc))
|
||||
})
|
||||
@@ -470,6 +500,74 @@ fn popup_constraint_target(
|
||||
output
|
||||
}
|
||||
|
||||
impl Dispatch<xdg_toplevel::XdgToplevel, XdgShellSurfaceUserData> for Ferese {
|
||||
fn request(
|
||||
state: &mut Self,
|
||||
client: &Client,
|
||||
resource: &xdg_toplevel::XdgToplevel,
|
||||
request: xdg_toplevel::Request,
|
||||
data: &XdgShellSurfaceUserData,
|
||||
dhandle: &DisplayHandle,
|
||||
data_init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
let unusable = match &request {
|
||||
xdg_toplevel::Request::SetMinSize { width, height } => {
|
||||
!size_limits_usable(state, resource, (*width, *height), true)
|
||||
}
|
||||
xdg_toplevel::Request::SetMaxSize { width, height } => {
|
||||
!size_limits_usable(state, resource, (*width, *height), false)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if unusable {
|
||||
resource.post_error(xdg_toplevel::Error::InvalidSize, "size limits are unusable");
|
||||
return;
|
||||
}
|
||||
|
||||
smithay::wayland::Dispatch2::request(
|
||||
data, state, client, resource, request, dhandle, data_init,
|
||||
);
|
||||
}
|
||||
|
||||
fn destroyed(
|
||||
state: &mut Self,
|
||||
client: ClientId,
|
||||
resource: &xdg_toplevel::XdgToplevel,
|
||||
data: &XdgShellSurfaceUserData,
|
||||
) {
|
||||
smithay::wayland::Dispatch2::destroyed(data, state, client, resource);
|
||||
}
|
||||
}
|
||||
|
||||
fn size_limits_usable(
|
||||
state: &Ferese,
|
||||
toplevel: &xdg_toplevel::XdgToplevel,
|
||||
proposed: (i32, i32),
|
||||
minimum: bool,
|
||||
) -> bool {
|
||||
if proposed.0 < 0 || proposed.1 < 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(surface) = state.xdg_shell_state.get_toplevel(toplevel) else {
|
||||
return true;
|
||||
};
|
||||
let (min, max) = with_states(surface.wl_surface(), |states| {
|
||||
let mut cached = states.cached_state.get::<SurfaceCachedState>();
|
||||
let pending = cached.pending();
|
||||
(
|
||||
(pending.min_size.w, pending.min_size.h),
|
||||
(pending.max_size.w, pending.max_size.h),
|
||||
)
|
||||
});
|
||||
let (min, max) = if minimum {
|
||||
(proposed, max)
|
||||
} else {
|
||||
(min, proposed)
|
||||
};
|
||||
(max.0 == 0 || min.0 <= max.0) && (max.1 == 0 || min.1 <= max.1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_positioner;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Automatic policy feeds the same idle notifier as Wayland and portal requests.
|
||||
pub(crate) mod media;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ferese_layout::WindowId;
|
||||
use serde::Deserialize;
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
use smithay::wayland::compositor::with_states;
|
||||
use smithay::wayland::shell::xdg::XdgToplevelSurfaceData;
|
||||
|
||||
use crate::Ferese;
|
||||
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
|
||||
)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub(crate) struct Settings {
|
||||
pub fullscreen_playback: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fullscreen_playback: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
|
||||
)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Mode {
|
||||
None,
|
||||
Visible,
|
||||
Fullscreen,
|
||||
Playing,
|
||||
FullscreenPlaying,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
fn inhibits(self, visible: bool, fullscreen: bool, playing: bool) -> bool {
|
||||
visible
|
||||
&& match self {
|
||||
Self::None => false,
|
||||
Self::Visible => true,
|
||||
Self::Fullscreen => fullscreen,
|
||||
Self::Playing => playing,
|
||||
Self::FullscreenPlaying => fullscreen && playing,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct AppWindow<'a> {
|
||||
id: WindowId,
|
||||
pid: Option<u32>,
|
||||
app_id: &'a str,
|
||||
}
|
||||
|
||||
/// MPRIS reports application playback, not which of its windows owns a video.
|
||||
/// Prefer process credentials; use DesktopEntry only when no process matches.
|
||||
/// Multiple matching windows are ambiguous, including hidden windows.
|
||||
fn player_window(player: &media::Player, windows: &[AppWindow<'_>]) -> Option<WindowId> {
|
||||
let mut matches = windows
|
||||
.iter()
|
||||
.filter(|window| player.pid.is_some() && window.pid == player.pid);
|
||||
if let Some(window) = matches.next() {
|
||||
return matches.next().is_none().then_some(window.id);
|
||||
}
|
||||
|
||||
let desktop = player.desktop_entry.as_deref()?;
|
||||
let mut matches = windows
|
||||
.iter()
|
||||
.filter(|window| crate::window_rules::normalize_app_id(window.app_id) == desktop);
|
||||
let window = matches.next()?;
|
||||
matches.next().is_none().then_some(window.id)
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
pub(crate) fn refresh_idle_inhibition(&mut self) {
|
||||
let playing = self.media_players.iter().any(|player| player.playing);
|
||||
let has_rules = self
|
||||
.window_rules
|
||||
.iter()
|
||||
.any(|rule| rule.idle_policy().is_some());
|
||||
let selected = self
|
||||
.window_rules
|
||||
.iter()
|
||||
.any(|rule| rule.idle_policy().is_some_and(|mode| mode != Mode::None));
|
||||
let playback_policy = self.idle_inhibit.fullscreen_playback
|
||||
|| self.window_rules.iter().any(|rule| {
|
||||
matches!(
|
||||
rule.idle_policy(),
|
||||
Some(Mode::Playing | Mode::FullscreenPlaying)
|
||||
)
|
||||
});
|
||||
let automatic = (playback_policy && playing) || selected;
|
||||
let eligible = if self.idle_inhibitors.is_empty() && !automatic {
|
||||
Default::default()
|
||||
} else {
|
||||
self.callback_outputs()
|
||||
};
|
||||
|
||||
let application = if playback_policy && playing {
|
||||
self.windows
|
||||
.records()
|
||||
.map(|(&id, record)| {
|
||||
let pid = record
|
||||
.surface
|
||||
.as_ref()
|
||||
.and_then(|surface| surface.client())
|
||||
.and_then(|client| client.get_credentials(&self.display_handle).ok())
|
||||
.and_then(|credentials| u32::try_from(credentials.pid).ok());
|
||||
AppWindow {
|
||||
id,
|
||||
pid,
|
||||
app_id: &record.app_id,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let playing_windows = self
|
||||
.media_players
|
||||
.iter()
|
||||
.filter(|player| player.playing)
|
||||
.filter_map(|player| player_window(player, &application))
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let automatic = automatic
|
||||
&& !self.session_lock.active()
|
||||
&& self.windows.records().any(|(&id, record)| {
|
||||
let Some(surface) = &record.surface else {
|
||||
return false;
|
||||
};
|
||||
if !eligible.contains_key(&surface.into()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let explicit = has_rules
|
||||
.then(|| {
|
||||
let transient = with_states(surface, |states| {
|
||||
states
|
||||
.data_map
|
||||
.get::<XdgToplevelSurfaceData>()
|
||||
.is_some_and(|data| data.lock().expect("Smithay surface metadata must remain consistent across callbacks").parent.is_some())
|
||||
});
|
||||
crate::window_rules::resolve(
|
||||
&self.window_rules,
|
||||
Some(&record.app_id),
|
||||
Some(&record.title),
|
||||
transient,
|
||||
)
|
||||
.idle_inhibit
|
||||
})
|
||||
.flatten();
|
||||
let mode = explicit.unwrap_or(if self.idle_inhibit.fullscreen_playback {
|
||||
Mode::FullscreenPlaying
|
||||
} else {
|
||||
Mode::None
|
||||
});
|
||||
let fullscreen = self
|
||||
.workspaces
|
||||
.workspace_for_window(id)
|
||||
.and_then(|workspace| self.workspaces.workspace(workspace))
|
||||
.is_some_and(|workspace| workspace.fullscreen == Some(id));
|
||||
|
||||
mode.inhibits(true, fullscreen, playing_windows.contains(&id))
|
||||
});
|
||||
let requested = self
|
||||
.idle_inhibitors
|
||||
.keys()
|
||||
.any(|surface| eligible.contains_key(&surface.into()));
|
||||
self.automatic_idle_inhibited = automatic;
|
||||
self.idle_notifier_state
|
||||
.set_is_inhibited(automatic || requested || self.portal_session.idle_inhibited());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn policy_releases_on_pause_hidden_window_and_fullscreen_exit() {
|
||||
assert!(Mode::FullscreenPlaying.inhibits(true, true, true));
|
||||
assert!(!Mode::FullscreenPlaying.inhibits(true, true, false));
|
||||
assert!(!Mode::FullscreenPlaying.inhibits(false, true, true));
|
||||
assert!(!Mode::FullscreenPlaying.inhibits(true, false, true));
|
||||
assert!(Mode::Visible.inhibits(true, false, false));
|
||||
assert!(!Mode::Visible.inhibits(false, true, true));
|
||||
assert!(Mode::Playing.inhibits(true, false, true));
|
||||
assert!(!Mode::Playing.inhibits(true, false, false));
|
||||
assert!(Mode::Fullscreen.inhibits(true, true, false));
|
||||
assert!(!Mode::None.inhibits(true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_validates_modes_and_keeps_idle_rules_out_of_placement() {
|
||||
let config = crate::config::Config::parse_source(
|
||||
"idle-inhibit { fullscreen-playback #false; }\nwindow-rule app-id=\"player\" idle-inhibit=\"visible\"\nwindow-rule app-id=\"player\" title=\"Private\" idle-inhibit=\"none\"\n",
|
||||
).unwrap().runtime_config().unwrap();
|
||||
assert!(!config.idle_inhibit.fullscreen_playback);
|
||||
let visible =
|
||||
crate::window_rules::resolve(&config.window_rules, Some("player"), None, false);
|
||||
assert_eq!(visible.idle_inhibit, Some(Mode::Visible));
|
||||
let none = crate::window_rules::resolve(
|
||||
&config.window_rules,
|
||||
Some("player"),
|
||||
Some("Private"),
|
||||
false,
|
||||
);
|
||||
assert_eq!(none.idle_inhibit, Some(Mode::None));
|
||||
assert_eq!(crate::window_rules::live_result(visible, none, false), None);
|
||||
for mode in ["playing", "fullscreen", "fullscreen-playing", "none"] {
|
||||
assert!(
|
||||
crate::config::Config::parse_source(&format!(
|
||||
"window-rule app-id=\"player\" idle-inhibit=\"{mode}\"\n"
|
||||
))
|
||||
.unwrap()
|
||||
.runtime_config()
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
crate::config::Config::parse_source(
|
||||
"window-rule app-id=\"player\" idle-inhibit=\"alwayss\"\n"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
crate::config::Config::parse_source("idle-inhibit { fullscreen-playback \"yes\"; }\n")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_identity_prefers_pid_and_rejects_ambiguous_or_closed_windows() {
|
||||
let player = media::Player {
|
||||
name: "org.mpris.MediaPlayer2.test".into(),
|
||||
owner: ":1.5".into(),
|
||||
pid: Some(42),
|
||||
desktop_entry: Some("player".into()),
|
||||
playing: true,
|
||||
..Default::default()
|
||||
};
|
||||
let window = AppWindow {
|
||||
id: WindowId(1),
|
||||
pid: Some(42),
|
||||
app_id: "other",
|
||||
};
|
||||
let unrelated = AppWindow {
|
||||
id: WindowId(2),
|
||||
pid: Some(99),
|
||||
app_id: "Player.desktop",
|
||||
};
|
||||
assert_eq!(
|
||||
player_window(&player, &[window, unrelated]),
|
||||
Some(window.id)
|
||||
);
|
||||
assert_eq!(player_window(&player, &[unrelated]), Some(unrelated.id));
|
||||
assert_eq!(player_window(&player, &[]), None);
|
||||
assert_eq!(
|
||||
player_window(
|
||||
&player,
|
||||
&[
|
||||
window,
|
||||
AppWindow {
|
||||
id: WindowId(3),
|
||||
..window
|
||||
}
|
||||
]
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
player_window(
|
||||
&player,
|
||||
&[
|
||||
unrelated,
|
||||
AppWindow {
|
||||
id: WindowId(4),
|
||||
..unrelated
|
||||
}
|
||||
]
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
mod bus;
|
||||
mod policy;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
|
||||
use calloop::EventLoop;
|
||||
use calloop::channel::{Event, sync_channel};
|
||||
use ferese_ipc::Response;
|
||||
use ferese_ipc::media::Snapshot;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::Ferese;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct Player {
|
||||
pub name: String,
|
||||
pub owner: String,
|
||||
pub pid: Option<u32>,
|
||||
pub desktop_entry: Option<String>,
|
||||
pub playing: bool,
|
||||
pub view: ferese_ipc::media::Player,
|
||||
pub(super) started: u64,
|
||||
pub(super) active: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct Command {
|
||||
args: Value,
|
||||
sent: Instant,
|
||||
reply: Option<(u64, mpsc::SyncSender<Response>)>,
|
||||
}
|
||||
|
||||
struct Update {
|
||||
players: Vec<Player>,
|
||||
snapshot: Snapshot,
|
||||
}
|
||||
|
||||
fn inhibition_changed(before: &[Player], after: &[Player]) -> bool {
|
||||
before.len() != after.len()
|
||||
|| before.iter().zip(after).any(|(a, b)| {
|
||||
(&a.name, &a.owner, a.pid, &a.desktop_entry, a.playing)
|
||||
!= (&b.name, &b.owner, b.pid, &b.desktop_entry, b.playing)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Engine {
|
||||
snapshot: Snapshot,
|
||||
value: Value,
|
||||
waiters: HashMap<u64, (u64, mpsc::SyncSender<Response>)>,
|
||||
locked: bool,
|
||||
commands: Option<async_channel::Sender<Command>>,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub(crate) fn set_locked(&mut self, locked: bool) {
|
||||
self.locked = locked;
|
||||
if locked {
|
||||
for (_, (id, response)) in self.waiters.drain() {
|
||||
let _ =
|
||||
response.try_send(Response::error(id, "session_locked", "Session is locked"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn value(&self) -> Value {
|
||||
if self.value.is_null() {
|
||||
serde_json::to_value(&self.snapshot).expect("serializable media snapshot")
|
||||
} else {
|
||||
self.value.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, mut snapshot: Snapshot) {
|
||||
snapshot.revision = self.snapshot.revision;
|
||||
if snapshot == self.snapshot {
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot.revision = snapshot.revision.wrapping_add(1);
|
||||
self.value = serde_json::to_value(&snapshot).expect("serializable media snapshot");
|
||||
self.snapshot = snapshot;
|
||||
for (_, (id, response)) in self.waiters.drain() {
|
||||
let _ = response.try_send(Response::success(id, self.value.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn watch(
|
||||
&mut self,
|
||||
owner: u64,
|
||||
id: u64,
|
||||
since: u64,
|
||||
response: mpsc::SyncSender<Response>,
|
||||
) {
|
||||
if self.locked {
|
||||
let _ = response.try_send(Response::error(id, "session_locked", "Session is locked"));
|
||||
} else if since != self.snapshot.revision {
|
||||
let _ = response.try_send(Response::success(id, self.value()));
|
||||
} else {
|
||||
self.waiters.insert(owner, (id, response));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove(&mut self, owner: u64) {
|
||||
self.waiters.remove(&owner);
|
||||
}
|
||||
|
||||
pub(crate) fn action(
|
||||
&self,
|
||||
args: Value,
|
||||
reply: Option<(u64, mpsc::SyncSender<Response>)>,
|
||||
) -> Result<(), String> {
|
||||
let commands = self
|
||||
.commands
|
||||
.as_ref()
|
||||
.ok_or("Media service is unavailable")?;
|
||||
commands
|
||||
.try_send(Command {
|
||||
args,
|
||||
sent: Instant::now(),
|
||||
reply,
|
||||
})
|
||||
.map_err(|_| "Media command queue is unavailable or full".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn init(event_loop: &mut EventLoop<Ferese>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (sender, receiver) = sync_channel::<Update>(1);
|
||||
let (commands, receive_commands) = async_channel::bounded(16);
|
||||
event_loop
|
||||
.handle()
|
||||
.insert_source(receiver, move |event, _, state| {
|
||||
if let Event::Msg(update) = event {
|
||||
let changed = inhibition_changed(&state.media_players, &update.players);
|
||||
state.media_players = update.players;
|
||||
state.media_engine.update(update.snapshot);
|
||||
if changed {
|
||||
state.refresh_idle_inhibition();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
event_loop
|
||||
.handle()
|
||||
.insert_idle(move |state| state.media_engine.commands = Some(commands));
|
||||
std::thread::Builder::new()
|
||||
.name("ferese-playback".into())
|
||||
.spawn(move || bus::run(sender, receive_commands))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,542 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use calloop::channel::SyncSender;
|
||||
use ferese_ipc::Response;
|
||||
use ferese_ipc::media::{Playback, now_us};
|
||||
use futures_lite::{StreamExt, future, stream};
|
||||
use zbus::zvariant::{Array, OwnedObjectPath, OwnedValue, Value};
|
||||
use zbus::{Connection, MatchRule, MessageStream};
|
||||
|
||||
use super::policy::PlayersState;
|
||||
use super::{Command, Player, Update};
|
||||
|
||||
const PREFIX: &str = "org.mpris.MediaPlayer2";
|
||||
const PATH: &str = "/org/mpris/MediaPlayer2";
|
||||
const PLAYER: &str = "org.mpris.MediaPlayer2.Player";
|
||||
const PROPERTIES: &str = "org.freedesktop.DBus.Properties";
|
||||
const MAX_PLAYERS: usize = 64;
|
||||
type Properties = HashMap<String, OwnedValue>;
|
||||
|
||||
async fn bus_call<T: serde::de::DeserializeOwned + zbus::zvariant::Type>(
|
||||
connection: &Connection,
|
||||
member: &str,
|
||||
name: &str,
|
||||
) -> zbus::Result<T> {
|
||||
connection
|
||||
.call_method(
|
||||
Some("org.freedesktop.DBus"),
|
||||
"/org/freedesktop/DBus",
|
||||
Some("org.freedesktop.DBus"),
|
||||
member,
|
||||
&(name,),
|
||||
)
|
||||
.await?
|
||||
.body()
|
||||
.deserialize()
|
||||
}
|
||||
|
||||
async fn properties(
|
||||
connection: &Connection,
|
||||
owner: &str,
|
||||
interface: &str,
|
||||
) -> zbus::Result<Properties> {
|
||||
connection
|
||||
.call_method(Some(owner), PATH, Some(PROPERTIES), "GetAll", &(interface,))
|
||||
.await?
|
||||
.body()
|
||||
.deserialize()
|
||||
}
|
||||
|
||||
fn string<'a>(properties: &'a Properties, name: &str) -> Option<&'a str> {
|
||||
properties
|
||||
.get(name)
|
||||
.and_then(|value| <&str>::try_from(value).ok())
|
||||
}
|
||||
|
||||
pub(super) fn clean(value: &str, length: usize) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(length)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn boolean(properties: &Properties, name: &str) -> bool {
|
||||
properties
|
||||
.get(name)
|
||||
.and_then(|value| bool::try_from(value).ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn integer(properties: &Properties, name: &str) -> Option<u64> {
|
||||
properties
|
||||
.get(name)
|
||||
.and_then(|value| i64::try_from(value).ok())
|
||||
.and_then(|value| value.try_into().ok())
|
||||
}
|
||||
|
||||
fn number(properties: &Properties, name: &str) -> Option<f64> {
|
||||
properties
|
||||
.get(name)
|
||||
.and_then(|value| f64::try_from(value).ok())
|
||||
.filter(|value| value.is_finite())
|
||||
}
|
||||
|
||||
async fn refresh(connection: &Connection, player: &mut Player, identity: bool) -> zbus::Result<()> {
|
||||
let mut fields = properties(connection, &player.owner, PLAYER).await?;
|
||||
let view = &mut player.view;
|
||||
view.status = match string(&fields, "PlaybackStatus") {
|
||||
Some("Playing") => Playback::Playing,
|
||||
Some("Paused") => Playback::Paused,
|
||||
_ => Playback::Stopped,
|
||||
};
|
||||
player.playing = view.status == Playback::Playing;
|
||||
view.position_us = integer(&fields, "Position");
|
||||
view.sampled_at_us = now_us();
|
||||
view.rate = number(&fields, "Rate")
|
||||
.filter(|rate| *rate != 0.0 && rate.abs() <= 100.0)
|
||||
.unwrap_or(1.0);
|
||||
view.volume = number(&fields, "Volume").filter(|volume| *volume >= 0.0);
|
||||
view.can_control = boolean(&fields, "CanControl");
|
||||
view.can_play = boolean(&fields, "CanPlay");
|
||||
view.can_pause = boolean(&fields, "CanPause");
|
||||
view.can_next = boolean(&fields, "CanGoNext");
|
||||
view.can_previous = boolean(&fields, "CanGoPrevious");
|
||||
view.can_seek = boolean(&fields, "CanSeek");
|
||||
let metadata = fields
|
||||
.remove("Metadata")
|
||||
.and_then(|value| Properties::try_from(value).ok())
|
||||
.unwrap_or_default();
|
||||
view.title = clean(string(&metadata, "xesam:title").unwrap_or_default(), 256);
|
||||
view.album = clean(string(&metadata, "xesam:album").unwrap_or_default(), 256);
|
||||
view.artist = metadata
|
||||
.get("xesam:artist")
|
||||
.and_then(|value| <&Array>::try_from(value).ok())
|
||||
.map(|array| {
|
||||
array
|
||||
.inner()
|
||||
.iter()
|
||||
.take(8)
|
||||
.filter_map(|value| <&str>::try_from(value).ok())
|
||||
.map(|artist| clean(artist, 64))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
view.length_us = integer(&metadata, "mpris:length").filter(|length| *length > 0);
|
||||
view.track_id = metadata
|
||||
.get("mpris:trackid")
|
||||
.and_then(|value| <&zbus::zvariant::ObjectPath>::try_from(value).ok())
|
||||
.map(|path| path.to_string());
|
||||
view.art_url = string(&metadata, "mpris:artUrl")
|
||||
.filter(|url| !url.is_empty() && url.len() <= 256 * 1024)
|
||||
.map(str::to_owned);
|
||||
|
||||
if identity || player.pid.is_none() {
|
||||
player.pid = bus_call(connection, "GetConnectionUnixProcessID", &player.owner)
|
||||
.await
|
||||
.ok();
|
||||
if let Ok(root) = properties(connection, &player.owner, PREFIX).await {
|
||||
view.identity = clean(string(&root, "Identity").unwrap_or(&player.name), 80);
|
||||
view.can_raise = boolean(&root, "CanRaise");
|
||||
player.desktop_entry = string(&root, "DesktopEntry")
|
||||
.map(crate::window_rules::normalize_app_id)
|
||||
.filter(|value| !value.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn discover(connection: &Connection, name: String, owner: String) -> Player {
|
||||
let mut player = Player {
|
||||
view: ferese_ipc::media::Player {
|
||||
name: name.clone(),
|
||||
owner: owner.clone(),
|
||||
identity: clean(&name, 80),
|
||||
..Default::default()
|
||||
},
|
||||
name,
|
||||
owner,
|
||||
..Default::default()
|
||||
};
|
||||
let _ = refresh(connection, &mut player, true).await;
|
||||
player
|
||||
}
|
||||
|
||||
async fn action(
|
||||
connection: &Connection,
|
||||
state: &mut PlayersState,
|
||||
args: &serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
let kind = args["action"].as_str().ok_or("Missing media action")?;
|
||||
if kind == "auto" {
|
||||
state.pinned = None;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let player = match args["player"].as_str() {
|
||||
Some(name) => state.players.get(name),
|
||||
None => state.selected(),
|
||||
}
|
||||
.cloned()
|
||||
.ok_or("No active media player")?;
|
||||
if args["owner"]
|
||||
.as_str()
|
||||
.is_some_and(|owner| owner != player.owner)
|
||||
{
|
||||
return Err("Media player was replaced".into());
|
||||
}
|
||||
|
||||
match kind {
|
||||
"pin" => {
|
||||
state.ignored.remove(&player.name);
|
||||
state.pinned = Some(player.name);
|
||||
return Ok(());
|
||||
}
|
||||
"ignore" => {
|
||||
let ignored = args["ignored"].as_bool().ok_or("Missing ignore state")?;
|
||||
if ignored {
|
||||
state.ignored.insert(player.name.clone());
|
||||
} else {
|
||||
state.ignored.remove(&player.name);
|
||||
}
|
||||
if ignored && state.pinned.as_deref() == Some(&player.name) {
|
||||
state.pinned = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
let view = &player.view;
|
||||
let supported = match kind {
|
||||
"play-pause" => view.can_toggle(),
|
||||
"next" => view.can_control && view.can_next,
|
||||
"previous" => view.can_control && view.can_previous,
|
||||
"raise" => view.can_raise,
|
||||
"seek" => view.can_control && view.can_seek && view.track_id.is_some(),
|
||||
"volume" => view.can_control && view.volume.is_some(),
|
||||
_ => false,
|
||||
};
|
||||
if !supported {
|
||||
return Err("Media action is unavailable for this player".into());
|
||||
}
|
||||
|
||||
let method = match kind {
|
||||
"play-pause" => "PlayPause",
|
||||
"next" => "Next",
|
||||
"previous" => "Previous",
|
||||
"raise" => "Raise",
|
||||
_ => "",
|
||||
};
|
||||
let response = match kind {
|
||||
"seek" => {
|
||||
if args["track_id"].as_str() != view.track_id.as_deref() {
|
||||
return Err("Media track changed".into());
|
||||
}
|
||||
let position = args["position_us"]
|
||||
.as_u64()
|
||||
.ok_or("Invalid media position")?;
|
||||
let position = view
|
||||
.length_us
|
||||
.map_or(position, |length| position.min(length))
|
||||
.min(i64::MAX as u64) as i64;
|
||||
let track = OwnedObjectPath::try_from(
|
||||
view.track_id
|
||||
.clone()
|
||||
.expect("media action validation requires an active track"),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
connection
|
||||
.call_method(
|
||||
Some(player.owner.as_str()),
|
||||
PATH,
|
||||
Some(PLAYER),
|
||||
"SetPosition",
|
||||
&(track, position),
|
||||
)
|
||||
.await
|
||||
}
|
||||
"volume" => {
|
||||
let delta = args["delta"]
|
||||
.as_f64()
|
||||
.filter(|delta| delta.is_finite() && delta.abs() <= 1.0)
|
||||
.ok_or("Invalid volume change")?;
|
||||
let volume = (view
|
||||
.volume
|
||||
.expect("relative volume validation requires the current volume")
|
||||
+ delta)
|
||||
.clamp(0.0, 1.0);
|
||||
connection
|
||||
.call_method(
|
||||
Some(player.owner.as_str()),
|
||||
PATH,
|
||||
Some(PROPERTIES),
|
||||
"Set",
|
||||
&(PLAYER, "Volume", Value::F64(volume)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
connection
|
||||
.call_method(
|
||||
Some(player.owner.as_str()),
|
||||
PATH,
|
||||
Some(if kind == "raise" { PREFIX } else { PLAYER }),
|
||||
method,
|
||||
&(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
response.map_err(|error| error.to_string())?;
|
||||
let mut player = player;
|
||||
if refresh(connection, &mut player, false).await.is_ok() {
|
||||
state.update(player);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn publish(
|
||||
state: &PlayersState,
|
||||
previous: &mut Option<Update>,
|
||||
sender: &SyncSender<Update>,
|
||||
) -> bool {
|
||||
let update = state.update_snapshot();
|
||||
if previous
|
||||
.as_ref()
|
||||
.is_some_and(|old| old.players == update.players && old.snapshot == update.snapshot)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
*previous = Some(Update {
|
||||
players: update.players.clone(),
|
||||
snapshot: update.snapshot.clone(),
|
||||
});
|
||||
sender.send(update).is_ok()
|
||||
}
|
||||
|
||||
async fn monitor(
|
||||
connection: Connection,
|
||||
commands: &async_channel::Receiver<Command>,
|
||||
sender: &SyncSender<Update>,
|
||||
) -> zbus::Result<()> {
|
||||
let properties = MatchRule::builder()
|
||||
.msg_type(zbus::message::Type::Signal)
|
||||
.path(PATH)?
|
||||
.interface(PROPERTIES)?
|
||||
.member("PropertiesChanged")?
|
||||
.build();
|
||||
let owners = MatchRule::builder()
|
||||
.msg_type(zbus::message::Type::Signal)
|
||||
.sender("org.freedesktop.DBus")?
|
||||
.interface("org.freedesktop.DBus")?
|
||||
.member("NameOwnerChanged")?
|
||||
.arg0ns(PREFIX)?
|
||||
.build();
|
||||
let seeks = MatchRule::builder()
|
||||
.msg_type(zbus::message::Type::Signal)
|
||||
.path(PATH)?
|
||||
.interface(PLAYER)?
|
||||
.member("Seeked")?
|
||||
.build();
|
||||
let properties = MessageStream::for_match_rule(properties, &connection, Some(128)).await?;
|
||||
let owners = MessageStream::for_match_rule(owners, &connection, Some(128)).await?;
|
||||
let seeks = MessageStream::for_match_rule(seeks, &connection, Some(64)).await?;
|
||||
let mut signals = stream::or(stream::or(properties, owners), seeks);
|
||||
let mut state = PlayersState::default();
|
||||
let names: Vec<String> = connection
|
||||
.call_method(
|
||||
Some("org.freedesktop.DBus"),
|
||||
"/org/freedesktop/DBus",
|
||||
Some("org.freedesktop.DBus"),
|
||||
"ListNames",
|
||||
&(),
|
||||
)
|
||||
.await?
|
||||
.body()
|
||||
.deserialize()?;
|
||||
let mut names: Vec<_> = names
|
||||
.into_iter()
|
||||
.filter(|name| {
|
||||
name.strip_prefix(PREFIX)
|
||||
.is_some_and(|suffix| suffix.starts_with('.'))
|
||||
})
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
for name in names.into_iter().take(MAX_PLAYERS) {
|
||||
if let Ok(owner) = bus_call(&connection, "GetNameOwner", &name).await {
|
||||
let player = discover(&connection, name.clone(), owner).await;
|
||||
// Existing players have no known start order; bus names break ties.
|
||||
state.players.insert(name, player);
|
||||
}
|
||||
}
|
||||
|
||||
let mut previous = None;
|
||||
if !publish(&state, &mut previous, sender) {
|
||||
return Ok(());
|
||||
}
|
||||
enum Input {
|
||||
Signal(Option<zbus::Result<zbus::Message>>),
|
||||
Action(Option<Command>),
|
||||
}
|
||||
loop {
|
||||
let input = match commands.try_recv() {
|
||||
Ok(command) => Input::Action(Some(command)),
|
||||
Err(_) => {
|
||||
future::or(async { Input::Signal(signals.next().await) }, async {
|
||||
Input::Action(commands.recv().await.ok())
|
||||
})
|
||||
.await
|
||||
}
|
||||
};
|
||||
match input {
|
||||
Input::Action(None) => return Ok(()),
|
||||
Input::Signal(None) => {
|
||||
return Err(zbus::Error::Failure("MPRIS signal stream ended".into()));
|
||||
}
|
||||
Input::Action(Some(command)) => {
|
||||
let result = if command.sent.elapsed() > Duration::from_secs(2) {
|
||||
Err("Media command expired".into())
|
||||
} else {
|
||||
action(&connection, &mut state, &command.args).await
|
||||
};
|
||||
if let Some((id, response)) = command.reply {
|
||||
let reply = match result {
|
||||
Ok(()) => Response::success(id, serde_json::json!({})),
|
||||
Err(error) => Response::error(id, "media_unavailable", error),
|
||||
};
|
||||
let _ = response.try_send(reply);
|
||||
}
|
||||
}
|
||||
Input::Signal(Some(message)) => {
|
||||
let message = message?;
|
||||
let header = message.header();
|
||||
if header
|
||||
.member()
|
||||
.is_some_and(|member| member.as_str() == "NameOwnerChanged")
|
||||
{
|
||||
let (name, _, owner): (String, String, String) =
|
||||
message.body().deserialize()?;
|
||||
if !name
|
||||
.strip_prefix(PREFIX)
|
||||
.is_some_and(|suffix| suffix.starts_with('.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
state.remove(&name);
|
||||
if !publish(&state, &mut previous, sender) {
|
||||
return Ok(());
|
||||
}
|
||||
if !owner.is_empty() && state.players.len() < MAX_PLAYERS {
|
||||
let player = discover(&connection, name, owner).await;
|
||||
state.update(player);
|
||||
}
|
||||
} else if let Some(owner) = header.sender() {
|
||||
if header
|
||||
.member()
|
||||
.is_some_and(|member| member.as_str() == "Seeked")
|
||||
{
|
||||
let names: Vec<_> = state
|
||||
.players
|
||||
.values()
|
||||
.filter(|player| player.owner == owner.as_str())
|
||||
.map(|player| player.name.clone())
|
||||
.collect();
|
||||
for name in names {
|
||||
let mut player = state.players[&name].clone();
|
||||
if refresh(&connection, &mut player, false).await.is_ok() {
|
||||
state.update(player);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let Ok((interface, changed, invalidated)) =
|
||||
message
|
||||
.body()
|
||||
.deserialize::<(String, Properties, Vec<String>)>()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let identity = interface == PREFIX
|
||||
&& ["Identity", "DesktopEntry", "CanRaise"].iter().any(|key| {
|
||||
changed.contains_key(*key)
|
||||
|| invalidated.iter().any(|field| field == key)
|
||||
});
|
||||
let playback = interface == PLAYER
|
||||
&& [
|
||||
"PlaybackStatus",
|
||||
"Metadata",
|
||||
"Rate",
|
||||
"Volume",
|
||||
"CanGoNext",
|
||||
"CanGoPrevious",
|
||||
"CanPlay",
|
||||
"CanPause",
|
||||
"CanSeek",
|
||||
"CanControl",
|
||||
]
|
||||
.iter()
|
||||
.any(|key| {
|
||||
changed.contains_key(*key)
|
||||
|| invalidated.iter().any(|field| field == key)
|
||||
});
|
||||
if !identity && !playback {
|
||||
continue;
|
||||
}
|
||||
let names: Vec<_> = state
|
||||
.players
|
||||
.values()
|
||||
.filter(|player| player.owner == owner.as_str())
|
||||
.map(|player| player.name.clone())
|
||||
.collect();
|
||||
for name in names {
|
||||
let mut player = state.players[&name].clone();
|
||||
// Re-read after a signal so queued startup messages cannot regress state.
|
||||
if refresh(&connection, &mut player, identity).await.is_ok() {
|
||||
state.update(player);
|
||||
} else {
|
||||
player.playing = false;
|
||||
player.view.status = Playback::Stopped;
|
||||
state.update(player);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !publish(&state, &mut previous, sender) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run(sender: SyncSender<Update>, commands: async_channel::Receiver<Command>) {
|
||||
loop {
|
||||
let result = future::block_on(async {
|
||||
let connection = zbus::connection::Builder::session()?
|
||||
.method_timeout(Duration::from_millis(500))
|
||||
.build()
|
||||
.await?;
|
||||
monitor(connection, &commands, &sender).await
|
||||
});
|
||||
if matches!(result, Ok(())) || commands.is_closed() {
|
||||
break;
|
||||
}
|
||||
if sender
|
||||
.send(PlayersState::default().update_snapshot())
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
tracing::debug!(
|
||||
?result,
|
||||
"MPRIS disconnected; clearing playback and retrying"
|
||||
);
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use ferese_ipc::media::{Choice, Playback, Snapshot};
|
||||
|
||||
use super::{Player, Update};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct PlayersState {
|
||||
pub players: BTreeMap<String, Player>,
|
||||
pub pinned: Option<String>,
|
||||
pub ignored: HashSet<String>,
|
||||
activity: u64,
|
||||
}
|
||||
|
||||
impl PlayersState {
|
||||
pub fn update(&mut self, mut player: Player) {
|
||||
if let Some(old) = self.players.get(&player.name) {
|
||||
player.started = old.started;
|
||||
player.active = old.active;
|
||||
if player.view.status != old.view.status
|
||||
|| player.view.track_id != old.view.track_id
|
||||
|| player.view.title != old.view.title
|
||||
|| player.view.artist != old.view.artist
|
||||
{
|
||||
self.activity += 1;
|
||||
player.active = self.activity;
|
||||
if player.playing && !old.playing {
|
||||
player.started = self.activity;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.activity += 1;
|
||||
player.active = self.activity;
|
||||
if player.playing {
|
||||
player.started = self.activity;
|
||||
}
|
||||
}
|
||||
self.players.insert(player.name.clone(), player);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, name: &str) {
|
||||
self.players.remove(name);
|
||||
self.ignored.remove(name);
|
||||
if self.pinned.as_deref() == Some(name) {
|
||||
self.pinned = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected(&self) -> Option<&Player> {
|
||||
let eligible = |player: &&Player| {
|
||||
player.view.status != Playback::Stopped && !self.ignored.contains(&player.name)
|
||||
};
|
||||
if let Some(player) = self
|
||||
.pinned
|
||||
.as_ref()
|
||||
.and_then(|name| self.players.get(name))
|
||||
.filter(eligible)
|
||||
{
|
||||
return Some(player);
|
||||
}
|
||||
|
||||
self.players.values().filter(eligible).max_by(|a, b| {
|
||||
(a.playing, if a.playing { a.started } else { a.active })
|
||||
.cmp(&(b.playing, if b.playing { b.started } else { b.active }))
|
||||
.then_with(|| b.name.cmp(&a.name))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_snapshot(&self) -> Update {
|
||||
Update {
|
||||
players: self.players.values().cloned().collect(),
|
||||
snapshot: Snapshot {
|
||||
revision: 0,
|
||||
selected: self.selected().map(|player| player.view.clone()),
|
||||
pinned: self.pinned.clone(),
|
||||
players: self
|
||||
.players
|
||||
.values()
|
||||
.map(|player| Choice {
|
||||
name: player.name.clone(),
|
||||
owner: player.owner.clone(),
|
||||
identity: player.view.identity.clone(),
|
||||
status: player.view.status,
|
||||
ignored: self.ignored.contains(&player.name),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use super::*;
|
||||
use ferese_ipc::media::Playback;
|
||||
use policy::PlayersState;
|
||||
|
||||
fn player(name: &str, status: Playback) -> Player {
|
||||
Player {
|
||||
name: name.into(),
|
||||
owner: format!(":{name}"),
|
||||
playing: status == Playback::Playing,
|
||||
view: ferese_ipc::media::Player {
|
||||
name: name.into(),
|
||||
status,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_tracks_starts_not_metadata_or_volume_changes() {
|
||||
let mut state = PlayersState::default();
|
||||
state.update(player("first", Playback::Playing));
|
||||
state.update(player("second", Playback::Playing));
|
||||
let mut first = state.players["first"].clone();
|
||||
first.view.title = "New track".into();
|
||||
first.view.volume = Some(0.5);
|
||||
state.update(first);
|
||||
assert_eq!(state.selected().unwrap().name, "second");
|
||||
|
||||
state.update(player("second", Playback::Paused));
|
||||
assert_eq!(state.selected().unwrap().name, "first");
|
||||
state.update(player("first", Playback::Paused));
|
||||
assert_eq!(state.selected().unwrap().name, "first");
|
||||
state.update(player("second", Playback::Playing));
|
||||
assert_eq!(state.selected().unwrap().name, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_ignore_and_departure_do_not_leave_a_ghost_player() {
|
||||
let mut state = PlayersState::default();
|
||||
state.update(player("first", Playback::Paused));
|
||||
state.update(player("second", Playback::Playing));
|
||||
state.pinned = Some("first".into());
|
||||
assert_eq!(state.selected().unwrap().name, "first");
|
||||
state.ignored.insert("first".into());
|
||||
assert_eq!(state.selected().unwrap().name, "second");
|
||||
state.remove("first");
|
||||
assert!(state.pinned.is_none());
|
||||
assert!(state.ignored.is_empty());
|
||||
state.remove("second");
|
||||
assert!(state.selected().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_ties_are_deterministic_and_stopped_players_are_hidden() {
|
||||
let mut state = PlayersState::default();
|
||||
for name in ["z", "a"] {
|
||||
state
|
||||
.players
|
||||
.insert(name.into(), player(name, Playback::Playing));
|
||||
}
|
||||
assert_eq!(state.selected().unwrap().name, "a");
|
||||
state.update(player("a", Playback::Stopped));
|
||||
state.update(player("z", Playback::Stopped));
|
||||
assert!(state.selected().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_waits_for_changes_deduplicates_and_drops_disconnected_clients() {
|
||||
let mut engine = Engine::default();
|
||||
let (send, receive) = mpsc::sync_channel(1);
|
||||
engine.watch(1, 42, 0, send);
|
||||
engine.update(Snapshot::default());
|
||||
assert!(receive.try_recv().is_err());
|
||||
let mut state = PlayersState::default();
|
||||
state.update(player("first", Playback::Playing));
|
||||
engine.update(state.update_snapshot().snapshot);
|
||||
let response = receive.try_recv().unwrap();
|
||||
assert_eq!(response.id, 42);
|
||||
assert_eq!(response.result.unwrap()["revision"], 1);
|
||||
let (send, receive) = mpsc::sync_channel(1);
|
||||
engine.watch(2, 43, 1, send);
|
||||
engine.remove(2);
|
||||
state.remove("first");
|
||||
engine.update(state.update_snapshot().snapshot);
|
||||
assert!(receive.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_is_bounded_and_control_characters_are_removed() {
|
||||
assert_eq!(bus::clean(" Track\nname\0 ", 80), "Trackname");
|
||||
assert_eq!(bus::clean(&"é".repeat(500), 80).chars().count(), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_position_updates_do_not_recompute_idle_policy() {
|
||||
let before = player("first", Playback::Playing);
|
||||
let mut after = before.clone();
|
||||
after.view.title = "New track".into();
|
||||
after.view.position_us = Some(10);
|
||||
after.view.volume = Some(0.8);
|
||||
assert!(!inhibition_changed(
|
||||
std::slice::from_ref(&before),
|
||||
std::slice::from_ref(&after)
|
||||
));
|
||||
after.playing = false;
|
||||
assert!(inhibition_changed(
|
||||
std::slice::from_ref(&before),
|
||||
std::slice::from_ref(&after)
|
||||
));
|
||||
after.playing = true;
|
||||
after.pid = Some(42);
|
||||
assert!(inhibition_changed(
|
||||
std::slice::from_ref(&before),
|
||||
std::slice::from_ref(&after)
|
||||
));
|
||||
assert!(inhibition_changed(std::slice::from_ref(&before), &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_lock_cancels_pending_and_rejects_new_media_watches() {
|
||||
let mut engine = Engine::default();
|
||||
let (send, receive) = mpsc::sync_channel(1);
|
||||
engine.watch(1, 42, 0, send);
|
||||
engine.set_locked(true);
|
||||
let reply = receive.recv().unwrap();
|
||||
assert_eq!(reply.id, 42);
|
||||
assert!(reply.result.is_none());
|
||||
assert_eq!(reply.error.unwrap().code, "session_locked");
|
||||
engine.update(Snapshot {
|
||||
selected: Some(player("private", Playback::Playing).view),
|
||||
..Default::default()
|
||||
});
|
||||
for since in [0, engine.snapshot.revision] {
|
||||
let (send, receive) = mpsc::sync_channel(1);
|
||||
engine.watch(2, 43, since, send);
|
||||
assert_eq!(
|
||||
receive.recv().unwrap().error.unwrap().code,
|
||||
"session_locked"
|
||||
);
|
||||
}
|
||||
engine.set_locked(false);
|
||||
let (send, receive) = mpsc::sync_channel(1);
|
||||
engine.watch(3, 44, 0, send);
|
||||
assert!(receive.recv().unwrap().result.is_some());
|
||||
}
|
||||