Compare commits

...
87 Commits
Author SHA1 Message Date
aria 326f237262 删除 installed.txt 2026-10-30 03:50:14 +09:00
aria a9f212237f 上传文件至「/」 2026-10-30 03:49:42 +09:00
aria 1bacbb5d75 上传文件至「/」 2026-10-30 03:47:44 +09:00
aria 993bc0f88f Integrate native Rime and stabilize input, output, and rendering
- Add optional modifier-only Super binding whose tap is cancelled by
  other input, focus changes, grabs, locks, and session teardown.
- Ship the native ferese-rime user service, Rime defaults, installer
  inventory, package dependencies, session environment, and docs.
- Vendor Smithay 8a27023 with the SHM texture-cache format fix and add
  GPU regressions for format changes and resize snapshots.
- Release reused CRTC owners before staging output modes, then restore
  changed outputs before former owners when a transaction fails.
- Wait for resize-capture GPU completion, expand filtered surface
  damage, and preserve IME popup origin under fractional scaling.
- Mark weekends like holidays in both calendar layouts.
2026-10-30 03:13:43 +09:00
aria a2be421974 Add searchable input controls and display profile editing 2026-10-28 08:19:05 +09:00
aria 78ce1ed590 Use application artwork in the running window tray 2026-10-28 07:33:19 +09:00
aria 81b99709bb perf(settings): coalesce refreshes and cache resize previews 2026-10-28 06:56:03 +09:00
aria 6671960d5e Avoid cold theme preview rasterization and load settings schema asynchronously 2026-10-28 06:28:06 +09:00
aria 326465952a Consolidate desktop controls with editable holidays and a running-window tray 2026-10-28 06:02:46 +09:00
aria 9908dde9f8 feat(settings): refine density, group detailed options and add edge maximize gestures 2026-10-28 04:49:27 +09:00
aria d7e122711a docs: summarize Graphite visual specification in Chinese 2026-10-28 04:14:36 +09:00
aria 1e34ec47ad feat(ui): refine Graphite visuals and center navigation content 2026-10-28 04:09:10 +09:00
aria cc3b6f903f feat(settings): add grouped configuration hub, languages and tray controls 2026-10-28 03:37:22 +09:00
aria 6f34fd080f Recover Rime faults, persist window modes and fix GPU allocation 2026-10-28 02:32:19 +09:00
aria 8e9a5838ab Add isolated native Rime service and MiSans candidate UI 2026-10-28 00:58:38 +09:00
Samuel Onoja f21167e1a2 Prepare stable 0.1.0 release 2026-10-25 16:35:51 +01:00
Samuel Onoja e03d491353 Add configurable capture and OBS status to the panel 2026-10-25 16:35:51 +01:00
Samuel Onoja 3d08d64c41 Preserve shortcut focus during mouse movement 2026-10-25 16:35:27 +01:00
Samuel Onoja 392e9cfeb0 Avoid capture busy-waiting and redundant config and DMA-BUF checks 2026-10-25 15:37:52 +01:00
Samuel Onoja 979afbd213 Reduce idle CPU use and prioritize compositor rendering (#62)
Avoid background title updates when no shell window list is visible, and skip output and capture invalidation for titles outside compositor decorations. Preserve focused titles, fresh window lists and capture privacy.

Request high EGL context priority with fallback, cover rendering and native title behavior in CI, and keep audio recording flowing while video capture is idle.
2026-10-25 14:54:50 +01:00
Samuel Onoja b66b19e4d9 Skip redundant stream capture clear (#65)
Remove the full-buffer clear before opaque stream transfers and disable blending for the destination region. Cover both shader modes, all transforms, and reused targets.

Fixes #61.
2026-10-25 14:30:30 +01:00
Samuel Onoja ad00e55fe4 Add mouse window controls and finer display scaling (#63)
Add configurable mouse and wheel bindings, window move and resize, tab drops, cross-output dragging, and device settings. Adjust display scaling in 0.05 increments.

Preserve opposite-edge positioning during scrolling resizes and show rows for edge drops into tabbed columns.
2026-10-25 14:14:35 +01:00
Samuel Onoja ab11269246 Add workspace screen sharing (#64)
Add workspace selection to the screen-sharing picker with pinned capture, privacy filtering and bounded capture resources. Refresh captured tab labels when title or app-ID metadata changes.
2026-10-25 13:59:34 +01:00
Samuel Onoja e64ab52190 perf(compositor): prioritize the event loop without promoting workers
Request modest negative nice priority through RTKit off the event loop. Set reset-on-fork before requesting priority so workers and launched clients retain normal scheduling. Respect explicit scheduling choices and continue when priority cannot be confirmed.
2026-10-24 22:44:06 +01:00
Samuel Onoja 417f78aa94 perf(compositor): account for late frame dispatch
Include observed timer dispatch delay in the render budget, capped at one refresh and decayed by elapsed time. Preserve the scheduled presentation target and output-local state. Existing scheduling assertions remain intact; a negative control verifies the new deadline regression.
2026-10-24 22:44:06 +01:00
Samuel Onoja f5fff6d930 fix(capture): preserve rounded fractional buffer dimensions 2026-10-24 22:03:23 +01:00
Samuel Onoja ad1207b417 test(settings): detect muted text against its rendered background
The previous brightness cutoff rejected visible muted glyphs. Compare against the local background and resolved text colors, retaining the minimum ink count. Verified against the previous toolkit and with a hidden-label negative control.
2026-10-24 22:03:23 +01:00
Samuel Onoja 6509cb714a fix(panel): snap Morph dimensions before centering 2026-10-24 22:03:23 +01:00
Samuel Onoja 910f7cc188 perf(shell): prefer GPU rendering and throttle allocator trimming 2026-10-24 22:03:23 +01:00
Samuel Onoja e9f36efdb6 perf(ipc): cache unchanged output event payloads 2026-10-24 22:03:23 +01:00
Samuel Onoja 835aadc3eb docs(capture): document stream behavior and performance checks 2026-10-24 19:59:24 +01:00
Samuel Onoja 533cd74c94 perf(capture): add SceneLens GPU streams and retained scene reuse
Use persistent privacy-filtered capture targets and client-owned GPU buffers with fence completion, backpressure, negotiated frame rates and shared-memory fallback.

Invalidate output views on scene changes while preserving inactive-window callbacks and capture revocation. Include OBS memory-mask negotiation and native pixel, transport and recording regressions.
2026-10-24 19:58:54 +01:00
Samuel Onoja f49f62583d perf(ipc): reuse theme snapshots and typed window records 2026-10-24 19:56:57 +01:00
Samuel Onoja a8ef3967a1 test(input): cover launcher touch dismissal and contact lifetimes
Verify outside contacts and additional fingers cannot reach the underlying window, and that up/cancel restore normal input. Covers the existing fix for #43.
2026-10-24 19:54:40 +01:00
Samuel Onoja df36d1c9b0 Fix floating placement and restore on overlapping outputs (#60)
Carry output identity through floating placement and restore, and retain the owning output when saved geometry overlaps multiple monitors equally.

Fixes #59.
2026-10-24 19:48:16 +01:00
Samuel Onoja 2248ee6846 test(activities): set the PTY prompt after system startup
Fixes #58.
2026-10-24 19:43:21 +01:00
Samuel Onoja d18f0fd8be Keep notification popovers open when unread badges clear 2026-10-23 13:08:52 +01:00
Samuel Onoja 8bc82a58a2 Refine notification stack edges and layer counts 2026-10-23 13:08:52 +01:00
Samuel Onoja 4f78cca71e Protect desktop notification service ownership 2026-10-23 01:17:20 +01:00
Samuel Onoja 1a484adead Open floating windows on the focused output 2026-10-23 01:17:20 +01:00
Samuel Onoja 1b51ba83b4 Explain runtime invariants and avoid redundant unwraps 2026-10-22 01:28:03 +01:00
Samuel Onoja 34f2f07301 Enforce shared Rust lint policy and document review rules 2026-10-22 01:28:03 +01:00
Samuel Onoja f2e728b503 Document unsafe contracts and stop reusing poisoned state 2026-10-22 01:28:03 +01:00
Samuel Onoja a7fcd69897 Clarify constructor failures and borrowed API contracts 2026-10-22 01:28:03 +01:00
Samuel Onoja 23e280a6b4 Separate library implementations from module declarations 2026-10-22 01:28:03 +01:00
Samuel Onoja 1b4db0e681 Make module dependencies explicit across the workspace 2026-10-22 01:28:03 +01:00
Samuel Onoja 610478f8c7 Format Rust sources at 100 columns 2026-10-22 01:28:03 +01:00
Samuel Onoja 4661f595f8 Refine Pomodoro popup styling and controls 2026-10-20 17:57:07 +01:00
Samuel Onoja 97bbf1b866 Isolate readiness tests that count process-wide descriptors
Run descriptor-count assertions in private processes so concurrent display and process-monitor tests cannot change their baseline. Keep the existing leak, timing and socket-recovery assertions unchanged.
2026-10-20 15:40:34 +01:00
Samuel Onoja 90879145b2 Upgrade Smithay to pinned upstream Git
Pin 8a270232f1bfc416c781273c98a3f52a812a0bba and adapt protocol dispatch, input timestamps, configure state and rendering APIs. Preserve custom lock and toplevel validation, compositor-owned key repeats, and valid layer remapping sequences.

Add native icon-buffer lifecycle regressions for the GoLand and Spotify launch failure.
2026-10-20 15:40:34 +01:00
Samuel Onoja 17df20089b Give touch-opened OnDemand layers keyboard focus
Remember and consume the opening interaction instead of depending on pointer focus. Revoke pending focus after another interaction, focus change, cancellation or unmapping, and retain lock and grab safeguards.

Fixes #54
2026-10-20 15:38:35 +01:00
Samuel Onoja de6e66460d Revoke fullscreen stacking exceptions when window parents change
Clear the old exception when a dialog is unparented or reparented, and remove child references when a parent is destroyed. Preserve floating placement while rebuilding the stacking order.

Fixes #55
2026-10-20 15:36:52 +01:00
Samuel Onoja 4fb383eaa3 Clear Background portal state when compositor IPC disconnects
Notify consumers when cached apps disappear and repopulate state from the next compositor snapshot. Cover disconnects, reconnects and duplicate signals on a private D-Bus session, and run the regression in CI.

Fixes #56
2026-10-20 15:36:45 +01:00
Samuel Onoja afd46c0ea5 Document Pomodoro configuration and session controls 2026-10-20 15:19:55 +01:00
Samuel Onoja 2c9bb85f2c Add session-owned Pomodoro cycles and controls
Add configurable work and break intervals, pause/resume, CLI and activity-popup controls, and silent completion alerts. Use CLOCK_BOOTTIME deadlines so running timers include suspend without consuming paused time.

Refs #31
2026-10-20 15:19:55 +01:00
Samuel Onoja 53ba599918 Release session devices during teardown and tolerate removed panel outputs 2026-10-14 19:02:54 +01:00
Samuel Onoja 90564e6564 Replace desktop demo video with launcher and activity screenshot 2026-10-14 18:38:09 +01:00
Samuel Onoja 63a3e41400 Keep the shell renderer preference out of launched applications 2026-10-14 18:24:44 +01:00
Samuel Onoja 0be35b7f8a Keep unrelated tree branches moving during spring reversals 2026-10-14 18:22:42 +01:00
Samuel Onoja 4bf556dedc Remove desktop reconciliation guide 2026-10-14 18:20:35 +01:00
Samuel Onoja 8f1efd37f0 Remove DMA-BUF feedback guide 2026-10-14 18:20:20 +01:00
Samuel Onoja cc38436bce Remove nested resize testing guide 2026-10-14 18:20:02 +01:00
Samuel Onoja f228a08ff9 Remove release preparation document 2026-10-14 18:17:34 +01:00
Samuel Onoja 77fa25e5f7 Prepare 0.1.0-beta.2 feature freeze and release validation 2026-10-14 18:16:55 +01:00
Samuel Onoja a62f1367bd Fix shell focus, launcher touch dismissal, and sandbox clipboard access 2026-10-14 18:11:13 +01:00
Samuel OnojaandSamuel Onoja c8dfb3074d feat(render): add output-specific DMA-BUF feedback (#53)
Co-authored-by: Samuel Onoja <[email protected]>
2026-10-14 13:32:18 +01:00
Samuel OnojaandSamuel Onoja 9251522a69 fix(input): match layer keyboard focus to fullscreen visibility (#49)
* fix(input): require mapped layers for keyboard focus

* fix(input): match layer keyboard focus to fullscreen visibility

---------

Co-authored-by: Samuel Onoja <[email protected]>
2026-10-14 13:30:14 +01:00
Samuel OnojaandSamuel Onoja b5f7793870 feat(render): add per-output DRM outcome diagnostics (#52)
Co-authored-by: Samuel Onoja <[email protected]>
2026-10-14 13:29:33 +01:00
Samuel OnojaandSamuel Onoja f449c19099 fix(layer-shell): resolve only registered usable outputs (#51)
Co-authored-by: Samuel Onoja <[email protected]>
2026-10-14 13:29:09 +01:00
Samuel OnojaandSamuel Onoja d73d5f08f9 fix(input): require mapped layers for keyboard focus (#48)
Co-authored-by: Samuel Onoja <[email protected]>
2026-10-14 13:28:33 +01:00
Samuel OnojaandSamuel Onoja 0589f5685b fix(outputs): complete CRTC matching before live validation (#45)
Co-authored-by: Samuel Onoja <[email protected]>
2026-10-14 13:28:23 +01:00
Samuel OnojaandSamuel Onoja e980bb2e43 fix: schedule pending resizes with bounded deadlines (#42)
Co-authored-by: Samuel Onoja <[email protected]>
2026-10-14 13:28:10 +01:00
Samuel Onoja 216289d66c fix: narrow tree resize presentation dependencies (#41)
* fix: scope tree resize waits to overlapping presentation motion

* Keep tree resize waits conservative during spring reversals
2026-10-14 13:27:50 +01:00
Samuel Onoja a5e8ba65e2 Fix fractional-scale capture sizing and release idle capture caches 2026-10-14 13:16:24 +01:00
Samuel Onoja 07a8e50f47 Add window blur, organize Settings, and reduce launcher memory 2026-10-14 01:01:21 +01:00
Samuel Onoja bbc2f226d8 Keep popover effects alive until surface destruction 2026-10-14 00:07:25 +01:00
Samuel Onoja 4d1d1af413 Polish bar alignment and battery indicator, dismiss launcher on outside click 2026-10-13 23:36:04 +01:00
Samuel Onoja d8bdb68b2a Add native shell launcher with themed search and browsing 2026-10-13 23:15:44 +01:00
Samuel Onoja 2a400b26d0 Add gamma control and night light (#38)
Add configurable night light, smooth gamma transitions, and shell controls. Update documentation and panel tests, and fix portal test startup synchronization.
2026-10-13 14:37:26 +01:00
Samuel Onoja 9163374cdb unblock brightness shortcut on lock screen 2026-10-13 13:10:53 +01:00
Samuel Onoja f0620e317b Fix Bash activity completion after failed commands (#36) 2026-10-13 11:27:59 +01:00
Samuel Onoja 305f49a62f Fix panel review findings and polkit library lifetime 2026-10-13 10:35:43 +01:00
Samuel Onoja f7e5efb245 Fix review findings across compositor and shell 2026-10-13 04:02:17 +01:00
Samuel Onoja 5d9673aad3 Fix config and IPC lifecycle issues and remove obsolete code 2026-10-13 03:36:25 +01:00
Samuel Onoja 8007f7a107 fix(panel): close Morph after outside clicks 2026-10-13 03:29:54 +01:00
Samuel Onoja f57c68454a Clarify desktop documentation and simplify project presentation 2026-10-13 03:15:00 +01:00
Samuel Onoja b552288dc1 feat(site): show the desktop demo in the hero and README 2026-10-13 01:07:37 +01:00
881 changed files with 239656 additions and 17081 deletions

No files matched your search

+18 -1
View File
@@ -38,11 +38,16 @@ jobs:
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
@@ -129,6 +134,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v8
with:
pattern: release-*
@@ -140,6 +146,12 @@ jobs:
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');
@@ -156,7 +168,12 @@ jobs:
if (!release) {
await github.rest.repos.createRelease({
...repo, tag_name: tag, name: tag, draft: true,
generate_release_notes: true, prerelease: tag.includes('-')
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
+10 -4
View File
@@ -24,7 +24,7 @@ jobs:
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 libpam-modules
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
@@ -50,15 +50,21 @@ jobs:
- 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 capture privacy, resize dependencies and cursor composition
- 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:
@@ -85,8 +91,8 @@ jobs:
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 libgbm1 libseat1 libinput10 libpipewire-0.3-0t64 \
libegl1 libegl-mesa0 libgl1-mesa-dri fonts-dejavu-core
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
+1
View File
@@ -1,4 +1,5 @@
/target/
/.artifacts/
/tools/
*.log
Generated
+1244 -366
View File
File diff suppressed because it is too large. Load diff
+25 -6
View File
@@ -13,6 +13,7 @@ members = [
"crates/ferese-layout",
"crates/ferese-panel",
"crates/ferese-protocols",
"crates/ferese-rime",
"shell/ferese-shell",
"shell/ferese-settings",
"shell/ferese-lock",
@@ -20,22 +21,37 @@ members = [
"portal",
]
resolver = "3"
exclude = ["vendor/smithay"]
[workspace.package]
version = "0.1.0-beta"
version = "0.1.0"
edition = "2024"
license = "MIT"
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"
iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.3-rc.2" }
libcosmic = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.3-rc.2", 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",
@@ -48,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"
@@ -57,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" }
+71 -77
View File
@@ -5,64 +5,57 @@
<p align="center"><strong>A configurable Wayland desktop</strong></p>
<p align="center">
<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/Screenshots">Screenshots</a> ·
<a href="https://github.com/ferese-wm/ferese/wiki">Documentation</a>
</p>
Ferese combines a window manager and desktop shell with one configuration.
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.
![Ferese desktop with Morph's music view open over a sunset wallpaper](docs/images/screenshots/ferese-morph.webp)
**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](https://github.com/ferese-wm/ferese/wiki/Screenshots)
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
![Ferese desktop with the launcher, build progress, Appearance settings and terminal windows](docs/images/screenshots/ferese-desktop.png)
- Scrolling columns, [tabbed window groups](https://github.com/ferese-wm/ferese/wiki/Tabbed-Windows), tree tiling and floating windows with saved size and position
- Integrated shell with notifications, media controls, Settings and Control Center
- [Custom panels](https://github.com/ferese-wm/ferese/wiki/Panel-Configuration) with seven styles, editable groups and items, and top or bottom placement
- [Live Activities](https://github.com/ferese-wm/ferese/wiki/Live-Activities) for build status, running commands and progress from connected apps
- Workspace overview and recent-window switching
- [Continuum animations](https://github.com/ferese-wm/ferese/wiki/Configuration-Animations): interruptible springs, gesture momentum and shared motion controls
- Live wallpapers and blur, custom KDL themes, and Light/Dark/Auto modes
- Multiple monitors, fractional scaling and lower refresh rates on low battery
- Wi-Fi, Bluetooth and audio controls
- Custom keyboard shortcuts and touchpad gestures
- Screenshot annotation, screen sharing and recording
- Lock screen and authentication dialogs
- [Keep awake](https://github.com/ferese-wm/ferese/wiki/Configuration-Idle-Inhibition) during fullscreen media playback or for selected apps
- Continuous corners for windows and shell
[Screenshots](https://github.com/ferese-wm/ferese/wiki/Screenshots)
## How Ferese compares
## Using Ferese
Ferese combines scrolling and tree layouts with its own desktop controls. The table
compares current upstream features and names separate tools where they are needed.
- 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.
| Feature | Ferese | [Hyprland](https://github.com/hyprwm/Hyprland) | [niri](https://github.com/niri-wm/niri) |
| --- | --- | --- | --- |
| Tiling layouts | Scrolling columns and tree tiling; switch per workspace | [Scrolling, dwindle, master and monocle; per-workspace layouts](https://github.com/hyprwm/Hyprland#features) | Scrolling columns |
| Floating windows | Built in | Built in | Built in |
| Workspace overview | Built in | Plugins such as [HyprExpo](https://github.com/sandwichfarm/hyprexpo) | Built in |
| Animations | [Continuum](https://github.com/ferese-wm/ferese/wiki/Animation-Model): interruptible springs, gesture momentum; speed and reduced-motion settings shared by the compositor and shell | Bézier and spring curves | [Spring and easing animations; custom shaders](https://niri-wm.github.io/niri/Configuration:-Animations.html) |
| Corner shapes | Continuous corners across windows and shell | [Rounded corners and squircles](https://wiki.hypr.land/configuring/core/config-options/#decoration) | [Rounded corners with optional clipping](https://niri-wm.github.io/niri/Configuration:-Window-Rules.html#geometry-corner-radius) |
| Live configuration reload | Yes | Yes | Yes |
| Monitor mirroring | Built in | [Built in](https://wiki.hypr.land/configuring/core/monitors/) | Separate tool, such as [wl-mirror](https://niri-wm.github.io/niri/Screencasting.html#screen-mirroring) |
| Desktop controls | Included Settings and Control Center | [Separate utilities](https://wiki.hypr.land/hypr-ecosystem/) or a desktop shell | [Separate desktop shell or tools](https://niri-wm.github.io/niri/Integrating-niri.html#desktop-components) |
| Notifications | Included in ferese-shell | [Separate notification daemon](https://wiki.hypr.land/Useful-Utilities/Must-have/) | [Separate notification daemon or shell](https://niri-wm.github.io/niri/Integrating-niri.html) |
| Lock screen | Included ferese-lock | Separate [hyprlock](https://wiki.hypr.land/Hypr-Ecosystem/hyprlock/) | Separate locker, such as [swaylock or hyprlock](https://niri-wm.github.io/niri/Integrating-niri.html) |
| Screen-sharing backend | xdg-desktop-portal-ferese | [xdg-desktop-portal-hyprland](https://wiki.hypr.land/Hypr-Ecosystem/xdg-desktop-portal-hyprland/) | [xdg-desktop-portal-gnome](https://niri-wm.github.io/niri/Screencasting.html#overview) |
Ferese's shell, Settings, lock screen and dialogs use a shared theme. Other desktop
shells, including [DankMaterialShell and Noctalia](https://niri-wm.github.io/niri/Integrating-niri.html#desktop-components),
also include desktop controls; their features depend on the shell and tools you install.
The [feature roadmap](https://github.com/ferese-wm/ferese/issues/5) tracks implemented features and remaining work.
## Install
Fedora and Arch package builds are covered in [Packages and releases](https://github.com/ferese-wm/ferese/wiki/Packages-and-Releases).
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.
Start with the [dependencies](https://github.com/ferese-wm/ferese/wiki/Installation#requirements), then clone Ferese and
build it as your normal user:
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
@@ -70,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](https://github.com/ferese-wm/ferese/wiki/Installation) 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
Configure Ferese through **Control Center → Settings** or `~/.config/ferese/config.kdl`.
Changes apply live, and if an edit is invalid, the last working configuration stays
in place.
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](https://github.com/ferese-wm/ferese/wiki/Configuration) and [example
config](packaging/config.kdl) cover appearance, window layouts,
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 |
| Display Fn key (`XF86Display`) | Choose the display mode |
| Media Play / Pause, Next, Previous | Control the selected MPRIS player |
| 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 |
See [all shortcuts and mouse actions](https://github.com/ferese-wm/ferese/wiki/Shortcuts-and-Gestures) for window and workspace controls.
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](https://github.com/ferese-wm/ferese/wiki) lists the desktop guides.
[Development instructions](https://github.com/ferese-wm/ferese/wiki/Development) cover building, testing and contributing.
Ferese is written in Rust using Smithay. Development happens [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.
+4
View File
@@ -5,7 +5,11 @@ 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
+158 -35
View File
@@ -1,16 +1,16 @@
//! 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,
activities::{Publish, Snapshot},
};
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, Instant};
use std::time::Duration;
#[derive(Clone, Debug)]
pub(crate) struct Rules {
@@ -21,16 +21,20 @@ 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<crate::ipc::DeadlineTimer<crate::Ferese>>,
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>,
@@ -39,10 +43,16 @@ impl Default for Engine {
fn default() -> Self {
let settings = ferese_config::activities::Settings::default();
Self {
store: Store::new(ferese_ipc::activities::now_ms() ^ u64::from(std::process::id()) << 32),
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::new(&settings).expect("built-in rules"),
matcher: ferese_config::activities::Matcher::try_new(&settings)
.expect("built-in rules"),
}))),
monitor: None,
process_enabled: false,
@@ -55,6 +65,7 @@ impl Default for Engine {
.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(),
@@ -107,6 +118,7 @@ impl Engine {
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,
@@ -130,7 +142,9 @@ impl Engine {
{
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"),
Err(error) => {
tracing::warn!(%error, "could not start optional activity process source")
}
}
}
}
@@ -143,14 +157,28 @@ impl Engine {
revision: self.revision,
now_ms: ferese_ipc::activities::now_ms(),
limits: self.store.limits,
activities: self.store.entries().cloned().collect(),
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 {
.map(
|(activity, window)| ferese_ipc::activities::ActivityWindow {
activity: activity.clone(),
window: *window,
})
},
)
.collect(),
});
self.cached_snapshot = Some(snapshot.clone());
@@ -159,7 +187,20 @@ impl Engine {
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;
}
@@ -173,8 +214,14 @@ impl Engine {
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 since != self.revision {
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));
@@ -203,7 +250,9 @@ impl Engine {
self.schedule();
}
pub fn tick(&mut self) {
self.store.advance(ferese_ipc::activities::now_ms());
let now = ferese_ipc::activities::now_ms();
self.store.advance(now);
self.advance_pomodoro(now);
self.changed();
}
pub fn schedule(&self) {
@@ -219,11 +268,19 @@ impl Engine {
}
if let Some(timer) = &self.timer {
let now = ferese_ipc::activities::now_ms();
timer.update(
if let Err(error) = timer.update(
self.store
.next_deadline(now)
.map(|deadline| Instant::now() + Duration::from_millis(deadline.saturating_sub(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(
@@ -268,16 +325,17 @@ impl Engine {
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())
}
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);
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");
}
@@ -353,7 +411,9 @@ impl Engine {
value: Value::Indeterminate,
template: Template::Progress,
};
let id = self.store.start(owner, local, data, now, 0, None, true, true, None)?;
let id = self
.store
.start(owner, local, data, now, 0, None, true, true, None)?;
json!({"handle":id,"progress":"indeterminate"})
}
}
@@ -447,7 +507,10 @@ impl Engine {
}
impl crate::Ferese {
pub(crate) fn activity_window(&self, owner: &crate::ipc::activity_owner::VerifiedOwner) -> Option<u64> {
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()?;
@@ -456,7 +519,8 @@ impl crate::Ferese {
.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()?;
let pid =
u32::try_from(client.get_credentials(&self.display_handle).ok()?.pid).ok()?;
Some((pid, id.0))
})
.collect();
@@ -492,6 +556,44 @@ mod tests {
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();
@@ -540,7 +642,10 @@ mod tests {
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");
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);
@@ -572,15 +677,21 @@ mod tests {
}
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_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 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::new(&Default::default())
let matched = ferese_config::activities::Matcher::try_new(&Default::default())
.unwrap()
.matches("cargo test", "/tmp", &[])
.unwrap();
@@ -596,7 +707,7 @@ mod tests {
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::new(&settings)
let matched = ferese_config::activities::Matcher::try_new(&settings)
.unwrap()
.matches("cargo test", "/tmp/test", &[])
.unwrap();
@@ -616,7 +727,10 @@ mod tests {
#[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), (100, 8)]),
Some(7)
);
assert_eq!(
origin_window(ancestry.into_iter(), &[(200, 7), (200, 9), (100, 8)]),
None
@@ -674,7 +788,9 @@ mod tests {
.unwrap();
engine.owner_exit(owner());
assert_eq!(engine.snapshot().windows[0].activity, id);
engine.store.advance(ferese_ipc::activities::now_ms() + 2_000);
engine
.store
.advance(ferese_ipc::activities::now_ms() + 2_000);
engine.changed();
assert!(engine.snapshot().windows.is_empty());
}
@@ -711,7 +827,14 @@ mod tests {
);
}
assert_eq!(
engine.store.entries().next().unwrap().data.output.as_deref(),
engine
.store
.entries()
.next()
.unwrap()
.data
.output
.as_deref(),
Some("first")
);
engine
@@ -736,7 +859,7 @@ mod tests {
#[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::new(&settings)
let matched = ferese_config::activities::Matcher::try_new(&settings)
.unwrap()
.matches("cargo test --secret", "/secret/cwd", &[])
.unwrap();
@@ -760,7 +883,7 @@ mod tests {
replacement.rules[0].title = "Replacement".into();
engine.configure(
&replacement,
ferese_config::activities::Matcher::new(&replacement).unwrap(),
ferese_config::activities::Matcher::try_new(&replacement).unwrap(),
);
let after = engine.store.entries().next().unwrap();
assert_eq!(after.data.title, "cargo test");
+127
View File
@@ -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);
}
}
+406
View File
@@ -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());
}
}
+81 -18
View File
@@ -1,19 +1,21 @@
//! Bounded serialization and delivery outside the compositor event loop.
use ferese_ipc::{Response, activities::Snapshot};
use std::sync::{
Arc,
mpsc::{self, SyncSender},
};
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>);
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 || {
@@ -23,26 +25,61 @@ impl Publisher {
.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().unwrap().1;
for (id, sender) in job.recipients {
// Client queues are bounded too. A slow/disconnected client
// cannot block serialization or another subscriber.
let _ = sender.try_send(Response::success(id, value.clone()));
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))
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>) {
if let Err(error) = self.0.try_send(Job { snapshot, recipients }) {
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(
@@ -58,10 +95,29 @@ 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);
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();
@@ -70,9 +126,16 @@ mod tests {
let publisher = Publisher::new().unwrap();
let (slow, _slow_receive) = mpsc::sync_channel(1);
slow.send(Response::success(1, serde_json::json!({}))).unwrap();
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);
assert_eq!(
fast_receive
.recv_timeout(Duration::from_secs(1))
.unwrap()
.id,
3
);
}
}
+33 -14
View File
@@ -5,10 +5,8 @@ use ferese_activities::Owner;
use smithay::reexports::calloop::channel;
use std::collections::BTreeMap;
use std::io::Read;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
pub(crate) struct Watcher(Arc<AtomicBool>);
@@ -27,13 +25,20 @@ impl Watcher {
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();
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))
.filter_map(|(owner, (fd, _))| {
crate::ipc::activity_owner::exited(fd).then_some(*owner)
})
.collect();
for owner in dead {
observed.remove(&owner);
@@ -54,21 +59,30 @@ impl Watcher {
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())
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 {
let Ok((owner, fd, parents)) = crate::ipc::activity_owner::inspect(pid)
else {
continue;
};
if observed.contains_key(&owner) || parents.iter().any(|parent| parent == "feresectl") {
if observed.contains_key(&owner)
|| parents.iter().any(|parent| parent == "feresectl")
{
continue;
}
let Ok(file) = std::fs::File::open(process.path().join("cmdline")) else {
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 {
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 {
@@ -79,14 +93,19 @@ impl Watcher {
continue;
};
let cwd = cwd.to_string_lossy();
let Some(matched) = rules.matcher.matches(&command, &cwd, &parents) else {
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()))
.try_send(IpcEvent::ActivityDetected(
owner,
Box::new(matched.clone()),
fd.clone(),
))
.is_ok()
{
observed.insert(owner, (fd, matched));
File diff suppressed because it is too large. Load diff
+699 -37
View File
@@ -1,18 +1,123 @@
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::{capture_output_elements, redraw_output};
use crate::state::FrameScene;
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,
) {
@@ -21,15 +126,35 @@ pub(crate) fn capture_output(
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 = capture_output_elements(state, renderer, output, include_cursor, scene);
let Some(target) = capture_request(state, output, include_cursor, || {
render_capture(renderer, texture, output, &elements)
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(renderer, &target, output, include_cursor);
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);
}
}
@@ -44,27 +169,119 @@ pub(super) fn capture_mirror(
if !state.has_pending_screencopy(&output.output, include_cursor) {
continue;
}
let elements = capture_output_elements(state, renderer, source, include_cursor, scene);
// Never sample mirror_texture: it contains the unfiltered display.
let canvas = output.mirror_canvas.as_ref().expect("mirror display composed");
let Some(()) = capture_request(state, &output.output, include_cursor, || {
super::mirror::compose(renderer, &mut output.mirror_capture_texture, canvas, source, &elements)
}) else {
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 element = super::mirror::fitted_texture(
output.mirror_capture_texture.as_ref().unwrap().clone(),
&output.output,
smithay::backend::renderer::element::Id::new(),
Default::default(),
);
let Some(target) = capture_request(state, &output.output, include_cursor, || {
render_capture(renderer, &mut output.capture_texture, &output.output, &[element])
}) else {
continue;
};
state.process_screencopies(renderer, &target, &output.output, include_cursor);
}
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>(
@@ -89,7 +306,76 @@ pub(super) fn after_queue<T, E>(queued: Result<T, E>, capture: impl FnOnce()) ->
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,
@@ -128,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(),
@@ -168,7 +803,7 @@ mod tests {
let mut displayed = None;
let mut capture = None;
{
let _ = render_capture(
let _ = render_texture(
&mut renderer,
&mut displayed,
&output,
@@ -183,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(
@@ -206,8 +855,10 @@ 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());
}
}
@@ -234,6 +885,7 @@ mod failure_tests {
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(),
@@ -253,6 +905,7 @@ mod failure_tests {
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(),
@@ -276,7 +929,10 @@ mod failure_tests {
Ok::<(), ()>(())
};
after_queue(queued, || {
assert!(submitted.get(), "capture must run after the display queue succeeds");
assert!(
submitted.get(),
"capture must run after the display queue succeeds"
);
assert!(
capture_request(&mut state, &output, false, || Err::<(), _>(
"injected allocation failure"
@@ -286,14 +942,20 @@ mod failure_tests {
})
.unwrap();
assert!(submitted.get(), "capture failure must not undo queued display work");
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!(
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"
);
}
}
+134
View File
@@ -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");
}
}
+258 -35
View File
@@ -1,5 +1,3 @@
use std::collections::HashSet;
use smithay::reexports::drm::control::crtc;
pub(super) struct Candidates {
@@ -8,45 +6,114 @@ pub(super) struct Candidates {
pub compatible: Vec<crtc::Handle>,
}
/// Reserve surviving outputs before assigning newly enabled connectors. Kernel
/// encoder routing can be stale for a disabled connector; applied ownership wins.
/// 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()];
let mut used = HashSet::new();
for applied in [true, false] {
for (request, assigned) in requests.iter().zip(&mut assigned) {
let preferred = if applied { request.applied } else { request.kernel };
if assigned.is_none()
&& let Some(crtc) = preferred.filter(|crtc| request.compatible.contains(crtc))
&& used.insert(crtc)
{
*assigned = Some(crtc);
for (index, crtc, edge) in assignments {
if !edges[edge].available {
assigned[index] = Some(crtc);
}
}
}
// Give connectors with fewer choices the first free CRTC.
let mut remaining = (0..requests.len())
.filter(|index| assigned[*index].is_none())
.collect::<Vec<_>>();
remaining.sort_by_key(|index| requests[*index].compatible.len());
for index in remaining {
if let Some(crtc) = requests[index].compatible.iter().find(|crtc| !used.contains(crtc)) {
used.insert(*crtc);
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()
@@ -60,16 +127,166 @@ mod tests {
}
}
#[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))]);
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))]);
assert_eq!(
assign(&[external, internal]),
vec![Some(crtc(151)), Some(crtc(270))]
);
}
#[test]
@@ -120,7 +337,10 @@ mod tests {
#[test]
fn restricted_connector_gets_its_only_free_crtc() {
assert_eq!(
assign(&[candidates(None, None, &[151, 270]), candidates(None, None, &[151])]),
assign(&[
candidates(None, None, &[151, 270]),
candidates(None, None, &[151])
]),
vec![Some(crtc(270)), Some(crtc(151))]
);
}
@@ -128,7 +348,10 @@ mod tests {
#[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])]),
assign(&[
candidates(None, None, &[151]),
candidates(Some(151), Some(151), &[151])
]),
vec![None, Some(crtc(151))]
);
assert!(assign(&[]).is_empty());
@@ -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());
}
}
+548
View File
@@ -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());
}
}
+442
View File
@@ -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());
}
}
}
+58 -16
View File
@@ -1,7 +1,10 @@
//! Resume reads are asynchronous; newer switch events win over old replies.
use std::sync::{Arc, Mutex, mpsc};
use super::*;
use super::{
Duration, Error, Ferese, RegistrationToken, TimeoutAction, Timer, cancel_output_timers,
reconcile_outputs,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct Probe {
@@ -145,7 +148,11 @@ fn read_acpi() -> Option<bool> {
impl Reader {
pub fn set_active(&mut self, active: bool) {
let generation = self.lease.lock().unwrap().activate(active);
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));
@@ -164,7 +171,9 @@ impl Reader {
let Ok(connection) = connection else { return };
while let Ok(request) = suspend_requests.recv() {
if let PolicyRequest::Activate(generation) = request {
let current = worker_lease.lock().unwrap();
let current = worker_lease
.lock()
.expect("lid policy lease must remain consistent across workers");
if !current.active || current.generation != generation {
continue;
}
@@ -182,14 +191,21 @@ impl Reader {
"block",
),
)
.and_then(|reply| reply.body().deserialize::<zbus::zvariant::OwnedFd>());
.and_then(|reply| {
reply.body().deserialize::<zbus::zvariant::OwnedFd>()
});
match inhibitor {
Ok(descriptor) => worker_lease.lock().unwrap().accept(generation, descriptor),
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().unwrap();
let current = worker_lease
.lock()
.expect("lid policy lease must remain consistent across workers");
if !current.active || !current.suspend {
continue;
}
@@ -214,7 +230,9 @@ impl Reader {
tracing::debug!("lid suspend prevented by sleep inhibitor");
continue;
}
let current = worker_lease.lock().unwrap();
let current = worker_lease
.lock()
.expect("lid policy lease must remain consistent across workers");
let requested = current.active && current.suspend;
drop(current);
if !requested {
@@ -230,7 +248,10 @@ impl Reader {
tracing::warn!(%error, "logind rejected lid suspend");
}
}
worker_lease.lock().unwrap().activate(false);
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();
@@ -299,22 +320,33 @@ fn request(state: &mut Ferese, reactivate: bool, wake: bool) {
}
}
backend.lid_reader.suspend_requested = false;
backend.lid_reader.lease.lock().unwrap().suspend = 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| {
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().unwrap().lid_reader.deadline = Some(token);
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);
@@ -344,7 +376,12 @@ fn complete_refresh(state: &mut Ferese, probe: Probe, value: Option<bool>) {
backend.topology.reconciling = true;
state.reset_animation_clock();
state.lock_input_activity();
state.direct_backend.as_mut().unwrap().topology.reconciling = false;
state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.topology
.reconciling = false;
}
reconcile_outputs(state, reactivate);
@@ -356,7 +393,12 @@ pub(super) fn apply_suspend_policy(state: &mut Ferese) {
return;
};
let requested = backend.active && backend.desired_outputs.suspend && backend.lid.closed();
backend.lid_reader.lease.lock().unwrap().suspend = requested;
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);
+52 -11
View File
@@ -1,12 +1,21 @@
//! 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.
use super::*;
#[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 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(
@@ -21,13 +30,17 @@ pub(super) fn elements(
source: &Output,
frame: &crate::state::FrameScene,
target: &mut DirectOutput,
) -> Result<Vec<crate::render::AnimatedWindowRenderElement>, smithay::backend::renderer::gles::GlesError> {
) -> 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(),
@@ -35,7 +48,13 @@ pub(super) fn elements(
},
)
});
compose(renderer, &mut target.mirror_texture, canvas, source, &elements)?;
compose(
renderer,
&mut target.mirror_texture,
canvas,
source,
&elements,
)?;
let texture = target
.mirror_texture
.as_ref()
@@ -43,7 +62,13 @@ pub(super) fn elements(
.clone();
target.mirror_commit.increment();
Ok(vec![
fitted_texture(texture, &target.output, target.mirror_id.clone(), target.mirror_commit).into(),
fitted_texture(
texture,
&target.output,
target.mirror_id.clone(),
target.mirror_commit,
)
.into(),
])
}
@@ -54,6 +79,13 @@ pub(super) fn compose(
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 {
@@ -64,9 +96,6 @@ pub(super) fn compose(
Some(source.current_scale()),
None,
);
let framebuffer = capture::render_capture(renderer, texture, canvas, elements)?;
drop(framebuffer);
Ok(())
}
pub(super) fn fitted_texture(
@@ -114,13 +143,18 @@ mod tests {
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(),
@@ -139,7 +173,8 @@ mod tests {
None,
);
let canvas = output("canvas");
let colors: Vec<crate::render::AnimatedWindowRenderElement> = [(0, [1., 0., 0., 1.]), (32, [0., 0., 1., 1.])]
let colors: Vec<crate::render::AnimatedWindowRenderElement> =
[(0, [1., 0., 0., 1.]), (32, [0., 0., 1., 1.])]
.into_iter()
.map(|(y, color)| {
SolidColorRenderElement::new(
@@ -173,9 +208,15 @@ mod tests {
CommitCounter::default(),
);
let mut target_texture = None;
let framebuffer = capture::render_capture(&mut renderer, &mut target_texture, &target, &[element]).unwrap();
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)
.copy_framebuffer(
&framebuffer,
Rectangle::from_size((96, 64).into()),
Fourcc::Abgr8888,
)
.unwrap();
let pixels = renderer.map_texture(&mapping).unwrap();
let red = pixels
+28 -9
View File
@@ -1,5 +1,7 @@
//! Uncertain KMS completion requires device reconciliation, not buffer reuse.
use super::*;
use super::{
DirectOutput, DrmNode, Duration, Ferese, Instant, TimeoutAction, Timer, crtc, reconcile_outputs,
};
const COMPLETION_TIMEOUT: Duration = Duration::from_secs(2);
@@ -8,7 +10,8 @@ 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 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)
}
@@ -57,7 +60,9 @@ pub(super) fn arm_completion(
}
let identity = output.output.clone();
match handle.insert_source(Timer::from_duration(COMPLETION_TIMEOUT), move |_, _, state| {
match handle.insert_source(
Timer::from_duration(COMPLETION_TIMEOUT),
move |_, _, state| {
let Some(output) = state
.direct_backend
.as_mut()
@@ -74,14 +79,19 @@ pub(super) fn arm_completion(
}
output.completion_timer = None;
tracing::error!(?node, ?crtc, "DRM frame completion timed out; reconciling device");
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
@@ -106,7 +116,9 @@ pub(super) fn reconcile_device(state: &mut Ferese, node: DrmNode) {
// sessions retain the invalidation for fresh enumeration on activation.
let active = backend.active;
if active {
match state.loop_handle.insert_source(Timer::immediate(), |_, _, state| {
match state
.loop_handle
.insert_source(Timer::immediate(), |_, _, state| {
reconcile_outputs(state, false);
TimeoutAction::Drop
}) {
@@ -125,10 +137,14 @@ mod tests {
let now = Instant::now();
let mut completion = Completion::default();
completion.queued(now);
let (feedback, retired, error) = retirement::<(), _>(Err("injected KMS retirement failure"));
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!(
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));
@@ -148,7 +164,10 @@ mod tests {
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),
Some(Duration::ZERO)
);
assert_eq!(
completion.remaining(now + COMPLETION_TIMEOUT + Duration::from_secs(1)),
Some(Duration::ZERO)
+82 -21
View File
@@ -13,7 +13,13 @@ impl AnimationFallback {
self.orphaned_since.is_some()
}
pub fn deadline(&mut self, now: Instant, interval: Duration, active: bool, chained: bool) -> Option<Instant> {
pub fn deadline(
&mut self,
now: Instant,
interval: Duration,
active: bool,
chained: bool,
) -> Option<Instant> {
if !active || chained {
self.orphaned_since = None;
return None;
@@ -55,12 +61,21 @@ impl SchedulingMetrics {
}
}
pub fn redraw(&mut self, source: &'static Location<'static>, global: bool, requested: usize, connected: usize) {
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();
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;
@@ -116,10 +131,20 @@ pub(super) fn cursor_on_output(state: &crate::Ferese, output: &smithay::output::
states
.data_map
.get::<CursorImageSurfaceData>()
.map(|attributes| attributes.lock().unwrap().hotspot)
.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())
smithay::desktop::utils::bbox_from_surface_tree(
surface,
(location - hotspot.to_f64()).to_i32_round(),
)
.to_f64()
}
@@ -135,7 +160,11 @@ pub(super) fn cursor_on_output(state: &crate::Ferese, output: &smithay::output::
// 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(),
(
f64::from(bounds.size.w) + 2.0,
f64::from(bounds.size.h) + 2.0,
)
.into(),
);
rect.overlaps(bounds)
}
@@ -152,7 +181,10 @@ mod tests {
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, true, false),
Some(now + slow * 2)
);
assert_eq!(
moving_slow.deadline(now + slow, slow, true, false),
Some(now + slow * 2)
@@ -166,7 +198,10 @@ mod tests {
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);
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),
@@ -192,7 +227,10 @@ mod tests {
last_tick_us = now_us;
}
assert_eq!(policy.deadline(start, Duration::from_micros(16_667), true, true), None);
assert_eq!(
policy.deadline(start, Duration::from_micros(16_667), true, true),
None
);
}
assert_eq!(old_wakes, 60);
@@ -208,11 +246,19 @@ mod tests {
scheduler.request(Duration::ZERO);
let scheduled = scheduler.plan(Duration::ZERO).unwrap();
assert_eq!(fallback.deadline(start, interval, true, true), None);
let plan = scheduler.begin().unwrap();
let plan = scheduler.begin(scheduled.render_at).unwrap();
assert_eq!(plan, scheduled);
scheduler.rendered(plan, Duration::from_millis(2), Duration::from_millis(2), true);
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);
assert_eq!(
fallback.deadline(start + interval, interval, true, true),
None
);
scheduler.presented(interval);
assert_eq!(
fallback.deadline(start + interval, interval, true, false),
@@ -220,7 +266,10 @@ mod tests {
);
scheduler.request(interval);
assert!(scheduler.plan(interval).is_some());
assert_eq!(fallback.deadline(start + interval, interval, true, true), None);
assert_eq!(
fallback.deadline(start + interval, interval, true, true),
None
);
}
#[test]
@@ -237,14 +286,18 @@ mod tests {
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());
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().unwrap();
assert_eq!(
fallback.deadline(deadline, interval, needs_final_frame, true),
None
);
scheduler.begin(deadline.duration_since(start)).unwrap();
assert!(!scheduler.has_pending_request());
}
@@ -279,6 +332,7 @@ mod tests {
let output = Output::new(
format!("cursor-test-{index}"),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
@@ -295,7 +349,11 @@ mod tests {
refresh,
}),
None,
Some(smithay::output::Scale::Fractional(if index == 0 { 2.0 } else { 1.0 })),
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));
@@ -313,7 +371,7 @@ mod tests {
&MotionEvent {
location: (x, 500.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: 0,
time: smithay::backend::input::InputTime::from_millis(0),
},
);
let current = [
@@ -337,11 +395,14 @@ mod tests {
&MotionEvent {
location: (1919.0, 500.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: 0,
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");
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);
@@ -351,7 +412,7 @@ mod tests {
&MotionEvent {
location: (x, 500.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: 0,
time: smithay::backend::input::InputTime::from_millis(0),
},
);
assert!(
+14 -4
View File
@@ -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 {
@@ -123,7 +126,7 @@ fn dispatch_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, identit
return;
}
let Some(plan) = output.scheduler.begin() else {
let Some(plan) = output.scheduler.begin(monotonic_now()) else {
return;
};
@@ -138,8 +141,15 @@ fn dispatch_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, identit
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());
}
}
+431 -88
View File
@@ -1,24 +1,30 @@
//! Desired device identities survive temporary loss of their DRM resources.
use std::path::PathBuf;
use super::*;
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 {
Ready(DrmNode),
Degraded(DrmNode),
pub(super) enum Availability<Node = DrmNode> {
Ready(Node),
Degraded(Node),
Missing,
Unavailable,
}
pub(super) struct DeviceState {
pub availability: Availability,
pub(super) struct DeviceState<Node = DrmNode> {
pub availability: Availability<Node>,
identity: Option<PathBuf>,
failures: u32,
retry_at: Option<Instant>,
}
impl Default for DeviceState {
impl<Node> Default for DeviceState<Node> {
fn default() -> Self {
Self {
availability: Availability::Missing,
@@ -29,20 +35,22 @@ impl Default for DeviceState {
}
}
fn device_identity(path: &Path) -> Option<PathBuf> {
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()
std::fs::canonicalize(
Path::new("/sys/class/drm")
.join(path.file_name()?)
.join("device"),
)
.ok()
}
impl DeviceState {
pub fn known(path: &Path) -> Self {
Self {
identity: device_identity(path),
..Self::default()
}
}
fn current_path<'a>(&self, original: &Path, observed: &'a [(PathBuf, Option<PathBuf>)]) -> Option<&'a Path> {
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 {
@@ -55,10 +63,11 @@ impl DeviceState {
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)));
self.retry_at =
Some(now + Duration::from_secs(1u64 << self.failures.saturating_sub(1).min(5)));
}
pub fn ready(&mut self, node: DrmNode) {
pub fn ready(&mut self, node: Node) {
self.availability = Availability::Ready(node);
self.failures = 0;
self.retry_at = None;
@@ -69,12 +78,18 @@ impl DeviceState {
self.failures = 0;
self.retry_at = None;
}
fn degraded(&mut self, node: Node, now: Instant) {
self.failed(now);
self.availability = Availability::Degraded(node);
}
}
#[derive(Default)]
pub(super) struct Topology {
pub devices: HashMap<PathBuf, DeviceState>,
pub invalidated: HashSet<DrmNode>,
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,
@@ -84,7 +99,62 @@ pub(super) struct Topology {
pub coalescer: crate::output_policy::Debouncer,
}
impl Topology {
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
@@ -92,7 +162,12 @@ impl Topology {
pub fn retry_deadline(&self, active: bool) -> Option<Instant> {
active
.then(|| self.devices.values().filter_map(|device| device.retry_at).min())
.then(|| {
self.devices
.values()
.filter_map(|device| device.retry_at)
.min()
})
.flatten()
}
}
@@ -101,11 +176,11 @@ fn slot_mut<'a>(state: &'a mut Ferese, path: &Path) -> &'a mut DeviceState {
state
.direct_backend
.as_mut()
.unwrap()
.expect("DRM callbacks require an installed direct backend")
.topology
.devices
.get_mut(path)
.unwrap()
.expect("the discovery path must have a topology slot")
}
/// The only entry point for applying the current output configuration to DRM.
@@ -113,7 +188,10 @@ 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()) {
if !backend
.topology
.request(backend.active && !backend.lid.pending())
{
return;
}
@@ -125,16 +203,14 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
backend.topology.dirty = false;
backend.output_error = None;
if reactivate {
backend
.presentation
.values_mut()
.for_each(PresentationClock::reset_timing);
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 paths = backend.topology.devices.keys().cloned().collect::<Vec<_>>();
let invalidated = std::mem::take(&mut backend.topology.invalidated);
let mut invalidated = std::mem::take(&mut backend.topology.invalidated);
let observed = all_gpus(backend.session.seat()).map(|paths| {
paths
.into_iter()
@@ -148,23 +224,23 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
.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());
backend.low_power = automatic
&& super::super::power::low_power(
backend.low_power,
super::super::power::battery_percent(),
);
if let Ok(observed) = &observed {
for (path, identity) in observed {
if !backend
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
.devices
.values()
.any(|slot| slot.identity == *identity && identity.is_some())
&& !backend.topology.devices.contains_key(path)
{
backend.topology.devices.insert(path.clone(), DeviceState::known(path));
paths.push(path.clone());
}
}
.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();
@@ -174,14 +250,26 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
}
for path in paths {
let slot = &state.direct_backend.as_ref().unwrap().topology.devices[&path];
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().unwrap().devices.contains_key(node));
.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,
@@ -208,10 +296,13 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
}
let node = match existing {
Some(node) => node,
None => match open_device(state, current_path.unwrap()) {
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, "DRM device unavailable; will retry");
tracing::warn!(%error, ?path, device_path = ?current_path, "DRM device unavailable; will retry");
slot_mut(state, &path).failed(Instant::now());
continue;
}
@@ -220,13 +311,20 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
let mut failed_outputs = Vec::new();
let activation = if reactivate && existing.is_some() {
let device = state.direct_backend.as_mut().unwrap().devices.get_mut(&node).unwrap();
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 = None;
output.capture_texture = Default::default();
output.frame_pending = false;
output.lock_frame_pending = false;
output.power.suspended();
@@ -235,7 +333,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
state
.desktop_transition
.as_mut()
.unwrap()
.expect("desktop transition must be started before publication or retirement")
.redraw
.insert(output.output.clone());
@@ -260,13 +358,13 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
let mut output = state
.direct_backend
.as_mut()
.unwrap()
.expect("DRM callbacks require an installed direct backend")
.devices
.get_mut(&node)
.unwrap()
.expect("the selected DRM device must remain registered during this operation")
.outputs
.remove(&crtc)
.unwrap();
.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);
@@ -275,7 +373,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
state
.direct_backend
.as_mut()
.unwrap()
.expect("DRM callbacks require an installed direct backend")
.presentation
.remove(&(node, crtc));
}
@@ -283,27 +381,42 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
ready.push((path, node));
}
let known_good = state.direct_backend.as_ref().unwrap().applied_outputs.clone();
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().unwrap().output_error = Some(error);
for (path, _) in &ready {
slot_mut(state, path).failed(Instant::now());
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().unwrap();
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();
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();
@@ -314,36 +427,60 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
entry.ready(*node);
succeeded.push(*node);
} else {
entry.failed(Instant::now());
entry.availability = Availability::Degraded(*node);
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().unwrap();
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().unwrap().desired_outputs = requested;
state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.desired_outputs = requested;
}
break;
}
}
if state.direct_backend.as_ref().unwrap().output_error.is_none() {
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().unwrap().topology.layout_dirty) {
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());
desktop_changes
.outputs
.extend(state.space.outputs().cloned());
}
let backend = state.direct_backend.as_mut().unwrap();
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) {
@@ -360,7 +497,14 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
reconcile_outputs(state, false);
TimeoutAction::Drop
}) {
Ok(token) => state.direct_backend.as_mut().unwrap().topology.retry_timer = Some(token),
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"),
}
}
@@ -376,7 +520,7 @@ fn publish_runtime_desktop(state: &mut Ferese) -> crate::state::DesktopChanges {
let mut outputs = state
.direct_backend
.as_ref()
.unwrap()
.expect("DRM callbacks require an installed direct backend")
.devices
.values()
.flat_map(|device| device.outputs.values())
@@ -404,7 +548,8 @@ fn publish_runtime_desktop(state: &mut Ferese) -> crate::state::DesktopChanges {
});
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;
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,
@@ -419,11 +564,18 @@ fn publish_runtime_desktop(state: &mut Ferese) -> crate::state::DesktopChanges {
let changes = state
.publish_desktop(runtime)
.expect("validated usable output inventory");
for device in state.direct_backend.as_mut().unwrap().devices.values_mut() {
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));
output.global =
Some(output.output.create_global::<Ferese>(&state.display_handle));
}
} else {
// Mirrors are physical scanouts, not workspace-owning outputs.
@@ -434,7 +586,9 @@ fn publish_runtime_desktop(state: &mut Ferese) -> crate::state::DesktopChanges {
None,
);
}
output.surface.set_output_mode_source((&output.output).into());
output
.surface
.set_output_mode_source((&output.output).into());
}
}
changes
@@ -470,7 +624,14 @@ pub(super) fn hotplug(state: &mut Ferese) {
reconcile_outputs(state, false);
TimeoutAction::Drop
}) {
Ok(token) => state.direct_backend.as_mut().unwrap().topology.settle_timer = Some(token),
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);
@@ -482,9 +643,179 @@ pub(super) fn hotplug(state: &mut Ferese) {
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::default();
let mut topology = Topology::<u64>::default();
assert!(!topology.request(false));
assert!(!topology.request(false));
assert!(topology.dirty);
@@ -497,7 +828,7 @@ mod tests {
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 {
let mut slot = DeviceState::<u64> {
identity: Some(identity.clone()),
..DeviceState::default()
};
@@ -521,13 +852,18 @@ mod tests {
#[test]
fn unavailable_devices_retry_independently_with_capped_backoff() {
let now = Instant::now();
let mut topology = Topology::default();
topology.devices.insert("/dev/dri/card0".into(), DeviceState::default());
let healthy = DrmNode::from_dev_id(libc::makedev(226, 1)).unwrap();
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();
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 {
@@ -539,8 +875,15 @@ mod tests {
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_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());
}
}
+173 -52
View File
@@ -1,12 +1,25 @@
//! 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::*;
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::{AtomicCommitFlags, ResourceHandle, atomic::AtomicModeReq};
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> {
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);
@@ -35,8 +48,9 @@ fn clear_secondary_planes(
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)
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)?;
@@ -59,12 +73,14 @@ impl Drop for Blobs {
}
}
pub(super) fn validate_device(device: &DirectDevice, scan: &OutputScan) -> Result<(), Box<dyn Error>> {
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)
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(());
@@ -82,8 +98,11 @@ pub(super) fn validate_device(device: &DirectDevice, scan: &OutputScan) -> Resul
};
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));
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
@@ -151,16 +170,33 @@ pub(super) fn validate_device(device: &DirectDevice, scan: &OutputScan) -> Resul
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)
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)?;
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),
@@ -176,7 +212,10 @@ pub(super) fn validate_device(device: &DirectDevice, scan: &OutputScan) -> Resul
buffers.push(buffer);
framebuffers.push(fb);
}
drm.atomic_commit(AtomicCommitFlags::TEST_ONLY | AtomicCommitFlags::ALLOW_MODESET, request)?;
drm.atomic_commit(
AtomicCommitFlags::TEST_ONLY | AtomicCommitFlags::ALLOW_MODESET,
request,
)?;
Ok(())
}
@@ -208,6 +247,7 @@ fn prepare(
/// 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);
@@ -217,7 +257,7 @@ fn retire(state: &mut Ferese, output: &mut DirectOutput, node: DrmNode, crtc: cr
state
.direct_backend
.as_mut()
.unwrap()
.expect("DRM callbacks require an installed direct backend")
.presentation
.remove(&(node, crtc));
}
@@ -256,11 +296,12 @@ impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> {
.staged
.get_mut(&crtc)
.or_else(|| self.device.outputs.get_mut(&crtc))
.unwrap();
.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,
@@ -276,15 +317,23 @@ impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> {
// 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).unwrap();
let previous = self.device.outputs.get_mut(&crtc).unwrap();
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))
.unwrap()
.expect("the selected CRTC has a live or staged output in this transaction")
.surface
.commit_frame()
.map_err(|error| error.to_string())
@@ -294,7 +343,7 @@ impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> {
self.device
.outputs
.get_mut(&crtc)
.unwrap()
.expect("the selected CRTC has a live or staged output in this transaction")
.surface
.clear()
.map_err(|error| error.to_string())
@@ -342,14 +391,26 @@ impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> {
&replacement.settings,
)
.map_err(|error| error.to_string())?;
let previous = self.device.outputs.get_mut(&crtc).unwrap();
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.surface.commit_frame().map_err(|error| error.to_string())?;
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.surface.reset_state().map_err(|error| error.to_string())?;
output.dmabuf_feedback.invalidate();
output
.surface
.reset_state()
.map_err(|error| error.to_string())?;
let restored = output
.surface
.set_connectors(&[output.connector])
@@ -363,25 +424,34 @@ impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> {
)
.map_err(|error| error.to_string())
})
.and_then(|()| output.surface.commit_frame().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
.display_presentation
.remove_output(&output.output);
self.state
.desktop_transition
.as_mut()
.unwrap()
.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());
output
.surface
.set_output_mode_source((&output.output).into());
cancel_output_timers(&self.state.loop_handle, output);
output.frame_pending = false;
restored
}
}
/// All allocations/tests precede commits. Logical publication waits for the
/// final usable inventory; devices are independently applied and 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,
@@ -413,6 +483,17 @@ pub(super) fn apply_device(
.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| {
@@ -430,7 +511,7 @@ pub(super) fn apply_device(
staged: HashMap::new(),
replaced: HashSet::new(),
};
let result = crate::output_transaction::apply(&mut driver, &hardware_changes, &removals);
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 {
@@ -442,8 +523,12 @@ pub(super) fn apply_device(
}
for crtc in &changed {
let selection = &selections[crtc];
let mut output = staged.remove(crtc).or_else(|| device.outputs.remove(crtc)).unwrap();
if output.connector != selection.connector.handle() || output.identity != selection.identity {
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,
@@ -477,16 +562,18 @@ pub(super) fn apply_device(
state.display_presentation.remove_output(&output.output);
}
output.lock_frame_pending = false;
output.surface.set_output_mode_source((&output.output).into());
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 = None;
output.capture_texture = Default::default();
output.power.suspended();
let clock = state
.direct_backend
.as_mut()
.unwrap()
.expect("DRM callbacks require an installed direct backend")
.presentation
.entry((node, *crtc))
.or_default();
@@ -496,7 +583,10 @@ pub(super) fn apply_device(
}
// Retire resources now; the final inventory determines evacuation once.
for crtc in removals {
let mut output = device.outputs.remove(&crtc).unwrap();
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;
@@ -508,7 +598,11 @@ 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) {
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;
@@ -523,7 +617,7 @@ pub(super) fn retire_disconnected(state: &mut Ferese) {
let missing = state
.direct_backend
.as_ref()
.unwrap()
.expect("DRM callbacks require an installed direct backend")
.devices
.iter()
.flat_map(|(node, device)| {
@@ -541,10 +635,10 @@ pub(super) fn retire_disconnected(state: &mut Ferese) {
if let Some(mut output) = state
.direct_backend
.as_mut()
.unwrap()
.expect("DRM callbacks require an installed direct backend")
.devices
.get_mut(&node)
.unwrap()
.expect("the selected DRM device must remain registered during this operation")
.outputs
.remove(&crtc)
{
@@ -560,14 +654,22 @@ pub(super) fn recover_last_output(state: &mut Ferese) {
if state
.direct_backend
.as_ref()
.unwrap()
.expect("DRM callbacks require an installed direct backend")
.devices
.values()
.any(|device| device.outputs.values().any(|output| output.mirror_source.is_none()))
.any(|device| {
device
.outputs
.values()
.any(|output| output.mirror_source.is_none())
})
{
return;
}
let backend = state.direct_backend.as_ref().unwrap();
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
@@ -578,7 +680,10 @@ pub(super) fn recover_last_output(state: &mut Ferese) {
.collect::<Vec<_>>();
candidates.sort_by_key(|monitor| (!monitor.internal, monitor.key.clone()));
for candidate in candidates {
let backend = state.direct_backend.as_mut().unwrap();
let backend = state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend");
backend.desired_outputs = DesiredOutputConfiguration {
profile: None,
suspend: requested.suspend,
@@ -599,7 +704,11 @@ pub(super) fn recover_last_output(state: &mut Ferese) {
let nodes = backend
.devices
.iter()
.filter(|(_, device)| candidate.key.starts_with(&format!("{}/", drm_device_key(&device.drm))))
.filter(|(_, device)| {
candidate
.key
.starts_with(&format!("{}/", drm_device_key(&device.drm)))
})
.map(|(node, _)| *node)
.collect::<Vec<_>>();
if validate_desired_outputs(state).is_ok() {
@@ -609,19 +718,31 @@ pub(super) fn recover_last_output(state: &mut Ferese) {
if state
.direct_backend
.as_ref()
.unwrap()
.expect("DRM callbacks require an installed direct backend")
.devices
.values()
.any(|device| device.outputs.values().any(|output| output.mirror_source.is_none()))
.any(|device| {
device
.outputs
.values()
.any(|output| output.mirror_source.is_none())
})
{
remember_applied_configuration(state);
state.direct_backend.as_mut().unwrap().output_error = original_error;
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().unwrap();
let backend = state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend");
backend.desired_outputs = requested;
backend.output_error = original_error;
}
+37 -9
View File
@@ -26,12 +26,16 @@ pub struct LaunchConfig {
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();
@@ -113,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
)),
}
}
@@ -148,7 +155,11 @@ mod tests {
.unwrap();
assert!(config.supervise_client);
assert_eq!(config.client, [OsString::from("ferese-session-shell")]);
assert!(config.client_capabilities.contains(ClientCapabilities::SHELL_CONTROL));
assert!(
config
.client_capabilities
.contains(ClientCapabilities::SHELL_CONTROL)
);
}
#[test]
@@ -194,25 +205,42 @@ 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"));
}
+11 -6
View File
@@ -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]
+248 -103
View File
@@ -14,19 +14,21 @@ use std::path::PathBuf;
pub(crate) use self::loading::{prepare, prepare_document, read_source, startup};
pub(crate) use self::xwayland::{XwaylandConfig, XwaylandStartup};
use appearance::*;
use appearance::{AppearanceConfig, ThemeConfig, parse_color};
pub use appearance::{BorderGradient, FocusEffectSettings, MaterialStyle, ThemeSettings};
pub(crate) use bindings::BindingModes;
use bindings::*;
pub use bindings::{Binding, BindingAction};
pub(crate) use bindings::{BindingSet, physical_keymap};
use bindings::{BindingConfig, BindingMatch, BindingModeConfig, BindingModifiers};
pub(crate) use bindings::{BindingModes, BindingSet, physical_keymap};
pub(crate) use bindings::{PointerTrigger, ScrollDirection};
use ferese_animation::SpringConfig;
use ferese_core::LayoutMode;
use ferese_layout::{ColumnWidth, Direction, GapConfig, ViewportFocusStrategy};
pub use input_motion::InputSettings;
use input_motion::*;
use outputs::*;
pub use outputs::{LidPolicy, OutputLayout, OutputModeRequest, OutputProfile, OutputSettings, OutputTransform};
pub use input_motion::{AccelerationProfile, InputSettings};
use input_motion::{AnimationsConfig, InputConfig};
use outputs::OutputProfileConfig;
pub use outputs::{
LidPolicy, OutputLayout, OutputModeRequest, OutputProfile, OutputSettings, OutputTransform,
};
use serde::Deserialize;
use smithay::input::keyboard::{Keycode, keysyms, xkb};
pub(crate) use tab_indicator::TabIndicatorSettings;
@@ -34,8 +36,10 @@ pub(crate) use tab_indicator::TabIndicatorSettings;
use crate::window_rules;
use crate::window_rules::{WindowRule, WindowRuleConfig};
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub struct Config {
#[serde(default)]
ui: ferese_config::locale::Settings,
#[serde(default)]
activities: ferese_config::activities::Settings,
#[serde(default)]
@@ -45,6 +49,8 @@ pub struct Config {
#[serde(default)]
lock_screen: crate::session_lock::IdleSettings,
#[serde(default)]
night_light: ferese_config::night_light::Settings,
#[serde(default)]
idle_inhibit: crate::idle_inhibition::Settings,
#[serde(default)]
pub(crate) autostart: Vec<DaemonConfig>,
@@ -75,10 +81,10 @@ pub struct Config {
#[serde(default)]
pub(crate) xwayland: XwaylandConfig,
#[serde(default, rename = "status")]
_status: ShellStatusConfig,
_status: ferese_config::status::Settings,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, PartialEq, schemars::JsonSchema, serde::Serialize)]
pub(crate) struct DaemonConfig {
pub command: Vec<String>,
#[serde(default = "default_true")]
@@ -89,16 +95,6 @@ pub(crate) struct DaemonConfig {
pub nested: bool,
}
// Validate shell-only fields too, before publishing an accepted source.
#[derive(Debug, Default, Deserialize)]
#[allow(dead_code)]
#[serde(deny_unknown_fields)]
struct ShellStatusConfig {
keybinding_guide: Option<bool>,
low_battery_threshold: Option<u8>,
settings_command: Option<Vec<String>>,
}
impl Config {
#[cfg(test)]
pub(crate) fn parse_source(source: &str) -> Result<Self, ConfigError> {
@@ -112,7 +108,10 @@ impl Config {
theme: &ferese_config::theme::ResolvedTheme,
) -> Result<ThemeSettings, ConfigError> {
let config = Self {
theme: serde_json::from_value(serde_json::to_value(&theme.tokens).unwrap())
theme: serde_json::from_value(
serde_json::to_value(&theme.tokens)
.expect("typed configuration and IPC values must serialize to JSON"),
)
.expect("resolved theme matches runtime schema"),
..Self::default()
};
@@ -122,15 +121,24 @@ impl Config {
}
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
self._status.validate().map_err(ConfigError::Binding)?;
if let Some(panels) = &self.panels {
ferese_panel::validate(panels).map_err(ConfigError::Binding)?;
}
self.activities.validate().map_err(ConfigError::Binding)?;
self.notifications.validate().map_err(ConfigError::Binding)?;
self.notifications
.validate()
.map_err(ConfigError::Binding)?;
self.xwayland.validate().map_err(ConfigError::Binding)?;
for daemon in &self.autostart {
if daemon.command.first().is_none_or(|program| program.trim().is_empty()) {
return Err(ConfigError::Binding("autostart command must contain a program".into()));
if daemon
.command
.first()
.is_none_or(|program| program.trim().is_empty())
{
return Err(ConfigError::Binding(
"autostart command must contain a program".into(),
));
}
}
let input_settings = self.input_settings()?;
@@ -138,9 +146,10 @@ impl Config {
let binding_modes = self.binding_modes(&input_settings, &bindings)?;
Ok(crate::RuntimeConfig {
activity_settings: self.activities.clone(),
activity_matcher: ferese_config::activities::Matcher::new(&self.activities)
activity_matcher: ferese_config::activities::Matcher::try_new(&self.activities)
.map_err(ConfigError::Binding)?,
lock_idle: self.lock_screen.validate()?,
night_light: self.night_light.validate().map_err(ConfigError::Binding)?,
idle_inhibit: self.idle_inhibit,
autostart: self.autostart.clone(),
layout_mode: self.layout_mode(),
@@ -196,13 +205,13 @@ impl Config {
}
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
struct WorkspacesConfig {
#[serde(default)]
auto_back_and_forth: bool,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
struct LayoutConfig {
mode: Option<LayoutModeValue>,
#[serde(default = "default_inner_gap")]
@@ -224,14 +233,14 @@ impl Default for LayoutConfig {
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[derive(Clone, Copy, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(rename_all = "lowercase")]
enum LayoutModeValue {
Scrolling,
Tree,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
struct ScrollingConfig {
#[serde(default)]
tab_indicator: tab_indicator::TabIndicatorConfig,
@@ -242,14 +251,14 @@ struct ScrollingConfig {
width_presets: Vec<ColumnWidthValue>,
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[derive(Clone, Copy, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(rename_all = "lowercase")]
enum ColumnDisplayValue {
Stacked,
Tabbed,
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[derive(Clone, Copy, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(rename_all = "snake_case")]
enum FocusStrategyValue {
Minimal,
@@ -257,7 +266,7 @@ enum FocusStrategyValue {
Paged,
}
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(untagged)]
enum ColumnWidthValue {
Proportion(f64),
@@ -412,6 +421,24 @@ pub(crate) fn config_path() -> Option<PathBuf> {
#[cfg(test)]
mod tests {
#[test]
fn configuration_schema_exposes_effective_panel_defaults_and_typed_values() {
let exported = crate::configuration_schema();
assert_eq!(exported["version"], 1);
assert_eq!(
exported["defaults"]["panels"],
serde_json::to_value(
Config::default()
.panels
.unwrap_or_else(|| vec![ferese_panel::Panel::default()])
)
.unwrap()
);
let config: Config = serde_json::from_value(exported["defaults"].clone()).unwrap();
config.runtime_config().unwrap();
assert!(exported["schema"]["properties"].get("ui").is_some());
assert!(exported["schema"]["properties"].get("status").is_some());
}
#[test]
fn geometry_rejects_removed_and_misspelled_keys_in_both_consumption_paths() {
for key in [
@@ -448,7 +475,10 @@ mod tests {
let runtime = config.runtime_config().unwrap();
assert_eq!(runtime.panel_background_opacity, Some(0.8));
assert!((runtime.theme_settings.shell_opacity - 0.3).abs() < 0.001);
let inherited = Config::parse_source("panel main;").unwrap().runtime_config().unwrap();
let inherited = Config::parse_source("panel main;")
.unwrap()
.runtime_config()
.unwrap();
assert!(inherited.panel_background_opacity.is_none());
}
@@ -472,7 +502,12 @@ mod tests {
#[test]
fn validates_shell_composition_before_publishing_config() {
let source = r#"panel "main" { end { group "status" { item "network" kind="network"; item "clock" kind="clock"; }; }; }"#;
assert!(Config::parse_source(source).unwrap().runtime_config().is_ok());
assert!(
Config::parse_source(source)
.unwrap()
.runtime_config()
.is_ok()
);
assert!(
Config::parse_source(&source.replace("item \"clock\"", "item \"network\""))
.unwrap()
@@ -495,10 +530,14 @@ mod tests {
"panel main {{ style {}; end {{ group status {{ item clock kind=clock; }}; }}; }}",
style.key()
);
Config::parse_source(&source).unwrap().runtime_config().unwrap();
Config::parse_source(&source)
.unwrap()
.runtime_config()
.unwrap();
}
for surface in ["none", "inset", "island"] {
let invalid = format!("panel main {{ end {{ group status {{ surface {surface}; }}; }}; }}");
let invalid =
format!("panel main {{ end {{ group status {{ surface {surface}; }}; }}; }}");
assert!(Config::parse_source(&invalid).is_err());
}
assert!(Config::parse_source("panel main { group-surface invalid; }").is_err());
@@ -509,11 +548,18 @@ mod tests {
#[test]
fn group_padding_is_validated_before_live_publication() {
for padding in ["0", "4.5", "12", "32"] {
let source = format!("panel main {{ end {{ group status {{ island-padding {padding}; }}; }}; }}");
Config::parse_source(&source).unwrap().runtime_config().unwrap();
let source = format!(
"panel main {{ end {{ group status {{ island-padding {padding}; }}; }}; }}"
);
Config::parse_source(&source)
.unwrap()
.runtime_config()
.unwrap();
}
for padding in ["-1", "32.5", "\"small\""] {
let source = format!("panel main {{ end {{ group status {{ island-padding {padding}; }}; }}; }}");
let source = format!(
"panel main {{ end {{ group status {{ island-padding {padding}; }}; }}; }}"
);
assert!(
Config::parse_source(&source)
.map(|config| config.runtime_config().is_err())
@@ -526,7 +572,8 @@ mod tests {
fn rejects_removed_physics_keys_before_publication() {
for property in ["spring", "viewport-spring"] {
for coefficient in ["mass", "stiffness", "damping", "damping-ratio"] {
let source = format!("animations {{ {property} {{ {coefficient} 0; overshoot #true; }}; }}");
let source =
format!("animations {{ {property} {{ {coefficient} 0; overshoot #true; }}; }}");
let error = Config::parse_source(&source).unwrap_err().to_string();
assert!(error.contains("unknown field"), "{error}");
}
@@ -534,14 +581,17 @@ mod tests {
}
use super::*;
use crate::config::appearance::RgbaColor;
use crate::config::bindings::BindingTrigger;
use crate::config::bindings::parse_action;
use crate::config::input_motion::TouchpadSettings;
#[test]
fn display_key_has_a_default_binding_and_can_be_rebound() {
let bindings = parse("").runtime_config().unwrap().bindings;
assert!(bindings.iter().any(
|binding| binding.trigger == BindingTrigger::Keysym(keysyms::KEY_XF86Display)
&& binding.action == BindingAction::ToggleDisplayMode
));
assert!(bindings.iter().any(|binding| binding.trigger
== BindingTrigger::Keysym(keysyms::KEY_XF86Display)
&& binding.action == BindingAction::ToggleDisplayMode));
let bindings = parse(
"binding keys=\"XF86Display\" disabled=#true\nbinding keys=\"Super+P\" action=\"toggle-display-mode\"\n",
)
@@ -554,9 +604,8 @@ mod tests {
.any(|binding| binding.trigger == BindingTrigger::Keysym(keysyms::KEY_XF86Display))
);
assert!(
bindings
.iter()
.any(|binding| binding.modifiers.logo && binding.action == BindingAction::ToggleDisplayMode)
bindings.iter().any(|binding| binding.modifiers.logo
&& binding.action == BindingAction::ToggleDisplayMode)
);
}
@@ -577,12 +626,9 @@ mod tests {
.iter()
.any(|binding| binding.trigger == BindingTrigger::Keysym(keysyms::KEY_F1))
);
assert!(
bindings
.iter()
.any(|binding| binding.trigger == BindingTrigger::Keysym(keysyms::KEY_F2)
&& binding.action == BindingAction::ToggleKeybindingGuide)
);
assert!(bindings.iter().any(|binding| binding.trigger
== BindingTrigger::Keysym(keysyms::KEY_F2)
&& binding.action == BindingAction::ToggleKeybindingGuide));
}
#[test]
fn workspace_back_and_forth_config_is_optional_and_bindable() {
@@ -632,7 +678,12 @@ mod tests {
BindingAction::FocusMru(false),
BindingAction::FocusMru(true),
] {
assert!(defaults.bindings.iter().any(|binding| binding.action == action));
assert!(
defaults
.bindings
.iter()
.any(|binding| binding.action == action)
);
}
for (name, action) in [
("focus-last-window", BindingAction::FocusLastWindow),
@@ -651,7 +702,8 @@ mod tests {
false,
true,
false
) && binding.action == BindingAction::FocusMru(false)));
) && binding.action
== BindingAction::FocusMru(false)));
assert!(bindings.iter().any(|binding| binding.matches(
23_u32.into(),
&[keysyms::KEY_Tab],
@@ -659,7 +711,8 @@ mod tests {
false,
true,
true
) && binding.action == BindingAction::FocusMru(true)));
) && binding.action
== BindingAction::FocusMru(true)));
}
#[test]
@@ -674,8 +727,14 @@ mod tests {
let source = "input {\n touchpad {\n swipe-threshold 96\n }\n}\nbinding keys=\"Swipe3Up\" action=\"toggle-overview\"\noutput-profile name=\"desk\" {\n output match=\"DP-1\" scale=1.5 {\n position 0 0\n }\n}\nautostart {\n command \"program\" \"argument with space\"\n}\n";
let config = Config::parse_source(source).unwrap();
config.runtime_config().unwrap();
assert_eq!(config.input_settings().unwrap().touchpad.swipe_threshold, 96);
assert_eq!(config.output_profiles().unwrap()[0].outputs[0].position, Some([0, 0]));
assert_eq!(
config.input_settings().unwrap().touchpad.swipe_threshold,
96
);
assert_eq!(
config.output_profiles().unwrap()[0].outputs[0].position,
Some([0, 0])
);
}
#[test]
@@ -713,7 +772,9 @@ mod tests {
);
assert_eq!(config.xwayland, XwaylandConfig::default());
assert!(config.xwayland.enabled);
let runtime = config.runtime_config().expect("the existing config stays valid");
let runtime = config
.runtime_config()
.expect("the existing config stays valid");
assert!(runtime.xwayland.enabled);
}
@@ -722,7 +783,10 @@ mod tests {
let config = parse("");
assert_eq!(config.layout_mode(), LayoutMode::Scrolling);
assert_eq!(config.default_column_width().unwrap(), ColumnWidth::Proportion(0.5));
assert_eq!(
config.default_column_width().unwrap(),
ColumnWidth::Proportion(0.5)
);
assert_eq!(config.gap_config().unwrap(), GapConfig::default());
}
@@ -732,7 +796,10 @@ mod tests {
let numeric = parse("scrolling {\n default-column-width 1.0\n}\n");
assert_eq!(full.default_column_width().unwrap(), ColumnWidth::Full);
assert_eq!(numeric.default_column_width().unwrap(), ColumnWidth::Proportion(1.0));
assert_eq!(
numeric.default_column_width().unwrap(),
ColumnWidth::Proportion(1.0)
);
}
#[test]
@@ -756,10 +823,19 @@ mod tests {
let minimal = parse("");
let centered = parse("scrolling {\n focus-strategy \"center_on_focus\"\n}\n");
let paged = parse("scrolling {\n focus-strategy \"paged\"\n}\n");
assert_eq!(paged.scrolling_focus_strategy(), ViewportFocusStrategy::Paged);
assert_eq!(
paged.scrolling_focus_strategy(),
ViewportFocusStrategy::Paged
);
assert_eq!(minimal.scrolling_focus_strategy(), ViewportFocusStrategy::Minimal);
assert_eq!(centered.scrolling_focus_strategy(), ViewportFocusStrategy::Center);
assert_eq!(
minimal.scrolling_focus_strategy(),
ViewportFocusStrategy::Minimal
);
assert_eq!(
centered.scrolling_focus_strategy(),
ViewportFocusStrategy::Center
);
}
#[test]
@@ -801,7 +877,8 @@ mod tests {
#[test]
fn parses_and_validates_layout_gaps() {
let configured = parse("layout {\n inner-gap 6.0\n outer-gap 14.0\n smart-gaps #true\n}\n");
let configured =
parse("layout {\n inner-gap 6.0\n outer-gap 14.0\n smart-gaps #true\n}\n");
let invalid = parse("layout {\n outer-gap -1.0\n}\n");
assert_eq!(
@@ -839,9 +916,11 @@ mod tests {
"theme {\n colors {\n border \"#11223344\"\n accent \"#AABBCC\"\n shadow \"#01020380\"\n }\n geometry {\n border-width 1.5\n focus-ring-width 3.0\n window-radius 12.0\n }\n shadow {\n soft {\n offset-y -2.0\n blur 24.0\n opacity 0.4\n }\n }\n material {\n style \"translucent\"\n }\n}\n",
);
let invalid_color = parse("theme {\n colors {\n accent \"blue\"\n }\n}\n");
let invalid_radius = parse("theme {\n geometry {\n window-radius -1.0\n }\n}\n");
let invalid_opacity =
parse("theme {\n shadow {\n soft {\n opacity 1.1\n }\n }\n}\n");
let invalid_radius =
parse("theme {\n geometry {\n window-radius -1.0\n }\n}\n");
let invalid_opacity = parse(
"theme {\n shadow {\n soft {\n opacity 1.1\n }\n }\n}\n",
);
assert_eq!(
configured.theme_settings().unwrap(),
@@ -874,15 +953,24 @@ mod tests {
assert!(invalid_radius.theme_settings().is_err());
assert!(invalid_opacity.theme_settings().is_err());
assert!(
ferese_config::from_str::<Config>("theme {\n material {\n style \"mist\"\n }\n}\n").is_err()
ferese_config::from_str::<Config>(
"theme {\n material {\n style \"mist\"\n }\n}\n"
)
.is_err()
);
}
#[test]
fn materials_default_to_solid_and_reject_removed_style() {
assert_eq!(parse("").theme_settings().unwrap().material_style, MaterialStyle::Solid);
assert_eq!(
parse("").theme_settings().unwrap().material_style,
MaterialStyle::Solid
);
assert!(
ferese_config::from_str::<Config>("theme {\n material {\n style \"glass\"\n }\n}\n").is_err()
ferese_config::from_str::<Config>(
"theme {\n material {\n style \"glass\"\n }\n}\n"
)
.is_err()
);
}
@@ -929,7 +1017,9 @@ mod tests {
"from \"#112233\"\nto \"#445566\"\nangle #inf\n",
] {
assert!(
Config::parse_source(&format!("theme {{ focus-ring {{ gradient {{\n{settings}\n}} }} }}"))
Config::parse_source(&format!(
"theme {{ focus-ring {{ gradient {{\n{settings}\n}} }} }}"
))
.and_then(|config| config.theme_settings())
.is_err()
);
@@ -1020,7 +1110,9 @@ mod tests {
for key in ["active-opacity", "inactive-opacity", "inactive-dim"] {
for value in ["-0.1", "1.1", "#nan", "#inf"] {
assert!(
Config::parse_source(&format!("appearance {{ focus-effect {{ {key} {value}; }} }}"))
Config::parse_source(&format!(
"appearance {{ focus-effect {{ {key} {value}; }} }}"
))
.and_then(|config| config.runtime_config())
.is_err()
);
@@ -1028,7 +1120,9 @@ mod tests {
}
for value in ["-1", "#nan", "#inf"] {
assert!(
Config::parse_source(&format!("appearance {{ focus-effect {{ duration-ms {value}; }} }}"))
Config::parse_source(&format!(
"appearance {{ focus-effect {{ duration-ms {value}; }} }}"
))
.and_then(|config| config.runtime_config())
.is_err()
);
@@ -1052,13 +1146,16 @@ mod tests {
config.input_settings().unwrap(),
InputSettings {
focus_follows_mouse: true,
mouse: Default::default(),
xkb_layout: "us,de".to_owned(),
xkb_variant: ",nodeadkeys".to_owned(),
xkb_options: vec!["grp:alt_shift_toggle".to_owned()],
repeat_rate: 30,
repeat_delay_ms: 450,
touchpad: TouchpadSettings {
accel_speed: 0.0,
tap: false,
disable_while_mouse_connected: false,
natural_scroll: false,
disable_while_typing: true,
swipe_threshold: 80,
@@ -1106,7 +1203,9 @@ mod tests {
);
for threshold in [0, 15, 1001] {
assert!(
parse(&format!("input {{ touchpad {{ swipe-threshold {threshold}; }} }}"))
parse(&format!(
"input {{ touchpad {{ swipe-threshold {threshold}; }} }}"
))
.input_settings()
.is_err()
);
@@ -1130,8 +1229,9 @@ mod tests {
#[test]
fn guide_uses_effective_bindings_including_overrides_and_unbindings() {
let config =
parse("binding keys=\"Super+Q\" action=\"none\"\nbinding keys=\"Super+Tab\" action=\"toggle-floating\"\n");
let config = parse(
"binding keys=\"Super+Q\" action=\"none\"\nbinding keys=\"Super+Tab\" action=\"toggle-floating\"\n",
);
let input = config.input_settings().unwrap();
let map = physical_keymap(&input).unwrap();
let entries = config
@@ -1140,12 +1240,13 @@ mod tests {
.iter()
.filter_map(|binding| binding.guide_entry(Some(&map)))
.collect::<Vec<_>>();
assert!(!entries.iter().any(|entry| entry["description"] == "Close window"));
assert!(
entries
!entries
.iter()
.any(|entry| entry["keys"] == "Super + Tab" && entry["description"] == "Toggle floating window")
.any(|entry| entry["description"] == "Close window")
);
assert!(entries.iter().any(|entry| entry["keys"] == "Super + Tab"
&& entry["description"] == "Toggle floating window"));
assert!(entries.iter().any(|entry| entry["keys"] == "Super + Enter"));
}
@@ -1154,7 +1255,11 @@ mod tests {
let defaults = Config::default().runtime_config().unwrap();
for (key, name, action) in [
(keysyms::KEY_XF86AudioPlay, "media-play-pause", "play-pause"),
(keysyms::KEY_XF86AudioPause, "media-play-pause", "play-pause"),
(
keysyms::KEY_XF86AudioPause,
"media-play-pause",
"play-pause",
),
(keysyms::KEY_XF86AudioNext, "media-next", "next"),
(keysyms::KEY_XF86AudioPrev, "media-previous", "previous"),
] {
@@ -1183,7 +1288,9 @@ mod tests {
parse("scrolling { default-column-display \"tabbed\"; }\n").default_column_display(),
ferese_layout::ColumnDisplay::Tabbed
);
assert!(Config::parse_source("scrolling { default-column-display \"unknown\"; }\n").is_err());
assert!(
Config::parse_source("scrolling { default-column-display \"unknown\"; }\n").is_err()
);
}
#[test]
@@ -1211,7 +1318,7 @@ mod tests {
let input = config.input_settings().unwrap();
let bindings = config.bindings(&input).unwrap();
assert_eq!(bindings.len(), 62);
assert_eq!(bindings.len(), 65);
for (shift, action) in [
(false, BindingAction::ToggleMaximized),
(true, BindingAction::ToggleFullscreen),
@@ -1221,7 +1328,15 @@ mod tests {
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_f)
&& binding.action == action));
}
assert!(bindings.iter().any(|binding| binding.action == BindingAction::Exit));
assert!(
bindings
.iter()
.any(|binding| binding.action == BindingAction::Exit)
);
assert!(bindings.iter().any(|binding| binding.modifiers.logo
&& !binding.modifiers.shift
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_space)
&& binding.action == BindingAction::ToggleLauncher));
assert!(bindings.iter().any(|binding| binding.modifiers.logo
&& binding.modifiers.shift
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_t)
@@ -1235,13 +1350,16 @@ mod tests {
binding.modifiers.logo
&& binding.modifiers.shift
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_s)
&& binding.action == BindingAction::Spawn(vec!["ferese-screenshot".to_owned()].into())
&& binding.action
== BindingAction::Spawn(vec!["ferese-screenshot".to_owned()].into())
}));
assert!(bindings.iter().any(|binding| {
binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Print)
&& binding.modifiers == BindingModifiers::default()
&& binding.action
== BindingAction::Spawn(vec!["ferese-screenshot".to_owned(), "--full".to_owned()].into())
== BindingAction::Spawn(
vec!["ferese-screenshot".to_owned(), "--full".to_owned()].into(),
)
}));
assert!(bindings.iter().any(|binding| {
binding.modifiers.logo
@@ -1259,21 +1377,29 @@ mod tests {
let input = replaced.input_settings().unwrap();
let bindings = replaced.bindings(&input).unwrap();
assert_eq!(bindings.len(), 62);
assert_eq!(bindings.len(), 65);
assert!(bindings.iter().any(|binding| {
binding.action
== BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into())
== BindingAction::Spawn(
vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into(),
)
}));
let input = unbound.input_settings().unwrap();
let bindings = unbound.bindings(&input).unwrap();
assert_eq!(bindings.len(), 61);
assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close));
assert_eq!(bindings.len(), 64);
assert!(
!bindings
.iter()
.any(|binding| binding.action == BindingAction::Close)
);
}
#[test]
fn accepts_physical_xkb_key_names() {
let config = parse("binding keys=\"Super+AD06\" match=\"physical\" action=\"focus\" argument=\"left\"\n");
let config = parse(
"binding keys=\"Super+AD06\" match=\"physical\" action=\"focus\" argument=\"left\"\n",
);
let input = config.input_settings().unwrap();
let bindings = config.bindings(&input).unwrap();
@@ -1285,9 +1411,13 @@ mod tests {
#[test]
fn rejects_duplicate_or_invalid_user_bindings() {
let duplicate = parse("binding keys=\"Super+Q\" action=\"close\"\nbinding keys=\"logo+q\" action=\"close\"\n");
let missing_command = parse("binding keys=\"Super+Enter\" action=\"spawn\" argument=\"missing\"\n");
let invalid_argument = parse("binding keys=\"Super+Q\" action=\"close\" argument=\"left\"\n");
let duplicate = parse(
"binding keys=\"Super+Q\" action=\"close\"\nbinding keys=\"logo+q\" action=\"close\"\n",
);
let missing_command =
parse("binding keys=\"Super+Enter\" action=\"spawn\" argument=\"missing\"\n");
let invalid_argument =
parse("binding keys=\"Super+Q\" action=\"close\" argument=\"left\"\n");
for config in [duplicate, missing_command, invalid_argument] {
let input = config.input_settings().unwrap();
@@ -1301,7 +1431,12 @@ mod tests {
"window-rule app-id=\"org.example.Editor\" workspace=3 floating=#true width=900.0 height=600.0 min-width=500 min-height=400 fullscreen=#false block-out-from-screencasts=#true\n",
);
let rules = config.window_rules().unwrap();
let result = window_rules::resolve(&rules, Some("org.example.editor.desktop"), Some("Document"), false);
let result = window_rules::resolve(
&rules,
Some("org.example.editor.desktop"),
Some("Document"),
false,
);
assert_eq!(result.workspace, Some(3));
assert_eq!(result.floating, Some(true));
@@ -1399,24 +1534,34 @@ mod tests {
] {
assert!(parse(source).output_profiles().is_err(), "{source}");
}
assert!(Config::parse_source("output-profile a layout=\"docked\" { output eDP-1; }").is_err());
assert!(
Config::parse_source("output-profile a layout=\"docked\" { output eDP-1; }").is_err()
);
}
#[test]
fn automatic_refresh_is_opt_in_and_rejects_wrong_types() {
let config = parse("output-profile laptop { output eDP-1 mode=\"2880x1800@120\" auto-refresh=#true; }");
let config = parse(
"output-profile laptop { output eDP-1 mode=\"2880x1800@120\" auto-refresh=#true; }",
);
assert!(config.output_profiles().unwrap()[0].outputs[0].auto_refresh);
let manual = parse("output-profile laptop { output eDP-1 mode=\"2880x1800@60\"; }");
assert!(!manual.output_profiles().unwrap()[0].outputs[0].auto_refresh);
assert!(Config::parse_source("output-profile laptop { output eDP-1 auto-refresh=\"yes\"; }").is_err());
assert!(
Config::parse_source("output-profile laptop { output eDP-1 auto-refresh=\"yes\"; }")
.is_err()
);
}
#[test]
fn rejects_invalid_output_profiles() {
let invalid_scale = parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" scale=0.0\n}\n");
let invalid_mode = parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" mode=\"native\"\n}\n");
let duplicate =
parse("output-profile name=\"bad\" {\n output match=\"eDP-1\"\n output match=\"eDP-1\"\n}\n");
let invalid_scale =
parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" scale=0.0\n}\n");
let invalid_mode =
parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" mode=\"native\"\n}\n");
let duplicate = parse(
"output-profile name=\"bad\" {\n output match=\"eDP-1\"\n output match=\"eDP-1\"\n}\n",
);
assert!(invalid_scale.output_profiles().is_err());
assert!(invalid_mode.output_profiles().is_err());
+78 -31
View File
@@ -1,25 +1,47 @@
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 focus_effect_settings(&self) -> Result<FocusEffectSettings, ConfigError> {
let effect = &self.appearance.focus_effect;
Ok(FocusEffectSettings {
enabled: effect.enabled,
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(effect.duration_ms, "appearance.focus_effect.duration_ms")?,
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(
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.unwrap_or(14.0),
@@ -29,13 +51,22 @@ impl Config {
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_raised_color: parse_color(&self.theme.colors.surface_raised, "colors.surface_raised")?,
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
@@ -51,8 +82,15 @@ 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,
@@ -164,14 +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,
@@ -188,7 +226,11 @@ 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",
)
};
let gradient = self
.gradient
@@ -236,7 +278,9 @@ pub struct ThemeSettings {
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,
@@ -244,7 +288,7 @@ pub enum MaterialStyle {
Solid,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub(super) struct AppearanceConfig {
#[serde(default)]
@@ -254,16 +298,18 @@ pub(super) struct AppearanceConfig {
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FocusEffectSettings {
pub enabled: bool,
pub blur: bool,
pub active_opacity: f64,
pub inactive_opacity: f64,
pub inactive_dim: f64,
pub duration_ms: f64,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)]
pub(super) struct FocusEffectConfig {
pub(super) enabled: bool,
pub(super) blur: bool,
pub(super) active_opacity: f64,
pub(super) inactive_opacity: f64,
pub(super) inactive_dim: f64,
@@ -274,6 +320,7 @@ impl Default for FocusEffectConfig {
fn default() -> Self {
Self {
enabled: true,
blur: true,
active_opacity: 1.0,
inactive_opacity: 1.0,
inactive_dim: 0.0,
@@ -282,7 +329,7 @@ impl Default for FocusEffectConfig {
}
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeConfig {
#[serde(default)]
pub(super) border: BorderPaintConfig,
@@ -304,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,
@@ -355,7 +402,7 @@ 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,
@@ -384,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,
@@ -410,7 +457,7 @@ 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")]
+222 -45
View File
@@ -1,18 +1,26 @@
use std::sync::Arc;
use super::*;
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 {
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()],
@@ -36,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);
@@ -56,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"
)));
}
}
@@ -98,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: [
@@ -117,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,
@@ -128,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(),
));
@@ -152,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(),
));
@@ -170,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 {
@@ -198,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() {
@@ -208,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))
}
@@ -264,10 +328,20 @@ 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()?;
@@ -275,7 +349,9 @@ pub(super) fn parse_action(
}
"workspace-next" | "workspace-previous" => {
no_argument()?;
Ok(BindingAction::SwitchRelativeWorkspace(action == "workspace-next"))
Ok(BindingAction::SwitchRelativeWorkspace(
action == "workspace-next",
))
}
"workspace-back-and-forth" => {
no_argument()?;
@@ -310,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)
@@ -337,7 +419,9 @@ pub(super) fn parse_action(
no_argument()?;
Ok(BindingAction::ToggleColumnTabbed)
}
"join-tab" => Ok(BindingAction::JoinTab(parse_direction(required_argument()?)?)),
"join-tab" => Ok(BindingAction::JoinTab(parse_direction(
required_argument()?
)?)),
"leave-tab" => {
no_argument()?;
Ok(BindingAction::LeaveTab)
@@ -378,6 +462,10 @@ pub(super) fn parse_action(
no_argument()?;
Ok(BindingAction::ToggleDisplayMode)
}
"toggle-launcher" => {
no_argument()?;
Ok(BindingAction::ToggleLauncher)
}
"toggle-keybinding-guide" => {
no_argument()?;
Ok(BindingAction::ToggleKeybindingGuide)
@@ -392,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")),
@@ -441,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",
@@ -458,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,
@@ -479,6 +590,8 @@ pub struct Binding {
pub(super) enum BindingTrigger {
Keysym(u32),
Physical(Keycode),
Pointer(PointerTrigger),
SuperTap,
Swipe {
fingers: u32,
direction: crate::gestures::SwipeDirection,
@@ -504,6 +617,8 @@ pub enum BindingAction {
FocusLastWindow,
FocusFloating,
FocusMru(bool),
PointerMove,
PointerResize,
Move(ferese_layout::Direction),
Resize(ferese_layout::Direction),
SwitchWorkspace(u8),
@@ -524,6 +639,7 @@ pub enum BindingAction {
ToggleFloating,
ToggleOverview,
ToggleKeybindingGuide,
ToggleLauncher,
ToggleDisplayMode,
Media(&'static str),
}
@@ -551,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()));
@@ -570,23 +689,41 @@ 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,
}
}
@@ -594,7 +731,11 @@ impl Binding {
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 {
@@ -613,21 +754,30 @@ 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::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(),
@@ -636,7 +786,12 @@ impl Binding {
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::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(),
@@ -648,6 +803,7 @@ impl Binding {
.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();
@@ -662,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() {
@@ -687,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")]
+87 -18
View File
@@ -1,26 +1,42 @@
use super::*;
#[cfg(test)]
use crate::config::BindingAction;
#[cfg(test)]
use ferese_layout::Direction;
#[cfg(test)]
use smithay::input::keyboard::keysyms;
/// Immutable bindings and their keyboard index are replaced together on reload.
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>,
keyboard: HashMap<BindingIdentity, usize>,
index: HashMap<BindingIdentity, usize>,
}
impl From<Vec<Binding>> for BindingSet {
fn from(ordered: Vec<Binding>) -> Self {
let mut keyboard = HashMap::with_capacity(ordered.len());
let mut index = HashMap::with_capacity(ordered.len());
for (index, binding) in ordered.iter().enumerate() {
for (position, binding) in ordered.iter().enumerate() {
let identity = binding_identity_for_runtime(binding);
if identity.match_mode != BindingMatch::Swipe {
keyboard.entry(identity).or_insert(index);
if matches!(
identity.match_mode,
BindingMatch::Keysym
| BindingMatch::Physical
| BindingMatch::Pointer
| BindingMatch::SuperTap
) {
index.entry(identity).or_insert(position);
}
}
Self { ordered, keyboard }
Self { ordered, index }
}
}
@@ -33,6 +49,36 @@ impl std::ops::Deref for BindingSet {
}
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,
@@ -42,14 +88,19 @@ impl BindingSet {
alt: bool,
shift: bool,
) -> Option<&Binding> {
let modifiers = BindingModifiers { logo, ctrl, alt, shift };
let physical = self.keyboard.get(&BindingIdentity {
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.keyboard.get(&BindingIdentity {
self.index.get(&BindingIdentity {
modifiers,
match_mode: BindingMatch::Keysym,
key,
@@ -82,6 +133,9 @@ impl BindingSet {
#[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 {
@@ -96,7 +150,10 @@ mod tests {
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::Physical(code),
BindingAction::ToggleFloating,
),
binding_for(BindingTrigger::Keysym(keysyms::KEY_a), BindingAction::Exit),
binding_for(BindingTrigger::Keysym(keysyms::KEY_b), BindingAction::None),
];
@@ -125,7 +182,10 @@ mod tests {
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::Physical(Keycode::new(38)),
BindingAction::None,
),
binding_for(BindingTrigger::Keysym(keysyms::KEY_a), BindingAction::Close),
binding_for(
BindingTrigger::Swipe {
@@ -138,7 +198,8 @@ mod tests {
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);
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 [
@@ -164,7 +225,10 @@ mod tests {
#[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)]);
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)
@@ -173,7 +237,10 @@ mod tests {
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, &[], false, false, false, false),
None
);
assert_eq!(
bindings.action_for_key(code, &[keysyms::KEY_b], false, false, false, false),
Some(&BindingAction::None)
@@ -182,9 +249,11 @@ mod tests {
#[test]
fn cloning_spawn_actions_shares_the_command_storage() {
let action = BindingAction::Spawn(vec!["foot".into(), "--app-id".into(), "work".into()].into());
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 {
let (BindingAction::Spawn(original), BindingAction::Spawn(cloned)) = (&action, cloned)
else {
unreachable!();
};
assert!(Arc::ptr_eq(original, &cloned));
+52 -12
View File
@@ -1,6 +1,12 @@
use super::*;
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)]
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct BindingModeConfig {
name: String,
@@ -75,16 +81,24 @@ impl Config {
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::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| {
&& (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)
.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 {:?}",
@@ -95,12 +109,17 @@ impl Config {
}
modes.maps.insert(mode.name.clone(), bindings.into());
}
for binding in default.iter().chain(modes.maps.values().flat_map(|map| map.iter())) {
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:?}")));
return Err(ConfigError::Binding(format!(
"unknown binding mode {name:?}"
)));
}
}
Ok(modes)
@@ -131,7 +150,14 @@ mod tests {
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),
selected.action_for_key(
Keycode::new(43),
&[keysyms::KEY_h],
false,
false,
false,
false
),
Some(&BindingAction::Resize(Direction::Left))
);
assert_eq!(
@@ -139,11 +165,25 @@ mod tests {
Some(&BindingAction::Resize(Direction::Up))
);
assert_eq!(
selected.action_for_key(Keycode::new(43), &[keysyms::KEY_h], true, false, false, false),
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),
selected.action_for_key(
Keycode::new(36),
&[keysyms::KEY_Return],
true,
false,
false,
false
),
None
);
assert!(!modes.set("missing"));
+161
View File
@@ -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());
}
}
+156 -14
View File
@@ -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,
@@ -48,23 +90,24 @@ impl Config {
}
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> {
self.animations.spring_config().map_err(ConfigError::Binding)
self.animations
.spring_config()
.map_err(ConfigError::Binding)
}
pub fn viewport_spring_config(&self) -> Result<SpringConfig, ConfigError> {
self.animations.viewport_config().map_err(ConfigError::Binding)
}
}
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)
}
}
@@ -81,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,
@@ -95,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,
@@ -111,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 {
@@ -123,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")]
@@ -143,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(),
@@ -151,3 +206,90 @@ impl Default for TouchpadConfig {
}
pub(super) type AnimationsConfig = ferese_animation::MotionSettings;
#[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(
Clone, Copy, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(rename_all = "lowercase")]
pub enum AccelerationProfile {
Adaptive,
Flat,
}
#[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 {
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,
}
}
}
+23 -6
View File
@@ -1,4 +1,7 @@
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 {
@@ -58,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,
@@ -66,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 {
@@ -74,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 {
+12 -4
View File
@@ -20,7 +20,10 @@ pub(crate) fn read_source(path: &Path) -> Result<String, String> {
Ok(source)
}
pub(crate) fn prepare(source: &str, directory: &Path) -> Result<(Config, RuntimeConfig, Candidate), String> {
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)
}
@@ -68,9 +71,11 @@ pub(crate) fn prepare_document(
) -> 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())?;
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())?;
runtime.theme_settings =
Config::resolved_theme_settings(&candidate.theme).map_err(|e| e.to_string())?;
Ok((config, runtime, candidate))
}
@@ -119,7 +124,10 @@ mod tests {
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!(
valid.runtime.panel_style,
ferese_panel::PanelStyle::Floating
);
assert_eq!(
Document::parse(valid.source.as_deref().unwrap())
.unwrap()
+29 -12
View File
@@ -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();
@@ -70,13 +76,16 @@ impl Config {
)));
}
if let Some(source) = &profile.mirror_source
&& !outputs.iter().any(|output| &output.matcher == source && output.enabled)
&& !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
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"
@@ -101,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"))?;
@@ -180,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]
@@ -200,7 +213,9 @@ pub enum OutputTransform {
Flipped270,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq)]
#[derive(
Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum LidPolicy {
#[default]
@@ -212,7 +227,9 @@ const fn default_confirm_timeout() -> u32 {
15
}
#[derive(Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq)]
#[derive(
Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum OutputLayout {
InternalOnly,
@@ -222,7 +239,7 @@ pub enum OutputLayout {
Mirror,
}
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct OutputProfileConfig {
pub(super) name: String,
#[serde(default)]
@@ -237,7 +254,7 @@ pub(super) struct OutputProfileConfig {
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,
+38 -10
View File
@@ -1,6 +1,6 @@
use super::*;
use super::{Config, ConfigError, Deserialize, parse_color};
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)]
pub(super) struct TabIndicatorConfig {
width: f64,
@@ -74,8 +74,12 @@ impl Config {
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 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")?;
@@ -85,11 +89,21 @@ impl Config {
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_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")?,
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,
@@ -111,9 +125,18 @@ mod tests {
#[test]
fn indicator_defaults_overrides_and_validation() {
let c = Config::parse_source("").unwrap().tab_indicator_settings().unwrap();
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),
(
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);
@@ -123,7 +146,10 @@ mod tests {
)
.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.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",
@@ -133,7 +159,9 @@ mod tests {
"flash-duration-ms 60001",
"active-color \"invalid\"",
] {
let config = Config::parse_source(&format!("scrolling {{ tab-indicator {{ {source}; }}; }}")).unwrap();
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());
+5 -3
View File
@@ -2,7 +2,9 @@ use std::path::PathBuf;
use serde::Deserialize;
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[derive(
Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum XwaylandStartup {
#[default]
@@ -10,7 +12,7 @@ pub(crate) enum XwaylandStartup {
Eager,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct XwaylandConfig {
pub enabled: bool,
@@ -79,7 +81,7 @@ mod tests {
#[test]
fn startup_mode_accepts_kebab_case() {
#[derive(Deserialize)]
#[derive(Deserialize, schemars::JsonSchema, serde::Serialize)]
struct Wrapper {
startup: XwaylandStartup,
}
+118 -37
View File
@@ -42,7 +42,11 @@ impl CursorFrame {
)
}
pub(crate) fn physical_location(&self, pointer: Point<f64, Logical>, scale: f64) -> Point<f64, Physical> {
pub(crate) fn physical_location(
&self,
pointer: Point<f64, Logical>,
scale: f64,
) -> Point<f64, Physical> {
self.logical_rect(pointer).loc.to_physical(scale)
}
}
@@ -64,7 +68,10 @@ impl NamedCursor {
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)));
return (
index,
Some(frame.delay - Duration::from_nanos(offset as u64)),
);
}
offset -= frame.delay.as_nanos();
}
@@ -97,7 +104,9 @@ impl Loader {
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()))
CursorTheme::load(
&std::env::var("XCURSOR_THEME").unwrap_or_else(|_| "default".into()),
)
});
load_named_cursor(theme, icon, scale)
}
@@ -123,23 +132,40 @@ impl Loader {
loop {
let (requests, ready) = &*worker;
let mut requests = ready
.wait_while(requests.lock().unwrap(), |requests| {
!requests.closed && requests.pending.is_none()
})
.unwrap();
.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().unwrap();
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() {
if sender
.send(Loaded {
icon,
scale,
cursor,
})
.is_err()
{
break;
}
signal.wakeup();
worker.0.lock().unwrap().in_flight = None;
worker
.0
.lock()
.expect("cursor decode queue must remain consistent across workers")
.in_flight = None;
}
})?;
Ok(Self { shared, results })
@@ -147,7 +173,11 @@ impl Loader {
pub(crate) fn request(&self, icon: CursorIcon, scale: i32) {
let request = (icon, scale);
let mut requests = self.shared.0.lock().unwrap();
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;
}
@@ -158,7 +188,11 @@ impl Loader {
impl Drop for Loader {
fn drop(&mut self) {
let mut requests = self.shared.0.lock().unwrap();
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();
@@ -174,6 +208,12 @@ pub(crate) struct Animation {
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 {
@@ -218,7 +258,12 @@ impl crate::Ferese {
}
.unwrap_or(1)
.clamp(1, 8);
while let Ok(Loaded { icon, scale, cursor }) = self.cursor_loader.results.try_recv() {
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 {
@@ -230,20 +275,22 @@ impl crate::Ferese {
&& 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 {
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).unwrap().bytes;
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);
self.cursor_redraw_pending |=
crate::backends::direct::cursor_animation_visible(self);
}
}
@@ -257,7 +304,8 @@ impl crate::Ferese {
.get(&icon)
.is_none_or(|cursor| cursor.requested_scale != self.cursor_animation.buffer_scale)
{
self.cursor_loader.request(icon, self.cursor_animation.buffer_scale);
self.cursor_loader
.request(icon, self.cursor_animation.buffer_scale);
}
let animated = icon
.and_then(|icon| {
@@ -279,13 +327,14 @@ impl crate::Ferese {
if !visible {
return;
}
let icon = icon.unwrap();
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))
.unwrap();
let (frame, remaining) = cursor.sample(now.saturating_duration_since(self.cursor_animation.epoch));
.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;
@@ -293,13 +342,14 @@ impl crate::Ferese {
if let Some(remaining) = remaining
&& self.cursor_animation.timer.is_none()
{
match self
.loop_handle
.insert_source(Timer::from_deadline(now + remaining), |_, _, state| {
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"),
}
@@ -321,7 +371,9 @@ fn load_named_cursor(theme: &CursorTheme, icon: CursorIcon, scale: i32) -> Optio
return None;
}
let mut bytes = Vec::new();
file.take((MAX_FILE_BYTES + 1) as u64).read_to_end(&mut bytes).ok()?;
file.take((MAX_FILE_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.ok()?;
parse_cursor(&bytes, requested, scale)
})
}
@@ -348,7 +400,10 @@ fn parse_cursor(bytes: &[u8], requested: u32, scale: i32) -> Option<NamedCursor>
}
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() {
if header < 16
|| count > MAX_FRAMES
|| header.checked_add(count.checked_mul(12)?)? > bytes.len()
{
return None;
}
let mut images = Vec::new();
@@ -371,7 +426,14 @@ fn parse_cursor(bytes: &[u8], requested: u32, scale: i32) -> Option<NamedCursor>
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 {
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)?;
@@ -382,7 +444,15 @@ fn parse_cursor(bytes: &[u8], requested: u32, scale: i32) -> Option<NamedCursor>
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));
images.push((
nominal,
width,
height,
xhot,
yhot,
word(bytes, pos + 32)?,
rgba,
));
}
let base = images
.iter()
@@ -396,7 +466,9 @@ fn parse_cursor(bytes: &[u8], requested: u32, scale: i32) -> Option<NamedCursor>
.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) {
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;
@@ -404,20 +476,29 @@ fn parse_cursor(bytes: &[u8], requested: u32, scale: i32) -> Option<NamedCursor>
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)?)?;
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 =
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),
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,
+56 -14
View File
@@ -7,15 +7,25 @@ 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));
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));
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]
[
36, 0xfffd0002, size, 1, width, width, hotspot, hotspot, delay,
]
.into_iter()
.flat_map(u32::to_le_bytes),
);
@@ -49,12 +59,20 @@ fn unavailable_hidpi_size_preserves_logical_size_and_hotspot() {
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());
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();
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),
@@ -93,7 +111,11 @@ fn rejects_oversized_and_malformed_assets_before_buffer_allocation() {
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(
[0xfffd0002, 1024, 52]
.into_iter()
.flat_map(u32::to_le_bytes),
);
}
bytes.extend(frame);
assert!(parse_cursor(&bytes, 24, 1).is_none());
@@ -133,7 +155,7 @@ fn delayed_loading_does_not_block_cursor_callbacks_or_pointer_dispatch() {
&MotionEvent {
location: (10.0, 20.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: 1,
time: smithay::backend::input::InputTime::from_millis(1),
},
);
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Text));
@@ -143,9 +165,15 @@ fn delayed_loading_does_not_block_cursor_callbacks_or_pointer_dispatch() {
state.named_cursor_frame().is_some(),
"cached fallback must remain visible"
);
assert!(calls.try_recv().is_err(), "decoder is still deliberately blocked");
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);
assert_eq!(
calls.recv_timeout(Duration::from_secs(2)).unwrap().0,
CursorIcon::Text
);
}
#[test]
@@ -162,6 +190,7 @@ fn cursor_animation_advances_and_cancels_on_hide_and_shape_change() {
let output = Output::new(
"cursor-test".into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
@@ -187,7 +216,9 @@ fn cursor_animation_advances_and_cancels_on_hide_and_shape_change() {
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();
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);
@@ -199,7 +230,9 @@ fn cursor_animation_advances_and_cancels_on_hide_and_shape_change() {
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
.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());
@@ -207,9 +240,18 @@ fn cursor_animation_advances_and_cancels_on_hide_and_shape_change() {
#[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::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"]
+34 -11
View File
@@ -98,15 +98,16 @@ impl Runner {
}
if let Some(deadline) = deadline {
let weak = Rc::downgrade(owner);
match state
.loop_handle
.insert_source(Timer::from_deadline(deadline), move |_, _, state| {
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"),
}
@@ -119,7 +120,11 @@ impl Runner {
.iter()
.filter(|c| c.enabled && policy.allows_autostart(c.nested))
{
if self.0.iter().any(|service| service.config.command == config.command) {
if self
.0
.iter()
.any(|service| service.config.command == config.command)
{
continue;
}
if let Some(index) = previous
@@ -187,7 +192,11 @@ impl Runner {
}
}
if !service.finished && now >= service.next_start {
service.child = spawn_client(state, service.config.command.iter(), ClientCapabilities::default());
service.child = spawn_client(
state,
service.config.command.iter(),
ClientCapabilities::default(),
);
service.next_start = now + Duration::from_secs(5);
if service.child.is_none() && !service.config.restart {
service.finished = true;
@@ -225,14 +234,19 @@ mod tests {
let mut events = smithay::reexports::calloop::EventLoop::<usize>::try_new().unwrap();
events
.handle()
.insert_source(Generic::new(fd, Interest::READ, Mode::Level), move |_, _, count| {
.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();
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);
@@ -247,7 +261,12 @@ mod tests {
nested: false,
};
let mut runner = Runner::new(&[config.clone()], SessionPolicy::Desktop);
runner.0[0].child = Some(std::process::Command::new("/bin/sleep").arg("30").spawn().unwrap());
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()], SessionPolicy::Desktop);
@@ -259,8 +278,12 @@ mod tests {
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));
}
}
+7 -2
View File
@@ -46,7 +46,9 @@ impl DisplayPresentation {
#[cfg(test)]
pub fn visible(&self, surface: &Id) -> bool {
self.outputs.values().any(|record| record.displayed.contains(surface))
self.outputs
.values()
.any(|record| record.displayed.contains(surface))
}
pub fn outputs_for<'a>(&'a self, surface: &'a Id) -> impl Iterator<Item = &'a Output> {
@@ -76,6 +78,7 @@ mod tests {
Output::new(
name.into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
@@ -89,6 +92,7 @@ mod tests {
states.states.insert(
id.clone(),
RenderElementState {
needs_capture: false,
visible_area: 20,
presentation_state: RenderElementPresentationState::ZeroCopy,
},
@@ -141,7 +145,8 @@ mod tests {
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;
states.states.get_mut(&id).unwrap().presentation_state =
RenderElementPresentationState::Skipped;
assert!(visible(&states).is_empty());
}
}
+94
View File
@@ -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;
+235
View File
@@ -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"
);
}
+53 -17
View File
@@ -37,7 +37,12 @@ impl<K, C, T> Default for Queue<K, C, T> {
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
|| self
.pending
.iter()
.filter(|entry| entry.client == client)
.count()
>= MAX_PER_CLIENT
{
return Err(value);
}
@@ -61,7 +66,10 @@ impl<K: Eq, C: Eq, T> Queue<K, C, T> {
mut complete: impl FnMut(Pending<K, C, T>),
) {
for _ in 0..self.pending.len() {
let entry = self.pending.pop_front().unwrap();
let entry = self
.pending
.pop_front()
.expect("the bounded drain removes at most the original queue length");
if remove(&entry) {
complete(entry);
} else {
@@ -89,7 +97,8 @@ pub(crate) struct ImportManager {
impl Drop for ImportManager {
fn drop(&mut self) {
self.queue.remove_if(|_| true, |entry| entry.value.notifier.failed());
self.queue
.remove_if(|_| true, |entry| entry.value.notifier.failed());
}
}
@@ -100,10 +109,17 @@ impl ImportManager {
pub(crate) fn remove(&mut self, global: DmabufGlobal) {
self.globals.remove(&global);
self.queue.process(&global, |request| request.notifier.failed());
self.queue
.process(&global, |request| request.notifier.failed());
}
pub(crate) fn enqueue(&mut self, global: DmabufGlobal, buffer: Dmabuf, notifier: ImportNotifier, now: Instant) {
pub(crate) fn enqueue(
&mut self,
global: DmabufGlobal,
buffer: Dmabuf,
notifier: ImportNotifier,
now: Instant,
) {
let Some(client) = notifier.client() else {
notifier.failed();
return;
@@ -114,16 +130,23 @@ impl ImportManager {
return;
}
if let Err(request) = self
.queue
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<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(
@@ -171,16 +194,17 @@ impl Ferese {
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);
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| {
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)
@@ -188,7 +212,8 @@ impl Ferese {
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");
@@ -234,7 +259,9 @@ mod tests {
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();
queue
.enqueue(1, index / MAX_PER_CLIENT, index, now)
.unwrap();
}
assert_eq!(queue.enqueue(1, 99, 100, now), Err(100));
@@ -275,17 +302,26 @@ mod tests {
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();
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,
|_| 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());
+154 -40
View File
@@ -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,11 @@ 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)]
@@ -97,22 +109,52 @@ impl SurfaceEffectsState {
}
fn commit_presentation(&self) {
let Some(pending) = self.pending.lock().unwrap().take() else {
let Some(pending) = self
.pending
.lock()
.expect("surface effect state must not be used after a mutator panics")
.take()
else {
return;
};
*self.role.lock().unwrap() = Some(pending.role);
*self.panel.lock().unwrap() = pending.panel;
*self.regions.lock().unwrap() = Some(pending.regions);
*self.region_opacities.lock().unwrap() = pending.region_opacities;
*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().unwrap() = pending.opacity;
*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 {
self.pending.lock().unwrap().take();
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);
@@ -132,7 +174,11 @@ 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()
}
}
@@ -167,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 {
@@ -234,7 +285,7 @@ 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()
})
}
@@ -246,7 +297,7 @@ pub(crate) fn surface_panel_presentation(surface: &WlSurface) -> Option<PanelPre
.get::<SurfaceEffectsState>()?
.panel
.lock()
.unwrap()
.expect("surface effect state must not be used after a mutator panics")
.clone()
})
}
@@ -282,13 +333,21 @@ pub(crate) fn surface_region_opacities(surface: &WlSurface) -> Vec<u32> {
states
.data_map
.get::<SurfaceEffectsState>()
.map(|effects| effects.region_opacities.lock().unwrap().clone())
.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 {
if !bytes.len().is_multiple_of(4)
|| bytes.len() > ferese_protocols::effects::v1::MAX_REGIONS * 4
{
return None;
}
bytes
@@ -303,7 +362,9 @@ fn decode_region_opacities(bytes: &[u8]) -> Option<Vec<u32>> {
}
fn decode_regions(bytes: &[u8]) -> Option<Vec<[f64; 5]>> {
if !bytes.len().is_multiple_of(20) || bytes.len() > ferese_protocols::effects::v1::MAX_REGIONS * 20 {
if !bytes.len().is_multiple_of(20)
|| bytes.len() > ferese_protocols::effects::v1::MAX_REGIONS * 20
{
return None;
}
bytes
@@ -311,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
@@ -326,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)))
})
}
@@ -363,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)
});
@@ -383,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)
});
@@ -416,8 +483,10 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
fusion,
opacity,
} => {
let (Some(regions), Some(panel)) = (decode_regions(&regions), decode_panel_shapes(&shapes, fusion))
else {
let (Some(regions), Some(panel)) = (
decode_regions(&regions),
decode_panel_shapes(&shapes, fusion),
) else {
return;
};
if panel.shapes.is_empty() != regions.is_empty() {
@@ -425,7 +494,9 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
}
with_states(&surface, |states| {
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
*effects.pending.lock().unwrap() = Some(PendingPresentation {
*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()],
@@ -450,7 +521,9 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
};
with_states(&surface, |states| {
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
*effects.pending.lock().unwrap() = Some(PendingPresentation {
*effects.pending.lock().expect(
"surface effect state must not be used after a mutator panics",
) = Some(PendingPresentation {
panel: None,
role,
regions,
@@ -468,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;
@@ -497,7 +573,10 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
let Some(effects) = states.data_map.get::<SurfaceEffectsState>() else {
return false;
};
let mut old = effects.region_opacities.lock().unwrap();
let mut old = effects
.region_opacities
.lock()
.expect("surface effect state must not be used after a mutator panics");
if *old == opacities {
return false;
}
@@ -517,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(&regions) {
return false;
}
@@ -583,11 +665,26 @@ fn detach(surface: &WlSurface) -> bool {
return false;
};
let role_changed = effects.set_role(None);
effects.region_opacities.lock().unwrap().clear();
effects.pending.lock().unwrap().take();
effects.panel.lock().unwrap().take();
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);
@@ -600,13 +697,23 @@ 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| {
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)
@@ -617,7 +724,11 @@ pub(crate) fn begin_surface_dismiss(surface: &WlSurface) -> bool {
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);
}
});
@@ -733,7 +844,10 @@ mod tests {
#[test]
fn region_opacities_validate_independent_fades() {
let bytes: Vec<_> = [0u32, 500, 1000].into_iter().flat_map(u32::to_ne_bytes).collect();
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());
+82 -25
View File
@@ -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();
+19 -4
View File
@@ -3,7 +3,11 @@ use std::time::Duration;
use crate::config::FocusEffectSettings;
use ferese_layout::WindowId;
pub(crate) fn target(settings: FocusEffectSettings, active: Option<WindowId>, window: WindowId) -> f64 {
pub(crate) fn target(
settings: FocusEffectSettings,
active: Option<WindowId>,
window: WindowId,
) -> f64 {
if !settings.has_effects() || active == Some(window) {
1.0
} else {
@@ -16,12 +20,18 @@ impl FocusEffectSettings {
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) {
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 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 {
@@ -87,7 +97,11 @@ impl BoundedFade {
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 },
if self.target != target {
Duration::ZERO
} else {
delta
},
duration_ms,
)
}
@@ -100,6 +114,7 @@ mod tests {
fn settings() -> FocusEffectSettings {
FocusEffectSettings {
enabled: true,
blur: true,
active_opacity: 1.0,
inactive_opacity: 0.8,
inactive_dim: 0.2,
+138 -20
View File
@@ -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,
}
}
@@ -68,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;
@@ -95,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;
}
@@ -130,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)
@@ -148,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);
}
}
@@ -168,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.
@@ -226,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)
}));
}
@@ -249,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);
@@ -264,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);
@@ -276,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);
@@ -284,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));
@@ -303,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]
@@ -317,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]
@@ -372,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));
@@ -395,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!(
+109
View File
@@ -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.),
]
}
+12 -4
View File
@@ -49,7 +49,11 @@ impl Swipe {
}
}
pub fn finish_at(&mut self, cancelled: bool, time: std::time::Duration) -> Option<SwipeDirection> {
pub fn finish_at(
&mut self,
cancelled: bool,
time: std::time::Duration,
) -> Option<SwipeDirection> {
let Some(direction) = self.direction else {
return self.finish(cancelled);
};
@@ -76,11 +80,14 @@ impl Swipe {
};
// 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) {
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)
(!cancelled
&& ferese_animation::gesture::project(progress, self.release_velocity, 0.997) >= 0.5)
.then_some(direction)
}
@@ -139,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)
}
}
+5 -3
View File
@@ -1,5 +1,7 @@
mod move_grab;
mod resize_grab;
pub use move_grab::MoveSurfaceGrab;
pub use resize_grab::{ResizeEdge, ResizeSurfaceGrab};
pub use resize_grab::ResizeEdge;
mod window_grab;
pub(crate) use resize_grab::{constrained_size, resized_rect};
pub(crate) use window_grab::WindowPointerGrab;
+27 -250
View File
@@ -1,16 +1,5 @@
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);
@@ -22,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;
@@ -45,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
@@ -61,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
@@ -78,236 +80,7 @@ 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 {
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 as f64 + initial.size.w as f64 + if self.edges.right() { delta.x } else { 0. };
let raw_bottom = initial.loc.y as f64 + 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)
});
let (minimum, maximum) =
data.effective_size_constraints(&self.window, (minimum.w, minimum.h), (maximum.w, maximum.h));
self.last_size = constrained_size(
self.initial_rect.size,
delta,
self.edges,
minimum,
(maximum.0, maximum.1),
);
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();
}
}
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();
data.set_floating_window_geometry(&self.window, self.initial_rect.loc, self.initial_rect.size);
}
}
fn constrained_size(
pub(crate) fn constrained_size(
initial: Size<i32, Logical>,
delta: Point<f64, Logical>,
edges: ResizeEdge,
@@ -349,7 +122,7 @@ fn constrained_size(
.into()
}
fn resized_rect(
pub(crate) fn resized_rect(
initial: Rectangle<i32, Logical>,
size: Size<i32, Logical>,
edges: ResizeEdge,
@@ -357,11 +130,15 @@ fn resized_rect(
let mut location = initial.loc;
if edges.left() {
location.x = location.x.saturating_add(initial.size.w.saturating_sub(size.w));
location.x = location
.x
.saturating_add(initial.size.w.saturating_sub(size.w));
}
if edges.top() {
location.y = location.y.saturating_add(initial.size.h.saturating_sub(size.h));
location.y = location
.y
.saturating_add(initial.size.h.saturating_sub(size.h));
}
Rectangle::new(location, size)
@@ -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);
}
}
+69 -46
View File
@@ -2,10 +2,12 @@ use std::collections::VecDeque;
use std::time::{Duration, Instant};
use smithay::input::Seat;
use smithay::reexports::wayland_server::{Resource, backend::ClientId};
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,
@@ -39,7 +41,9 @@ impl<C: PartialEq> InputHistory<C> {
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)
(*event == serial
&& owner == client
&& now.saturating_duration_since(*time) <= TOKEN_MAX_AGE)
.then_some(*time)
})
}
@@ -54,7 +58,8 @@ impl Ferese {
}
if let Some(client) = surface.and_then(|surface| surface.client()) {
self.activation_inputs.record(serial, client.id(), Instant::now());
self.activation_inputs
.record(serial, client.id(), Instant::now());
}
}
}
@@ -85,14 +90,23 @@ impl XdgActivationHandler for Ferese {
return false;
}
let Some(time) = self.activation_inputs.validate(*serial, client, Instant::now()) else {
let Some(time) = self
.activation_inputs
.validate(*serial, client, Instant::now())
else {
return false;
};
data.user_data.insert_if_missing(|| ApprovedInteraction(time));
data.user_data
.insert_if_missing(|| ApprovedInteraction(time));
true
}
fn request_activation(&mut self, token: XdgActivationToken, data: XdgActivationTokenData, surface: WlSurface) {
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;
@@ -110,34 +124,10 @@ impl XdgActivationHandler for Ferese {
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.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");
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();
}
});
self.activate_managed_window(id);
}
}
@@ -175,7 +165,9 @@ mod tests {
wire.write_all(&bytes).unwrap();
};
send(&mut wire, 1, 1, &[2]); // wl_display.get_registry
events.dispatch(Duration::from_millis(1), &mut state).unwrap();
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();
@@ -185,10 +177,16 @@ mod tests {
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 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()));
seat_name = Some(u32::from_ne_bytes(
globals[offset + 8..offset + 12].try_into().unwrap(),
));
}
}
@@ -203,7 +201,9 @@ mod tests {
);
args.extend([7, 3]);
send(&mut wire, 2, 0, &args);
events.dispatch(Duration::from_millis(1), &mut state).unwrap();
events
.dispatch(Duration::from_millis(1), &mut state)
.unwrap();
let seat: WlSeat = state
.seat
.client_seats(&launcher)
@@ -231,7 +231,9 @@ mod tests {
!state.token_created(token.clone(), data()),
"fresh token renewed stale input"
);
state.activation_inputs.record(serial, launcher.id(), Instant::now());
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());
@@ -250,23 +252,40 @@ mod tests {
// 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);
assert!(
approved
.user_data
.get::<ApprovedInteraction>()
.unwrap()
.0
.elapsed()
< TOKEN_MAX_AGE
);
// Exercise the production handler with a real recipient toplevel.
// Creating the window needs no GPU or client buffer.
// 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, (), Ferese>(&state.display_handle, 6, ())
.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())
.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();
events
.dispatch(Duration::from_millis(1), &mut state)
.unwrap();
let target = state
.space
.elements()
@@ -279,7 +298,7 @@ mod tests {
})
.expect("recipient's toplevel");
state.request_activation(token, approved, target.clone());
assert_eq!(state.seat.get_keyboard().unwrap().current_focus(), Some(target));
assert_eq!(state.seat.get_keyboard().unwrap().current_focus(), None);
}
#[test]
@@ -292,7 +311,11 @@ mod tests {
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)),
history.validate(
serial,
&"launcher",
now + TOKEN_MAX_AGE + Duration::from_millis(1)
),
None
);
// An approved token retains the interaction time across client handoff.
+50 -3
View File
@@ -1,13 +1,17 @@
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, get_role, 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};
@@ -28,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) {
@@ -80,10 +117,12 @@ impl CompositorHandler for Ferese {
layer_shell::handle_commit(self, surface);
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) {
self.dmabuf_feedback.destroyed(surface);
if self.session_lock.active() {
self.session_lock
.surfaces
@@ -112,6 +151,14 @@ impl DmabufHandler for Ferese {
&mut self.dmabuf_state
}
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());
+145
View File
@@ -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,
);
+101
View File
@@ -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);
}
}
}
+952
View File
@@ -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;
+103 -2
View File
@@ -1,13 +1,18 @@
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_surface(self, surface.wl_surface());
@@ -20,6 +25,7 @@ impl InputMethodHandler for Ferese {
}
fn popup_repositioned(&mut self, surface: PopupSurface) {
self.position_input_popup(&surface);
crate::backends::direct::render_surface(self, surface.wl_surface());
}
@@ -34,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();
};
@@ -52,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))
);
}
}
+213 -28
View File
@@ -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,19 +16,39 @@ 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;
}
@@ -51,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)
@@ -61,7 +90,11 @@ impl WlrLayerShellHandler for Ferese {
let outputs = self
.space
.outputs()
.filter(|output| layer_map_for_output(output).layer_geometry(&layer).is_some())
.filter(|output| {
layer_map_for_output(output)
.layer_geometry(&layer)
.is_some()
})
.cloned()
.collect::<Vec<_>>();
@@ -113,25 +146,47 @@ impl LayoutState {
}
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()
.filter(|output| layer_map_for_output(output).layer_geometry(&layer).is_some())
.filter(|output| {
layer_map_for_output(output)
.layer_geometry(&layer)
.is_some()
})
.cloned()
.collect::<Vec<_>>();
if !state.layer_mapped_and_visible(&layer)
&& state
.pending_layer_focus
.as_ref()
.is_some_and(|(target, _)| target == surface)
{
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>));
let changed = {
let mut previous = previous.lock().unwrap();
let changed = previous.as_ref() != Some(&current);
*previous = Some(current);
changed
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(&current);
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 {
@@ -140,20 +195,43 @@ pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) {
// Configure acknowledgement and keyboard policy are independent of layout.
layer.layer_surface().send_pending_configure();
let policy = layer.cached_state();
if !state.session_lock.active()
// 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)
&& policy.keyboard_interactivity == KeyboardInteractivity::Exclusive
&& 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
.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();
}
if let Err(error) = state.display_handle.flush_clients() {
@@ -161,12 +239,113 @@ pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) {
}
}
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)
}
@@ -203,6 +382,12 @@ mod tests {
original,
LayoutState::from_policy(policy, Rectangle::from_size((800, 33).into()))
);
assert_ne!(original, LayoutState::from_policy(policy, Rectangle::default()));
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));
}
+102 -71
View File
@@ -1,17 +1,25 @@
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_protocols::xdg::shell::server::{xdg_popup, xdg_positioner, xdg_surface, xdg_wm_base};
use smithay::reexports::wayland_server::Resource;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::wayland::compositor::{TraversalAction, with_states, with_surface_tree_downward};
@@ -23,18 +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::shell::xdg::{
XdgPositionerUserData, XdgShellState, XdgShellSurfaceUserData, XdgSurfaceUserData, XdgWmBaseUserData,
};
use smithay::wayland::tablet_manager::TabletSeatHandler;
use smithay::wayland::xdg_foreign::{XdgForeignHandler, XdgForeignState};
use smithay::wayland::xdg_toplevel_icon::XdgToplevelIconHandler;
@@ -53,12 +59,17 @@ impl SeatHandler for Ferese {
if let CursorImageStatus::Named(icon) = &image
&& !self.named_cursors.contains_key(icon)
{
self.cursor_loader.request(*icon, self.cursor_animation.buffer_scale);
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::Named(previous), CursorImageStatus::Named(next)) => {
previous != next
}
(CursorImageStatus::Hidden, CursorImageStatus::Hidden) => false,
(CursorImageStatus::Surface(previous), CursorImageStatus::Surface(next)) => previous != next,
(CursorImageStatus::Surface(previous), CursorImageStatus::Surface(next)) => {
previous != next
}
_ => true,
};
self.cursor_status = image;
@@ -70,9 +81,14 @@ impl SeatHandler for Ferese {
}
fn focus_changed(&mut self, seat: &Seat<Self>, focused: Option<&WlSurface>) {
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()) {
if let Some(serial) = seat
.get_keyboard()
.and_then(|keyboard| keyboard.last_enter())
{
self.record_activation_input(serial, focused.cloned());
}
@@ -84,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);
}
@@ -96,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) {
@@ -133,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);
}
@@ -213,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)
});
}
}
@@ -223,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) {
@@ -282,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
}
}
@@ -318,46 +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::reexports::wayland_server::delegate_global_dispatch!(
Ferese: [xdg_wm_base::XdgWmBase: ()] => XdgShellState
);
smithay::reexports::wayland_server::delegate_dispatch!(
Ferese: [xdg_wm_base::XdgWmBase: XdgWmBaseUserData] => XdgShellState
);
smithay::reexports::wayland_server::delegate_dispatch!(
Ferese: [xdg_positioner::XdgPositioner: XdgPositionerUserData] => XdgShellState
);
smithay::reexports::wayland_server::delegate_dispatch!(
Ferese: [xdg_popup::XdgPopup: XdgShellSurfaceUserData] => XdgShellState
);
smithay::reexports::wayland_server::delegate_dispatch!(
Ferese: [xdg_surface::XdgSurface: XdgSurfaceUserData] => XdgShellState
);
smithay::delegate_xdg_toplevel_icon!(Ferese);
smithay::delegate_data_control!(Ferese);
smithay::delegate_ext_data_control!(Ferese);
pub(crate) mod workspace_capture;
+142 -25
View File
@@ -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)]
@@ -138,6 +145,7 @@ impl PendingScreencopy {
// The compositor-owned path deliberately omits the cursor.
overlay_cursor: false,
with_damage: false,
history: None,
}
}
@@ -157,7 +165,8 @@ 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 {
@@ -184,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>,
) {
@@ -204,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,
@@ -221,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 => {}
@@ -247,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!(),
}
@@ -262,11 +282,19 @@ 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();
let mut used = data
.used
.lock()
.expect("surface rendering state must remain consistent across callbacks");
if self.session_lock.active() {
frame.failed();
return;
@@ -293,6 +321,12 @@ 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(),
@@ -302,8 +336,24 @@ impl Ferese {
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_on(self, &[output]);
}
pub(crate) fn process_screencopies<R>(
@@ -316,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;
}
@@ -331,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);
}
}
@@ -342,15 +429,19 @@ 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) {
if capture.output == *output
&& cursor_overlay_matches(capture.overlay_cursor, overlay_cursor)
{
capture.sink.fail();
} else {
remaining.push(capture);
@@ -371,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(
@@ -381,6 +473,7 @@ fn create_frame(
overlay_cursor,
used: Mutex::new(true),
snapshot: Mutex::new(None),
history: Some(history.clone()),
}),
);
frame.failed();
@@ -395,6 +488,7 @@ fn create_frame(
overlay_cursor,
used: Mutex::new(true),
snapshot: Mutex::new(None),
history: Some(history.clone()),
}),
);
frame.failed();
@@ -408,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);
@@ -422,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();
@@ -447,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(),
)
.to_i32_round()
.intersection(buffer_bounds)
}
pub(super) fn valid_shm_buffer(buffer: &WlBuffer, expected: Size<i32, Buffer>) -> bool {
@@ -501,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(());
@@ -559,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(())
}
@@ -588,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()));
}
@@ -598,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]
+347 -89
View File
@@ -1,11 +1,9 @@
//! Converts output-buffer readback into upright image coordinates.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::SyncSender as ReplySender;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use std::time::{Duration, Instant};
use ferese_ipc::Response;
@@ -40,7 +38,9 @@ const MAX_CANVAS_BYTES: usize = 128 * 1024 * 1024;
impl Canvas {
fn new(width: u32, height: u32) -> Option<Self> {
let length = (width as usize).checked_mul(height as usize)?.checked_mul(4)?;
let length = (width as usize)
.checked_mul(height as usize)?
.checked_mul(4)?;
if width == 0 || height == 0 || length > MAX_CANVAS_BYTES {
return None;
}
@@ -83,7 +83,11 @@ impl Canvas {
}
}
fn scale_and_convert(frame: &OutputFrame, target_width: usize, target_height: usize) -> Option<Vec<u8>> {
fn scale_and_convert(
frame: &OutputFrame,
target_width: usize,
target_height: usize,
) -> Option<Vec<u8>> {
let (upright_width, upright_height) = upright_size(frame);
if upright_width <= 0 || upright_height <= 0 || target_width == 0 || target_height == 0 {
return None;
@@ -95,8 +99,13 @@ fn scale_and_convert(frame: &OutputFrame, target_width: usize, target_height: us
if upright_width as usize == target_width && upright_height as usize == target_height {
for row in 0..target_height {
for column in 0..target_width {
let (x, y) =
upright_source_pixel(column as i32, row as i32, frame.width, frame.height, frame.transform);
let (x, y) = upright_source_pixel(
column as i32,
row as i32,
frame.width,
frame.height,
frame.transform,
);
let from = (y as usize)
.checked_mul(frame.stride)?
.checked_add(x as usize * BYTES_PER_PIXEL)?;
@@ -147,7 +156,13 @@ fn scale_and_convert(frame: &OutputFrame, target_width: usize, target_height: us
Some(out)
}
fn upright_source_pixel(x: i32, y: i32, width: i32, height: i32, transform: Transform) -> (i32, i32) {
fn upright_source_pixel(
x: i32,
y: i32,
width: i32,
height: i32,
transform: Transform,
) -> (i32, i32) {
match transform {
Transform::_90 => (y, height - 1 - x),
Transform::_180 => (width - 1 - x, height - 1 - y),
@@ -173,7 +188,10 @@ pub(crate) fn compose(frames: &[OutputFrame]) -> Result<Canvas, String> {
}
let (left, top) = logical_origin(frames).ok_or("Screenshot region is empty")?;
let scale = frames.iter().map(|frame| frame.scale).fold(0.0_f64, f64::max);
let scale = frames
.iter()
.map(|frame| frame.scale)
.fold(0.0_f64, f64::max);
if !(scale.is_finite() && scale > 0.0) {
return Err("Screenshot outputs have an invalid scale".into());
}
@@ -200,15 +218,22 @@ pub(crate) fn compose(frames: &[OutputFrame]) -> Result<Canvas, String> {
if width <= 0 || height <= 0 {
return Err("Screenshot region is empty".into());
}
let mut canvas = Canvas::new(width as u32, height as u32).ok_or("Screenshot image is too large to encode")?;
let mut canvas = Canvas::new(width as u32, height as u32)
.ok_or("Screenshot image is too large to encode")?;
for (frame, (x0, y0, target_width, target_height)) in frames.iter().zip(placement) {
if target_width == 0 || target_height == 0 {
continue;
}
let scaled =
scale_and_convert(frame, target_width as usize, target_height as usize).ok_or("Invalid capture size")?;
canvas.blit(&scaled, target_width as usize, target_height as usize, x0, y0);
let scaled = scale_and_convert(frame, target_width as usize, target_height as usize)
.ok_or("Invalid capture size")?;
canvas.blit(
&scaled,
target_width as usize,
target_height as usize,
x0,
y0,
);
}
Ok(canvas)
@@ -269,7 +294,12 @@ fn logical_origin(frames: &[OutputFrame]) -> Option<(i32, i32)> {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Geometry {
All,
Region { x: i32, y: i32, width: i32, height: i32 },
Region {
x: i32,
y: i32,
width: i32,
height: i32,
},
}
impl Geometry {
@@ -279,9 +309,16 @@ impl Geometry {
if width <= 0 || height <= 0 {
return Err("Screenshot region must have a positive size".into());
}
x.checked_add(width).ok_or("screenshot geometry overflows")?;
y.checked_add(height).ok_or("screenshot geometry overflows")?;
Ok(Geometry::Region { x, y, width, height })
x.checked_add(width)
.ok_or("screenshot geometry overflows")?;
y.checked_add(height)
.ok_or("screenshot geometry overflows")?;
Ok(Geometry::Region {
x,
y,
width,
height,
})
}
}
@@ -402,7 +439,8 @@ impl Budget {
fn reserve(self: &Arc<Self>, bytes: usize) -> Result<BudgetPermit, String> {
self.used
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| {
used.checked_add(bytes).filter(|total| *total <= MAX_PENDING_BYTES)
used.checked_add(bytes)
.filter(|total| *total <= MAX_PENDING_BYTES)
})
.map_err(|_| "Screenshot memory budget is exhausted".to_string())?;
Ok(BudgetPermit {
@@ -492,7 +530,10 @@ impl Coordinator {
response_id,
reply,
state: RequestState::Collecting,
parts: specs.into_iter().map(|spec| (spec, PartState::Pending)).collect(),
parts: specs
.into_iter()
.map(|spec| (spec, PartState::Pending))
.collect(),
received: 0,
deadline: now + REQUEST_TIMEOUT,
permit: Some(permit),
@@ -503,7 +544,9 @@ impl Coordinator {
}
pub(crate) fn permit(&self, request: u64) -> Option<BudgetPermit> {
self.requests.get(&request).and_then(|entry| entry.permit.clone())
self.requests
.get(&request)
.and_then(|entry| entry.permit.clone())
}
// Used by the completion tests to assert that a request is still tracked.
@@ -519,7 +562,12 @@ impl Coordinator {
)
}
pub(crate) fn on_part(&mut self, request: u64, part: usize, result: Result<CaptureBuffer, String>) -> Action {
pub(crate) fn on_part(
&mut self,
request: u64,
part: usize,
result: Result<CaptureBuffer, String>,
) -> Action {
let Some(entry) = self.requests.get_mut(&request) else {
return Action::None;
};
@@ -572,7 +620,10 @@ impl Coordinator {
Action::Encode {
request,
frames,
permit: entry.permit.take().expect("collecting request owns its reservation"),
permit: entry
.permit
.take()
.expect("collecting request owns its reservation"),
}
}
@@ -613,7 +664,10 @@ impl Coordinator {
.map(|(id, _)| *id)
.collect();
for id in &expired {
self.reject_inner(*id, "Screenshot timed out: an output was not redrawn in time");
self.reject_inner(
*id,
"Screenshot timed out: an output was not redrawn in time",
);
}
expired
}
@@ -728,7 +782,10 @@ impl PlannedPart {
// Turns a requested logical region into the per-output reads that cover it.
// The logical-to-buffer conversion is shared with the screencopy path so both
// agree on rounding at fractional scales.
pub(crate) fn plan(geometry: &Geometry, outputs: &[OutputLayout]) -> Result<Vec<PlannedPart>, String> {
pub(crate) fn plan(
geometry: &Geometry,
outputs: &[OutputLayout],
) -> Result<Vec<PlannedPart>, String> {
if outputs.is_empty() {
return Err("No outputs are enabled".into());
}
@@ -739,7 +796,8 @@ pub(crate) fn plan(geometry: &Geometry, outputs: &[OutputLayout]) -> Result<Vec<
{
return Err("Screenshot region must have a positive size".into());
}
let logical_sizes: Vec<Size<i32, Logical>> = outputs.iter().map(OutputLayout::logical_size).collect();
let logical_sizes: Vec<Size<i32, Logical>> =
outputs.iter().map(OutputLayout::logical_size).collect();
let requested = match geometry {
Geometry::All => {
@@ -751,8 +809,10 @@ pub(crate) fn plan(geometry: &Geometry, outputs: &[OutputLayout]) -> Result<Vec<
};
union = Some(match union {
Some(current) => {
let top_left =
Point::<i32, Logical>::new(current.loc.x.min(rect.loc.x), current.loc.y.min(rect.loc.y));
let top_left = Point::<i32, Logical>::new(
current.loc.x.min(rect.loc.x),
current.loc.y.min(rect.loc.y),
);
let bottom_right = Point::<i32, Logical>::new(
(current.loc.x + current.size.w).max(rect.loc.x + rect.size.w),
(current.loc.y + current.size.h).max(rect.loc.y + rect.size.h),
@@ -764,7 +824,12 @@ pub(crate) fn plan(geometry: &Geometry, outputs: &[OutputLayout]) -> Result<Vec<
}
union.expect("outputs is not empty")
}
Geometry::Region { x, y, width, height } => Rectangle {
Geometry::Region {
x,
y,
width,
height,
} => Rectangle {
loc: (*x, *y).into(),
size: (*width, *height).into(),
},
@@ -792,7 +857,11 @@ pub(crate) fn plan(geometry: &Geometry, outputs: &[OutputLayout]) -> Result<Vec<
}
// capture_region_for_geometry works in output-local logical space.
let local = Rectangle {
loc: (clipped.loc.x - output.location.x, clipped.loc.y - output.location.y).into(),
loc: (
clipped.loc.x - output.location.x,
clipped.loc.y - output.location.y,
)
.into(),
size: clipped.size,
};
// The clip above already intersected against this same logical rect, so
@@ -839,19 +908,34 @@ mod planning {
}
fn region(x: i32, y: i32, width: i32, height: i32) -> Geometry {
Geometry::Region { x, y, width, height }
Geometry::Region {
x,
y,
width,
height,
}
}
#[test]
fn a_crop_crossing_a_seam_splits_into_one_part_per_output() {
let outputs = [output(0, 0, 1920, 1080, 1.0), output(1920, 0, 1920, 1080, 1.0)];
let outputs = [
output(0, 0, 1920, 1080, 1.0),
output(1920, 0, 1920, 1080, 1.0),
];
let parts = plan(&region(1800, 0, 200, 100), &outputs).unwrap();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0].index, 0);
assert_eq!((parts[0].logical.loc.x, parts[0].logical.size.w), (1800, 120));
assert_eq!(
(parts[1].index, parts[1].logical.loc.x, parts[1].logical.size.w),
(parts[0].logical.loc.x, parts[0].logical.size.w),
(1800, 120)
);
assert_eq!(
(
parts[1].index,
parts[1].logical.loc.x,
parts[1].logical.size.w
),
(1, 1920, 80)
);
// The clipped widths must add back up to the request.
@@ -884,8 +968,14 @@ mod planning {
let outputs = [output(0, 0, 800, 600, 1.0), output(800, 0, 1600, 1200, 1.5)];
let parts = plan(&region(600, 0, 400, 300), &outputs).unwrap();
assert_eq!(parts.len(), 2);
assert_eq!((parts[0].logical.loc.x, parts[0].logical.size.w), (600, 200));
assert_eq!((parts[1].logical.loc.x, parts[1].logical.size.w), (800, 200));
assert_eq!(
(parts[0].logical.loc.x, parts[0].logical.size.w),
(600, 200)
);
assert_eq!(
(parts[1].logical.loc.x, parts[1].logical.size.w),
(800, 200)
);
// The 1.5 output reads back at 1.5x while still reporting logical 200.
let spec = parts[1].spec(&outputs[1]);
assert_eq!(spec.logical_width, 200);
@@ -895,7 +985,10 @@ mod planning {
#[test]
fn all_geometry_unions_every_output() {
let outputs = [output(0, 0, 1920, 1080, 1.0), output(-1280, -400, 1280, 800, 1.0)];
let outputs = [
output(0, 0, 1920, 1080, 1.0),
output(-1280, -400, 1280, 800, 1.0),
];
let parts = plan(&Geometry::All, &outputs).unwrap();
assert_eq!(parts.len(), 2);
// Neither output is moved into a normalised origin at plan time.
@@ -906,7 +999,10 @@ mod planning {
#[test]
fn a_crop_misses_outputs_that_do_not_intersect() {
let outputs = [output(0, 0, 1920, 1080, 1.0), output(1920, 0, 1920, 1080, 1.0)];
let outputs = [
output(0, 0, 1920, 1080, 1.0),
output(1920, 0, 1920, 1080, 1.0),
];
let parts = plan(&region(4000, 0, 100, 100), &outputs);
assert!(parts.is_err(), "a region off the desktop is reported");
}
@@ -916,11 +1012,17 @@ mod planning {
// The second output starts exactly where the region ends. It contributes
// nothing, so the capture must still succeed with just the first output
// rather than failing on a region it does not overlap.
let outputs = [output(0, 0, 1920, 1080, 1.0), output(1920, 0, 1920, 1080, 1.0)];
let outputs = [
output(0, 0, 1920, 1080, 1.0),
output(1920, 0, 1920, 1080, 1.0),
];
let parts = plan(&region(100, 100, 1820, 980), &outputs).expect("captures the overlap");
assert_eq!(parts.len(), 1, "only the overlapping output is read");
assert_eq!(parts[0].index, 0);
assert_eq!((parts[0].logical.size.w, parts[0].logical.size.h), (1820, 980));
assert_eq!(
(parts[0].logical.size.w, parts[0].logical.size.h),
(1820, 980)
);
}
#[test]
@@ -930,10 +1032,16 @@ mod planning {
// A 90-degree transform turns the 1920x1080 mode into a 1080x1920
// logical area, and the readback follows the same rotation.
let parts = plan(&region(0, 0, 1080, 400), std::slice::from_ref(&rotated)).unwrap();
assert_eq!((parts[0].logical.size.w, parts[0].logical.size.h), (1080, 400));
assert_eq!(
(parts[0].logical.size.w, parts[0].logical.size.h),
(1080, 400)
);
let spec = parts[0].spec(&rotated);
assert_eq!(spec.transform, Transform::_90);
assert!(spec.buffer_height > spec.buffer_width, "buffer axes are swapped");
assert!(
spec.buffer_height > spec.buffer_width,
"buffer axes are swapped"
);
}
#[test]
@@ -1013,9 +1121,17 @@ mod completion {
let (reply, received) = sync_channel(8);
let mut coordinator = Coordinator::new();
coordinator
.admit(1, 42, reply.clone(), (0..parts).map(|_| spec(2, 2)).collect())
.admit(
1,
42,
reply.clone(),
(0..parts).map(|_| spec(2, 2)).collect(),
)
.expect("admitted");
Self { coordinator, received }
Self {
coordinator,
received,
}
}
fn response(&self) -> Option<Response> {
@@ -1036,7 +1152,11 @@ mod completion {
};
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pixels.as_ptr(), allocation, "readback pixels were copied");
assert_eq!(
frames[0].pixels.as_ptr(),
allocation,
"readback pixels were copied"
);
assert_eq!(frames[0].pixels, vec![137; 16]);
}
@@ -1049,13 +1169,21 @@ mod completion {
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))),
Action::None
));
assert!(harness.coordinator.is_live(id), "still collecting after part 0");
assert!(
harness.coordinator.is_live(id),
"still collecting after part 0"
);
assert!(matches!(
harness.coordinator.on_part(id, 1, Err("readback failed".into())),
harness
.coordinator
.on_part(id, 1, Err("readback failed".into())),
Action::None
));
assert!(!harness.coordinator.is_live(id), "one failed part ends the request");
assert!(
!harness.coordinator.is_live(id),
"one failed part ends the request"
);
let response = harness.response().expect("caller is always answered");
assert_eq!(response.id, 42);
@@ -1070,7 +1198,9 @@ mod completion {
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2)));
// Output 1 is removed from the layout, so its pending capture is failed.
harness.coordinator.on_part(id, 1, Err("output was removed".into()));
harness
.coordinator
.on_part(id, 1, Err("output was removed".into()));
assert!(!harness.coordinator.is_live(id));
assert!(harness.response().unwrap().error.is_some());
@@ -1109,14 +1239,26 @@ mod completion {
fn admission_enforces_one_budget_across_requests_and_late_readbacks() {
let (reply, _received) = sync_channel(8);
let mut coordinator = Coordinator::new();
let first = coordinator.admit(1, 1, reply.clone(), vec![spec(4096, 8192)]).unwrap();
let second = coordinator.admit(1, 2, reply.clone(), vec![spec(4096, 8192)]).unwrap();
assert!(coordinator.admit(1, 3, reply.clone(), vec![spec(2, 2)]).is_err());
let first = coordinator
.admit(1, 1, reply.clone(), vec![spec(4096, 8192)])
.unwrap();
let second = coordinator
.admit(1, 2, reply.clone(), vec![spec(4096, 8192)])
.unwrap();
assert!(
coordinator
.admit(1, 3, reply.clone(), vec![spec(2, 2)])
.is_err()
);
// A cancelled request can still have a readback retained by a sink or channel.
let late_readback = coordinator.permit(first).unwrap();
coordinator.reject(first, "cancelled");
assert!(coordinator.admit(1, 4, reply.clone(), vec![spec(2, 2)]).is_err());
assert!(
coordinator
.admit(1, 4, reply.clone(), vec![spec(2, 2)])
.is_err()
);
drop(late_readback);
assert!(coordinator.admit(1, 5, reply, vec![spec(2, 2)]).is_ok());
coordinator.reject(second, "finished");
@@ -1127,19 +1269,48 @@ mod completion {
let mut harness = Harness::new(1);
let id = harness.coordinator.next_id;
let bytes = harness.coordinator.budget.used.load(Ordering::Acquire);
let Action::Encode { frames, permit, .. } = harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))) else {
let Action::Encode { frames, permit, .. } =
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2)))
else {
panic!("ready request should encode");
};
harness.coordinator.reject(id, "cancelled while encoder owns pixels");
assert_eq!(harness.coordinator.budget.used.load(Ordering::Acquire), bytes);
assert!(harness.coordinator.budget.reserve(MAX_PENDING_BYTES).is_err());
harness
.coordinator
.reject(id, "cancelled while encoder owns pixels");
assert_eq!(
harness.coordinator.budget.used.load(Ordering::Acquire),
bytes
);
assert!(
harness
.coordinator
.budget
.reserve(MAX_PENDING_BYTES)
.is_err()
);
drop(frames);
drop(permit);
assert_eq!(harness.coordinator.budget.used.load(Ordering::Acquire), 0);
let first = harness.coordinator.budget.reserve(MAX_PENDING_BYTES / 2 + 1).unwrap();
assert!(harness.coordinator.budget.reserve(MAX_PENDING_BYTES / 2).is_err());
let first = harness
.coordinator
.budget
.reserve(MAX_PENDING_BYTES / 2 + 1)
.unwrap();
assert!(
harness
.coordinator
.budget
.reserve(MAX_PENDING_BYTES / 2)
.is_err()
);
drop(first);
assert!(harness.coordinator.budget.reserve(MAX_PENDING_BYTES).is_ok());
assert!(
harness
.coordinator
.budget
.reserve(MAX_PENDING_BYTES)
.is_ok()
);
}
#[test]
@@ -1152,7 +1323,10 @@ mod completion {
Action::None
));
let action = harness.coordinator.on_part(id, 1, Ok(buffer(2, 2)));
let Action::Encode { request, frames, .. } = action else {
let Action::Encode {
request, frames, ..
} = action
else {
panic!("expected the request to move to encoding");
};
assert_eq!(request, id);
@@ -1177,7 +1351,9 @@ mod completion {
// Unlocking does not resurrect the request; the file the worker
// eventually produces is ours to delete.
assert!(!harness.coordinator.is_live(id));
let action = harness.coordinator.on_encoded(id, Ok(PathBuf::from("/tmp/late.png")));
let action = harness
.coordinator
.on_encoded(id, Ok(PathBuf::from("/tmp/late.png")));
assert!(matches!(action, Action::DiscardFile(ref path) if path.ends_with("late.png")));
assert!(harness.response().is_none(), "no late success is delivered");
}
@@ -1189,7 +1365,9 @@ mod completion {
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2)));
assert!(harness.coordinator.is_encoding(id));
assert_eq!(
harness.coordinator.terminate_all_with_reason("Capture privacy changed"),
harness
.coordinator
.terminate_all_with_reason("Capture privacy changed"),
vec![id]
);
assert!(harness.response().unwrap().error.is_some());
@@ -1208,7 +1386,9 @@ mod completion {
let id = harness.coordinator.next_id;
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2)));
harness.coordinator.on_encoded(id, Ok(PathBuf::from("/tmp/shot.png")));
harness
.coordinator
.on_encoded(id, Ok(PathBuf::from("/tmp/shot.png")));
let response = harness.response().expect("success is answered");
assert_eq!(response.id, 42);
assert!(response.error.is_none());
@@ -1216,7 +1396,9 @@ mod completion {
// A duplicate completion for the same request has nowhere to go, and
// its file is an orphan rather than a second delivery.
let action = harness.coordinator.on_encoded(id, Ok(PathBuf::from("/tmp/again.png")));
let action = harness
.coordinator
.on_encoded(id, Ok(PathBuf::from("/tmp/again.png")));
assert!(matches!(action, Action::DiscardFile(_)));
// A delivered path is reported separately so the caller cannot
@@ -1239,7 +1421,9 @@ mod completion {
let id = harness.coordinator.next_id;
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2)));
harness.coordinator.on_encoded(id, Err("encoding failed".into()));
harness
.coordinator
.on_encoded(id, Err("encoding failed".into()));
let response = harness.response().expect("failure is answered");
assert_eq!(response.error.unwrap().message, "encoding failed");
assert!(!harness.coordinator.is_live(id));
@@ -1252,13 +1436,23 @@ mod completion {
// client would never be answered.
let mut harness = Harness::new(2);
let id = harness.coordinator.next_id;
harness.coordinator.requests.get_mut(&id).expect("tracked").deadline = Instant::now() - Duration::from_secs(1);
harness
.coordinator
.requests
.get_mut(&id)
.expect("tracked")
.deadline = Instant::now() - Duration::from_secs(1);
assert_eq!(harness.coordinator.expire_at(Instant::now()), vec![id]);
let response = harness.response().expect("the caller is answered");
assert_eq!(response.error.as_ref().unwrap().code, "screenshot_failed");
assert!(
response.error.as_ref().unwrap().message.contains("timed out"),
response
.error
.as_ref()
.unwrap()
.message
.contains("timed out"),
"the failure explains itself: {:?}",
response.error.as_ref().unwrap().message
);
@@ -1275,7 +1469,10 @@ mod completion {
fn a_live_request_is_not_expired_early() {
let mut harness = Harness::new(1);
let id = harness.coordinator.next_id;
assert!(harness.coordinator.expire_at(Instant::now()).is_empty(), "not yet due");
assert!(
harness.coordinator.expire_at(Instant::now()).is_empty(),
"not yet due"
);
assert!(harness.coordinator.is_live(id));
assert!(harness.response().is_none(), "still unanswered, not failed");
let remaining = harness
@@ -1334,7 +1531,9 @@ mod completion {
);
}
fn observe_deadlines(coordinator: &mut Coordinator) -> std::rc::Rc<std::cell::RefCell<Vec<Option<Instant>>>> {
fn observe_deadlines(
coordinator: &mut Coordinator,
) -> std::rc::Rc<std::cell::RefCell<Vec<Option<Instant>>>> {
let changes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let observed = changes.clone();
coordinator.set_deadline_observer(move |deadline| observed.borrow_mut().push(deadline));
@@ -1356,15 +1555,29 @@ mod completion {
.admit_at(1, 42, reply.clone(), vec![spec(2, 2)], late)
.unwrap();
let second = coordinator
.admit_at(2, 43, reply.clone(), vec![spec(2, 2)], late + Duration::from_secs(5))
.admit_at(
2,
43,
reply.clone(),
vec![spec(2, 2)],
late + Duration::from_secs(5),
)
.unwrap();
assert_eq!(
changes.borrow().len(),
2,
"a later request does not rearm the earliest timer"
);
assert!(coordinator.admit_at(1, 44, reply, Vec::new(), late).is_err());
assert_eq!(changes.borrow().len(), 2, "rejected admission does not touch the timer");
assert!(
coordinator
.admit_at(1, 44, reply, Vec::new(), late)
.is_err()
);
assert_eq!(
changes.borrow().len(),
2,
"rejected admission does not touch the timer"
);
assert_eq!(coordinator.terminate_owner(1), vec![first]);
assert!(coordinator.is_live(second));
assert_eq!(coordinator.terminate_owner(99), Vec::<u64>::new());
@@ -1382,7 +1595,10 @@ mod completion {
#[test]
fn deadline_survives_readback_and_encoding_then_disarms_on_completion() {
for result in [Ok(PathBuf::from("/tmp/shot.png")), Err("encoder failed".into())] {
for result in [
Ok(PathBuf::from("/tmp/shot.png")),
Err("encoder failed".into()),
] {
let mut harness = Harness::new(2);
let changes = observe_deadlines(&mut harness.coordinator);
let id = harness.coordinator.next_id;
@@ -1392,11 +1608,19 @@ mod completion {
harness.coordinator.on_part(id, 1, Ok(buffer(2, 2))),
Action::Encode { .. }
));
assert_eq!(&*changes.borrow(), &[deadline], "encoding keeps the original deadline");
assert_eq!(
&*changes.borrow(),
&[deadline],
"encoding keeps the original deadline"
);
harness.coordinator.on_encoded(id, result);
assert_eq!(&*changes.borrow(), &[deadline, None]);
harness.coordinator.on_part(id, 0, Err("late readback".into()));
harness.coordinator.on_encoded(id, Err("late encode".into()));
harness
.coordinator
.on_part(id, 0, Err("late readback".into()));
harness
.coordinator
.on_encoded(id, Err("late encode".into()));
assert_eq!(
changes.borrow().len(),
2,
@@ -1440,7 +1664,11 @@ mod completion {
}
_ => unreachable!(),
}
assert_eq!(changes.borrow().last(), Some(&None), "{cancellation} must disarm");
assert_eq!(
changes.borrow().last(),
Some(&None),
"{cancellation} must disarm"
);
assert_eq!(changes.borrow().len(), 2);
assert!(harness.response().unwrap().error.is_some());
assert!(!harness.coordinator.is_live(id));
@@ -1458,7 +1686,13 @@ mod completion {
.admit_at(1, 42, reply.clone(), vec![spec(2, 2)], now)
.unwrap();
let second = coordinator
.admit_at(2, 43, reply.clone(), vec![spec(2, 2)], now + Duration::from_secs(5))
.admit_at(
2,
43,
reply.clone(),
vec![spec(2, 2)],
now + Duration::from_secs(5),
)
.unwrap();
coordinator.on_part(first, 0, Ok(buffer(2, 2)));
assert!(
@@ -1487,7 +1721,13 @@ mod completion {
Action::DiscardFile(_)
));
coordinator
.admit_at(3, 44, reply, vec![spec(2, 2)], now + Duration::from_secs(3600))
.admit_at(
3,
44,
reply,
vec![spec(2, 2)],
now + Duration::from_secs(3600),
)
.unwrap();
assert_eq!(
changes.borrow().last(),
@@ -1505,7 +1745,10 @@ mod completion {
assert!(!sink.publish(buffer(64, 64)));
let PartOutcome {
request, part, result, ..
request,
part,
result,
..
} = receiver.try_recv().expect("published once");
assert_eq!((request, part), (7, 1));
assert_eq!(result.unwrap().width, 2);
@@ -1522,7 +1765,10 @@ mod completion {
assert!(!sink.publish(buffer(2, 2)));
let PartOutcome {
request, part, result, ..
request,
part,
result,
..
} = receiver.try_recv().expect("answered");
assert_eq!((request, part), (3, 0));
assert!(result.is_err());
@@ -1706,10 +1952,10 @@ mod tests {
fn conversion_does_not_flip_vertically() {
// Top row red, bottom row blue. An extra Y flip would swap them.
let mut f = frame(Transform::Normal, 1, 2);
f.pixels = vec![0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0x00, 0x00];
f.pixels = vec![0x00, 0x00, 0xff, 0x00, 0xff, 0x00, 0x00, 0x00];
let out = scale_and_convert(&f, 1, 2).unwrap();
assert_eq!(&out[0..4], &[0xFF, 0x00, 0x00, 255], "top row");
assert_eq!(&out[4..8], &[0x00, 0x00, 0xFF, 255], "bottom row");
assert_eq!(&out[0..4], &[0xff, 0x00, 0x00, 255], "top row");
assert_eq!(&out[4..8], &[0x00, 0x00, 0xff, 255], "bottom row");
}
#[test]
@@ -1723,7 +1969,7 @@ mod tests {
let value = (row * 2 + column) as u8;
padded.extend_from_slice(&[value, value, value, 255]);
}
padded.extend_from_slice(&[0xEE; 4]);
padded.extend_from_slice(&[0xee; 4]);
}
f.pixels = padded;
@@ -1913,7 +2159,11 @@ mod tests {
// 9 logical at scale 1.5.
assert_eq!((canvas.width, canvas.height), (14, 5));
for column in 0..canvas.width as usize {
assert_eq!(canvas.pixels[column * 4 + 3], 255, "column {column} has a gap");
assert_eq!(
canvas.pixels[column * 4 + 3],
255,
"column {column} has a gap"
);
}
}
@@ -1945,7 +2195,11 @@ mod tests {
let canvas = compose(&[left, right]).unwrap();
assert_eq!(canvas.width, 5);
assert_eq!(&canvas.pixels[2 * 4..3 * 4], &[0, 0, 0, 0], "gap is transparent");
assert_eq!(
&canvas.pixels[2 * 4..3 * 4],
&[0, 0, 0, 0],
"gap is transparent"
);
assert_eq!(canvas.pixels[3], 255);
assert_eq!(canvas.pixels[3 * 4 + 3], 255);
}
@@ -1974,7 +2228,11 @@ mod tests {
#[test]
fn geometry_tolerates_surrounding_whitespace_from_slurp() {
for raw in ["100,200 800x600\n", " 100,200 800x600 ", "100,200 800x600\r\n"] {
for raw in [
"100,200 800x600\n",
" 100,200 800x600 ",
"100,200 800x600\r\n",
] {
assert_eq!(
parse_geometry(raw).unwrap(),
Geometry::Region {
+28 -8
View File
@@ -113,7 +113,8 @@ fn encode_png(canvas: &Canvas) -> Result<Vec<u8>, String> {
if width == 0 || height == 0 {
return Err("Screenshot is empty".into());
}
let image = image::ImageBuffer::<image::Rgba<u8>, _>::from_raw(width, height, canvas.pixels.as_slice())
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();
@@ -122,7 +123,13 @@ fn encode_png(canvas: &Canvas) -> Result<Vec<u8>, String> {
image::codecs::png::CompressionType::Fast,
image::codecs::png::FilterType::Adaptive,
);
image::ImageEncoder::write_image(encoder, image.as_raw(), width, height, image::ExtendedColorType::Rgba8)
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)
@@ -178,8 +185,8 @@ fn write_private_file_in(
}
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) {
@@ -188,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}")),
@@ -248,7 +258,11 @@ 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");
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);
}
@@ -279,7 +293,10 @@ mod tests {
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();
}
@@ -293,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);
}
}
}
+353 -41
View File
@@ -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;
}
@@ -77,7 +123,11 @@ struct Conversion {
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.
@@ -95,7 +145,10 @@ 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())
@@ -138,7 +191,7 @@ pub(super) fn init_global(
}
display.create_global::<Ferese, FereseWindowCaptureManagerV1, Global>(
1,
3,
Global {
budget: Arc::default(),
worker,
@@ -155,21 +208,32 @@ fn finish_conversion(state: &Ferese, result: Conversion) {
let Some(data) = frame.data::<Arc<FrameData>>() else {
return;
};
if !frame.is_alive() || *data.used.lock().unwrap() {
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().unwrap() = true;
*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().unwrap() = Some(snapshot);
*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);
}
@@ -178,7 +242,9 @@ fn finish_conversion(state: &Ferese, result: Conversion) {
impl GlobalDispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
fn can_view(client: Client, _: &Global) -> bool {
client.get_data::<crate::state::ClientState>().is_some_and(|state| {
client
.get_data::<crate::state::ClientState>()
.is_some_and(|state| {
state
.capabilities
.contains(crate::private_client::ClientCapabilities::WINDOW_CAPTURE)
@@ -207,6 +273,90 @@ impl Dispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
_: &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,
@@ -232,6 +382,7 @@ impl Dispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
overlay_cursor: false,
used: Mutex::new(failed),
snapshot: Mutex::new(None),
history: None,
}),
);
if failed {
@@ -242,13 +393,20 @@ impl Dispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
} else if global
.worker
.try_send(Conversion {
snapshot: snapshot.unwrap(),
snapshot: snapshot
.expect("conversion is queued only after obtaining a capture snapshot"),
frame: resource.clone(),
manager: manager.clone(),
})
.is_err()
{
*resource.data::<Arc<FrameData>>().unwrap().used.lock().unwrap() = true;
*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();
}
@@ -256,14 +414,16 @@ impl Dispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
}
}
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> {
pub(super) fn window_capture_info(&self, id: WindowId) -> Option<WindowInfo> {
if self.session_lock.active() || !super::screencopy::capture_allowed() {
return None;
}
@@ -286,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()?)?
@@ -295,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() && !self.capture_cursor_protected() {
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;
}
@@ -318,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(
@@ -330,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
};
@@ -357,14 +580,30 @@ 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;
};
@@ -393,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
})
@@ -415,6 +658,57 @@ pub(super) fn copy_snapshot(state: &Ferese, resource: &ZwlrScreencopyFrameV1, da
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,
@@ -444,6 +738,7 @@ pub(crate) mod tests {
overlay_cursor: false,
used: Mutex::new(false),
snapshot: Mutex::new(None),
history: None,
})
};
let (worker, _requests) = std::sync::mpsc::sync_channel(1);
@@ -459,7 +754,11 @@ pub(crate) mod tests {
.unwrap();
let data = frame_data();
let frame = client
.create_resource::<ZwlrScreencopyFrameV1, Arc<FrameData>, Ferese>(&state.display_handle, 3, data.clone())
.create_resource::<ZwlrScreencopyFrameV1, Arc<FrameData>, Ferese>(
&state.display_handle,
3,
data.clone(),
)
.unwrap();
finish_conversion(
state,
@@ -475,28 +774,41 @@ pub(crate) mod tests {
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())
.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() }
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) };
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());
std::ptr::copy_nonoverlapping(
before.as_ptr(),
ptr.add(layout.offset as usize),
before.len(),
);
}
})
.unwrap();
@@ -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));
}
}
}
}
+93 -60
View File
@@ -19,7 +19,7 @@ use smithay::wayland::shell::xdg::{
};
use crate::Ferese;
use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab};
use crate::grabs::ResizeEdge;
impl XdgShellHandler for Ferese {
fn xdg_shell_state(&mut self) -> &mut XdgShellState {
@@ -38,17 +38,46 @@ impl XdgShellHandler for Ferese {
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()) {
@@ -60,10 +89,9 @@ impl XdgShellHandler for Ferese {
fn parent_changed(&mut self, surface: ToplevelSurface) {
self.refresh_capture_privacy();
self.refresh_idle_inhibition();
let Some(parent) = surface.parent() else {
return;
};
let parent = self.windows.id_for_surface(&parent);
let parent = surface
.parent()
.and_then(|parent| self.windows.id_for_surface(&parent));
let child = self
.windows
.id_for_surface(surface.wl_surface())
@@ -77,8 +105,8 @@ impl XdgShellHandler for Ferese {
})
.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);
}
}
@@ -98,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;
@@ -132,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(
@@ -185,19 +199,16 @@ impl XdgShellHandler for Ferese {
else {
return;
};
let Some(rect) = self.visual_rect_for_window(&window) else {
return;
};
if !self.is_floating_window(&window) {
return;
}
pointer.set_grab(
self,
ResizeSurfaceGrab::new(start_data, window, ResizeEdge::from(edges), rect),
if edges != xdg_toplevel::ResizeEdge::None {
self.begin_window_interaction(
&pointer,
window,
start_data,
serial,
Focus::Clear,
Some(ResizeEdge::from(edges)),
);
}
}
fn maximize_request(&mut self, surface: ToplevelSurface) {
if let Some(id) = self.windows.id_for_surface(surface.wl_surface()) {
@@ -255,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);
@@ -267,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 {
@@ -313,9 +336,9 @@ 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
})
}
@@ -328,12 +351,14 @@ pub(crate) fn validate_size_constraints(toplevel: &ToplevelSurface) -> bool {
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)
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");
toplevel.xdg_toplevel().post_error(
xdg_toplevel::Error::InvalidSize,
"invalid minimum/maximum window size",
);
}
valid
@@ -362,7 +387,9 @@ pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) {
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");
}
}
@@ -418,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)?;
@@ -435,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))
})
@@ -493,8 +524,8 @@ impl Dispatch<xdg_toplevel::XdgToplevel, XdgShellSurfaceUserData> for Ferese {
return;
}
<XdgShellState as Dispatch<xdg_toplevel::XdgToplevel, XdgShellSurfaceUserData, Ferese>>::request(
state, client, resource, request, data, dhandle, data_init,
smithay::wayland::Dispatch2::request(
data, state, client, resource, request, dhandle, data_init,
);
}
@@ -504,9 +535,7 @@ impl Dispatch<xdg_toplevel::XdgToplevel, XdgShellSurfaceUserData> for Ferese {
resource: &xdg_toplevel::XdgToplevel,
data: &XdgShellSurfaceUserData,
) {
<XdgShellState as Dispatch<xdg_toplevel::XdgToplevel, XdgShellSurfaceUserData, Ferese>>::destroyed(
state, client, resource, data,
);
smithay::wayland::Dispatch2::destroyed(data, state, client, resource);
}
}
@@ -531,7 +560,11 @@ fn size_limits_usable(
(pending.max_size.w, pending.max_size.h),
)
});
let (min, max) = if minimum { (proposed, max) } else { (min, proposed) };
let (min, max) = if minimum {
(proposed, max)
} else {
(min, proposed)
};
(max.0 == 0 || min.0 <= max.0) && (max.1 == 0 || min.1 <= max.1)
}
+37 -13
View File
@@ -11,7 +11,9 @@ use smithay::wayland::shell::xdg::XdgToplevelSurfaceData;
use crate::Ferese;
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[derive(
Clone, Copy, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct Settings {
pub fullscreen_playback: bool,
@@ -25,7 +27,9 @@ impl Default for Settings {
}
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[derive(
Clone, Copy, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(rename_all = "kebab-case")]
pub enum Mode {
None,
@@ -77,16 +81,21 @@ fn player_window(player: &media::Player, windows: &[AppWindow<'_>]) -> Option<Wi
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 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)));
|| 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()
@@ -137,7 +146,7 @@ impl Ferese {
states
.data_map
.get::<XdgToplevelSurfaceData>()
.is_some_and(|data| data.lock().unwrap().parent.is_some())
.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,
@@ -195,9 +204,15 @@ mod tests {
"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);
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);
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"] {
@@ -211,9 +226,15 @@ mod tests {
);
}
assert!(
crate::config::Config::parse_source("window-rule app-id=\"player\" idle-inhibit=\"alwayss\"\n").is_err()
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()
);
assert!(crate::config::Config::parse_source("idle-inhibit { fullscreen-playback \"yes\"; }\n").is_err());
}
#[test]
@@ -236,7 +257,10 @@ mod tests {
pid: Some(99),
app_id: "Player.desktop",
};
assert_eq!(player_window(&player, &[window, unrelated]), Some(window.id));
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!(
+35 -6
View File
@@ -7,7 +7,8 @@ use std::time::Instant;
use calloop::EventLoop;
use calloop::channel::{Event, sync_channel};
use ferese_ipc::{Response, media::Snapshot};
use ferese_ipc::Response;
use ferese_ipc::media::Snapshot;
use serde_json::Value;
use crate::Ferese;
@@ -48,10 +49,21 @@ 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")
@@ -74,8 +86,16 @@ impl Engine {
}
}
pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: mpsc::SyncSender<Response>) {
if since != self.snapshot.revision {
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));
@@ -86,8 +106,15 @@ impl Engine {
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")?;
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,
@@ -101,7 +128,9 @@ impl Engine {
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| {
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;
+74 -24
View File
@@ -2,10 +2,8 @@ use std::collections::HashMap;
use std::time::Duration;
use calloop::channel::SyncSender;
use ferese_ipc::{
Response,
media::{Playback, now_us},
};
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};
@@ -38,7 +36,11 @@ async fn bus_call<T: serde::de::DeserializeOwned + zbus::zvariant::Type>(
.deserialize()
}
async fn properties(connection: &Connection, owner: &str, interface: &str) -> zbus::Result<Properties> {
async fn properties(
connection: &Connection,
owner: &str,
interface: &str,
) -> zbus::Result<Properties> {
connection
.call_method(Some(owner), PATH, Some(PROPERTIES), "GetAll", &(interface,))
.await?
@@ -47,7 +49,9 @@ async fn properties(connection: &Connection, owner: &str, interface: &str) -> zb
}
fn string<'a>(properties: &'a Properties, name: &str) -> Option<&'a str> {
properties.get(name).and_then(|value| <&str>::try_from(value).ok())
properties
.get(name)
.and_then(|value| <&str>::try_from(value).ok())
}
pub(super) fn clean(value: &str, length: usize) -> String {
@@ -163,7 +167,11 @@ async fn discover(connection: &Connection, name: String, owner: String) -> Playe
player
}
async fn action(connection: &Connection, state: &mut PlayersState, args: &serde_json::Value) -> Result<(), String> {
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;
@@ -176,7 +184,10 @@ async fn action(connection: &Connection, state: &mut PlayersState, args: &serde_
}
.cloned()
.ok_or("No active media player")?;
if args["owner"].as_str().is_some_and(|owner| owner != player.owner) {
if args["owner"]
.as_str()
.is_some_and(|owner| owner != player.owner)
{
return Err("Media player was replaced".into());
}
@@ -227,12 +238,19 @@ async fn action(connection: &Connection, state: &mut PlayersState, args: &serde_
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 = 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().unwrap()).map_err(|error| error.to_string())?;
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()),
@@ -248,7 +266,11 @@ async fn action(connection: &Connection, state: &mut PlayersState, args: &serde_
.as_f64()
.filter(|delta| delta.is_finite() && delta.abs() <= 1.0)
.ok_or("Invalid volume change")?;
let volume = (view.volume.unwrap() + delta).clamp(0.0, 1.0);
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()),
@@ -279,7 +301,11 @@ async fn action(connection: &Connection, state: &mut PlayersState, args: &serde_
Ok(())
}
fn publish(state: &PlayersState, previous: &mut Option<Update>, sender: &SyncSender<Update>) -> bool {
fn publish(
state: &PlayersState,
previous: &mut Option<Update>,
sender: &SyncSender<Update>,
) -> bool {
let update = state.update_snapshot();
if previous
.as_ref()
@@ -336,7 +362,10 @@ async fn monitor(
.deserialize()?;
let mut names: Vec<_> = names
.into_iter()
.filter(|name| name.strip_prefix(PREFIX).is_some_and(|suffix| suffix.starts_with('.')))
.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) {
@@ -367,7 +396,9 @@ async fn monitor(
};
match input {
Input::Action(None) => return Ok(()),
Input::Signal(None) => return Err(zbus::Error::Failure("MPRIS signal stream ended".into())),
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())
@@ -389,8 +420,12 @@ async fn monitor(
.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('.')) {
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);
@@ -402,7 +437,10 @@ async fn monitor(
state.update(player);
}
} else if let Some(owner) = header.sender() {
if header.member().is_some_and(|member| member.as_str() == "Seeked") {
if header
.member()
.is_some_and(|member| member.as_str() == "Seeked")
{
let names: Vec<_> = state
.players
.values()
@@ -417,14 +455,17 @@ async fn monitor(
}
} else {
let Ok((interface, changed, invalidated)) =
message.body().deserialize::<(String, Properties, Vec<String>)>()
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));
&& ["Identity", "DesktopEntry", "CanRaise"].iter().any(|key| {
changed.contains_key(*key)
|| invalidated.iter().any(|field| field == key)
});
let playback = interface == PLAYER
&& [
"PlaybackStatus",
@@ -439,7 +480,10 @@ async fn monitor(
"CanControl",
]
.iter()
.any(|key| changed.contains_key(*key) || invalidated.iter().any(|field| field == key));
.any(|key| {
changed.contains_key(*key)
|| invalidated.iter().any(|field| field == key)
});
if !identity && !playback {
continue;
}
@@ -483,10 +527,16 @@ pub(super) fn run(sender: SyncSender<Update>, commands: async_channel::Receiver<
if matches!(result, Ok(())) || commands.is_closed() {
break;
}
if sender.send(PlayersState::default().update_snapshot()).is_err() {
if sender
.send(PlayersState::default().update_snapshot())
.is_err()
{
break;
}
tracing::debug!(?result, "MPRIS disconnected; clearing playback and retrying");
tracing::debug!(
?result,
"MPRIS disconnected; clearing playback and retrying"
);
std::thread::sleep(Duration::from_secs(5));
}
}
@@ -47,8 +47,9 @@ impl PlayersState {
}
pub fn selected(&self) -> Option<&Player> {
let eligible =
|player: &&Player| player.view.status != Playback::Stopped && !self.ignored.contains(&player.name);
let eligible = |player: &&Player| {
player.view.status != Playback::Stopped && !self.ignored.contains(&player.name)
};
if let Some(player) = self
.pinned
.as_ref()
+31 -1
View File
@@ -55,7 +55,9 @@ fn pin_ignore_and_departure_do_not_leave_a_ghost_player() {
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));
state
.players
.insert(name.into(), player(name, Playback::Playing));
}
assert_eq!(state.selected().unwrap().name, "a");
state.update(player("a", Playback::Stopped));
@@ -114,3 +116,31 @@ fn metadata_and_position_updates_do_not_recompute_idle_policy() {
));
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());
}
+520 -153
View File
File diff suppressed because it is too large. Load diff
+66 -10
View File
@@ -2,7 +2,8 @@ use super::*;
use smithay::backend::input::UnusedEvent;
use smithay::backend::winit::WinitVirtualDevice;
use smithay::input::keyboard::Keycode;
use smithay::reexports::{calloop::EventLoop, wayland_server::Display};
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_server::Display;
#[derive(Debug)]
struct TestInput;
@@ -38,8 +39,8 @@ impl InputBackend for TestInput {
#[derive(Debug)]
struct TestKey(Keycode, KeyState);
impl Event<TestInput> for TestKey {
fn time(&self) -> u64 {
1_000
fn time(&self) -> smithay::backend::input::InputTime {
smithay::backend::input::InputTime::from_micros(1_000)
}
fn device(&self) -> WinitVirtualDevice {
WinitVirtualDevice
@@ -60,7 +61,11 @@ pub(crate) fn key(state: &mut Ferese, code: u32, pressed: bool) {
state.process_input_event::<TestInput>(InputEvent::Keyboard {
event: TestKey(
Keycode::new(code),
if pressed { KeyState::Pressed } else { KeyState::Released },
if pressed {
KeyState::Pressed
} else {
KeyState::Released
},
),
});
}
@@ -73,12 +78,52 @@ binding "Super+r" binding-mode resize
binding-mode resize { binding h resize left; binding l binding-mode launch; }
binding-mode launch { binding h binding-mode default; }
"#;
#[test]
fn nested_focus_loss_releases_modifiers_and_preserves_auxiliary_keys() {
if !crate::startup_tests::private_runtime(
"input::binding_mode_tests::nested_focus_loss_releases_modifiers_and_preserves_auxiliary_keys",
) {
return;
}
let mut events = EventLoop::try_new().unwrap();
let mut state = state(&mut events);
let keyboard = state.seat.get_keyboard().unwrap();
key(&mut state, 64, true); // Alt
key(&mut state, 133, true); // Super
let auxiliary = KeyboardSource::new_auxiliary();
keyboard.input_from_source::<(), _>(
auxiliary,
&mut state,
Keycode::new(38),
KeyState::Pressed,
SERIAL_COUNTER.next_serial(),
smithay::backend::input::InputTime::now(),
|_, _, _| FilterResult::Forward,
);
assert!(keyboard.modifier_state().alt);
assert!(keyboard.modifier_state().logo);
state.release_nested_keyboard();
assert!(!keyboard.modifier_state().alt);
assert!(!keyboard.modifier_state().logo);
assert_eq!(
keyboard.pressed_keys(),
[Keycode::new(38)].into_iter().collect()
);
assert!(state.binding_mode_pressed.is_empty());
assert!(state.binding_mode_repeats.is_empty());
keyboard.release_source(&mut state, auxiliary);
assert!(keyboard.pressed_keys().is_empty());
}
fn state(events: &mut EventLoop<'static, Ferese>) -> Ferese {
let (_, mut config, _) = crate::config::prepare(SOURCE, std::path::Path::new(".")).unwrap();
config.wallpaper.path = None;
let mut state = Ferese::new(events, Display::new().unwrap(), config).unwrap();
state.config_source = Some(SOURCE.into());
let mut sections = ferese_config::Document::parse(SOURCE).unwrap().value().clone();
let mut sections = ferese_config::Document::parse(SOURCE)
.unwrap()
.value()
.clone();
sections.as_object_mut().unwrap().remove("theme");
state.config_sections = Some(sections);
state
@@ -149,7 +194,11 @@ fn reload_transitions_cancel_modes_without_losing_key_ownership() {
let mut state = state(&mut events);
state.set_binding_mode("resize");
key(&mut state, 43, true);
assert!(state.reload_config_source("binding-mode default {}".into()).is_err());
assert!(
state
.reload_config_source("binding-mode default {}".into())
.is_err()
);
assert_eq!(state.binding_modes.active(), Some("resize"));
assert!(state.intercepted_keys.contains(&Keycode::new(43)));
state
@@ -163,7 +212,11 @@ fn reload_transitions_cancel_modes_without_losing_key_ownership() {
state
.reload_config_source(format!("{SOURCE}\ninput {{ repeat-rate 30; }}"))
.unwrap();
assert_eq!(state.binding_modes.active(), None, "input reload exits mode");
assert_eq!(
state.binding_modes.active(),
None,
"input reload exits mode"
);
state.set_binding_mode("resize");
state
.reload_config_source("binding-mode launch { binding t spawn terminal; }".into())
@@ -176,8 +229,9 @@ fn reload_transitions_cancel_modes_without_losing_key_ownership() {
#[test]
fn escape_exits_with_every_modifier_combination() {
if !crate::startup_tests::private_runtime("input::binding_mode_tests::escape_exits_with_every_modifier_combination")
{
if !crate::startup_tests::private_runtime(
"input::binding_mode_tests::escape_exits_with_every_modifier_combination",
) {
return;
}
let mut events = EventLoop::try_new().unwrap();
@@ -220,7 +274,9 @@ fn input_capture_exits_modes_and_prevents_reentry() {
y: 0,
}]);
let id = state.input_capture.register(1, 3).unwrap();
let zone_set = state.input_capture.zones(id).unwrap()["zone_set"].as_u64().unwrap() as u32;
let zone_set = state.input_capture.zones(id).unwrap()["zone_set"]
.as_u64()
.unwrap() as u32;
state
.input_capture
.set_barriers(
@@ -0,0 +1,210 @@
//! Exercise dismissal through the real input path, including contact lifetimes.
use super::*;
use ferese_protocols::shell::v1::server::ferese_shell_v1::FereseShellV1;
use smithay::backend::input::{
TouchCancelEvent, TouchDownEvent, TouchFrameEvent, TouchMotionEvent, TouchSlot, TouchUpEvent,
UnusedEvent,
};
use smithay::backend::winit::WinitVirtualDevice;
use std::io::Read;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
#[derive(Debug)]
struct TouchInput;
impl InputBackend for TouchInput {
type Device = WinitVirtualDevice;
type KeyboardKeyEvent = UnusedEvent;
type PointerButtonEvent = UnusedEvent;
type PointerAxisEvent = UnusedEvent;
type PointerMotionEvent = UnusedEvent;
type PointerMotionAbsoluteEvent = UnusedEvent;
type GestureSwipeBeginEvent = UnusedEvent;
type GestureSwipeUpdateEvent = UnusedEvent;
type GestureSwipeEndEvent = UnusedEvent;
type GesturePinchBeginEvent = UnusedEvent;
type GesturePinchUpdateEvent = UnusedEvent;
type GesturePinchEndEvent = UnusedEvent;
type GestureHoldBeginEvent = UnusedEvent;
type GestureHoldEndEvent = UnusedEvent;
type TouchDownEvent = Contact;
type TouchMotionEvent = Contact;
type TouchUpEvent = Contact;
type TouchCancelEvent = Contact;
type TouchFrameEvent = Contact;
type TabletToolAxisEvent = UnusedEvent;
type TabletToolProximityEvent = UnusedEvent;
type TabletToolTipEvent = UnusedEvent;
type TabletToolButtonEvent = UnusedEvent;
type SwitchToggleEvent = UnusedEvent;
type SpecialEvent = UnusedEvent;
}
#[derive(Clone, Debug)]
struct Contact(u32, Point<f64, Logical>);
impl Event<TouchInput> for Contact {
fn time(&self) -> smithay::backend::input::InputTime {
smithay::backend::input::InputTime::from_millis(1)
}
fn device(&self) -> WinitVirtualDevice {
WinitVirtualDevice
}
}
impl TouchEvent<TouchInput> for Contact {
fn slot(&self) -> TouchSlot {
Some(self.0).into()
}
}
impl AbsolutePositionEvent<TouchInput> for Contact {
fn x(&self) -> f64 {
self.1.x
}
fn y(&self) -> f64 {
self.1.y
}
fn x_transformed(&self, _: i32) -> f64 {
self.1.x
}
fn y_transformed(&self, _: i32) -> f64 {
self.1.y
}
}
impl TouchDownEvent<TouchInput> for Contact {}
impl TouchMotionEvent<TouchInput> for Contact {}
impl TouchUpEvent<TouchInput> for Contact {}
impl TouchCancelEvent<TouchInput> for Contact {}
impl TouchFrameEvent<TouchInput> for Contact {}
#[test]
fn outside_launcher_touches_are_consumed_until_up_or_cancel() {
if !crate::startup_tests::private_runtime(
"input::launcher_touch_tests::outside_launcher_touches_are_consumed_until_up_or_cancel",
) {
return;
}
let (mut state, mut events, output) = super::layer_focus_tests::fixture();
state.window_rules = crate::config::Config::parse_source(
"window-rule app-id=dev.ferese.TouchTarget floating=#true\n",
)
.unwrap()
.window_rules()
.unwrap();
let (window, _wire) = crate::state::test_window_with_app_id(
&mut state,
&mut events,
0xff112233,
"dev.ferese.TouchTarget",
);
let id = state.windows.ids()[&window];
let bounds = state.windows.geometry(&id).unwrap().logical;
let app = window.toplevel().unwrap().wl_surface().clone();
let (launcher, _launcher_wire) = super::layer_focus_tests::layer_with_namespace(
&mut state,
&mut events,
&output,
1,
3,
"ferese-launcher",
);
let keyboard = state.seat.get_keyboard().unwrap();
let touch = state.seat.get_touch().unwrap();
assert_eq!(
keyboard.current_focus(),
Some(launcher.wl_surface().clone())
);
let outside = Contact(
0,
(bounds.x + bounds.width / 2., bounds.y + bounds.height / 2.).into(),
);
assert_eq!(state.surface_under(outside.1).unwrap().0, app);
let (server, mut shell_wire) = UnixStream::pair().unwrap();
let client = state
.display_handle
.insert_client(server, Arc::new(crate::state::ClientState::default()))
.unwrap();
let shell = client
.create_resource::<FereseShellV1, crate::shell_control::ShellData, Ferese>(
&state.display_handle,
10,
Default::default(),
)
.unwrap();
state.shell_resources.push(shell.downgrade());
state.process_input_event::<TouchInput>(InputEvent::TouchDown {
event: outside.clone(),
});
assert!(
!touch.is_grabbed(),
"dismissing must not begin an app touch sequence"
);
assert_eq!(
keyboard.current_focus(),
Some(launcher.wl_surface().clone())
);
// The launcher can close before the rest of this gesture arrives.
keyboard.set_focus(&mut state, Some(app.clone()), SERIAL_COUNTER.next_serial());
let second = Contact(1, outside.1);
state.process_input_event::<TouchInput>(InputEvent::TouchDown {
event: second.clone(),
});
for event in [outside.clone(), second.clone()] {
state.process_input_event::<TouchInput>(InputEvent::TouchMotion {
event: event.clone(),
});
state.process_input_event::<TouchInput>(InputEvent::TouchUp { event });
}
assert!(!touch.is_grabbed());
assert!(state.consumed_touch_slots.is_empty());
// A fresh tap must work after the swallowed contacts have ended.
state.process_input_event::<TouchInput>(InputEvent::TouchDown {
event: outside.clone(),
});
assert_eq!(touch.grab_start_data().unwrap().focus.unwrap().0, app);
state.process_input_event::<TouchInput>(InputEvent::TouchUp {
event: outside.clone(),
});
keyboard.set_focus(
&mut state,
Some(launcher.wl_surface().clone()),
SERIAL_COUNTER.next_serial(),
);
state.process_input_event::<TouchInput>(InputEvent::TouchDown {
event: outside.clone(),
});
state.process_input_event::<TouchInput>(InputEvent::TouchCancel {
event: outside.clone(),
});
assert!(state.consumed_touch_slots.is_empty());
assert!(!touch.is_grabbed());
// An inside tap still reaches the launcher itself after cancellation.
let inside = Contact(0, (20., 20.).into());
state.process_input_event::<TouchInput>(InputEvent::TouchDown {
event: inside.clone(),
});
assert_eq!(
touch.grab_start_data().unwrap().focus.unwrap().0,
*launcher.wl_surface()
);
state.process_input_event::<TouchInput>(InputEvent::TouchUp { event: inside });
state.display_handle.flush_clients().unwrap();
shell_wire.set_nonblocking(true).unwrap();
let mut bytes = Vec::new();
let _ = shell_wire.read_to_end(&mut bytes);
let opcode = FereseShellV1::interface()
.events
.iter()
.position(|event| event.name == "toggle_launcher")
.unwrap() as u16;
let (mut offset, mut dismissals) = (0, 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());
if object == shell.id().protocol_id() && header as u16 == opcode {
dismissals += 1;
}
offset += (header >> 16) as usize;
}
assert_eq!(dismissals, 2, "each outside gesture dismisses exactly once");
}
+948
View File
@@ -0,0 +1,948 @@
use super::*;
use crate::state::{
ClientState, dispatch_test_client as dispatch, test_client_request as request,
test_window as window,
};
use smithay::desktop::{LayerSurface, layer_map_for_output};
use smithay::output::{Mode, Output, 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::protocol::wl_compositor::WlCompositor;
use smithay::reexports::wayland_server::protocol::wl_shm::WlShm;
use std::io::{Read, Write};
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
fn layer(
state: &mut Ferese,
events: &mut EventLoop<'static, Ferese>,
output: &Output,
) -> (LayerSurface, UnixStream) {
layer_with_policy(state, events, output, 0, 2)
}
fn layer_with_policy(
state: &mut Ferese,
events: &mut EventLoop<'static, Ferese>,
output: &Output,
interactivity: u32,
level: u32,
) -> (LayerSurface, UnixStream) {
layer_with_namespace(state, events, output, interactivity, level, "bar")
}
pub(super) fn layer_with_namespace(
state: &mut Ferese,
events: &mut EventLoop<'static, Ferese>,
output: &Output,
interactivity: u32,
level: u32,
namespace: &str,
) -> (LayerSurface, UnixStream) {
let initial_focus = state.seat.get_keyboard().unwrap().current_focus();
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 layers = client
.create_resource::<ZwlrLayerShellV1, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
4,
smithay::wayland::GlobalData,
)
.unwrap();
let shm = client
.create_resource::<WlShm, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
1,
smithay::wayland::GlobalData,
)
.unwrap();
request(&mut wire, compositor.id().protocol_id(), 0, &[2], None);
let mut name = namespace.as_bytes().to_vec();
name.push(0);
let mut args = vec![3, 2, 0, level, name.len() as u32];
name.resize(name.len().next_multiple_of(4), 0);
args.extend(
name.chunks_exact(4)
.map(|word| u32::from_ne_bytes(word.try_into().unwrap())),
);
request(&mut wire, layers.id().protocol_id(), 0, &args, None);
request(&mut wire, 3, 0, &[64, 48], None);
request(&mut wire, 3, 1, &[1 | 4], None); // Top left.
request(&mut wire, 3, 4, &[interactivity], None);
request(&mut wire, 2, 6, &[], None);
dispatch(events, state);
assert_eq!(
state.seat.get_keyboard().unwrap().current_focus(),
initial_focus,
"an initially unconfigured layer must not acquire focus"
);
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 serial = None;
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());
if object == 3 && header & 0xffff == 0 {
serial = Some(u32::from_ne_bytes(
bytes[offset + 8..offset + 12].try_into().unwrap(),
));
}
offset += (header >> 16) as usize;
}
request(&mut wire, 3, 6, &[serial.expect("layer configure")], None);
dispatch(events, state);
assert_eq!(
state.seat.get_keyboard().unwrap().current_focus(),
initial_focus,
"acknowledging configure without mapping must not acquire focus"
);
let mut file = tempfile::tempfile().unwrap();
file.write_all(&0xff112233u32.to_ne_bytes().repeat(64 * 48))
.unwrap();
request(
&mut wire,
shm.id().protocol_id(),
0,
&[4, 64 * 48 * 4],
Some(file.as_raw_fd()),
);
request(&mut wire, 4, 0, &[5, 0, 64, 48, 64 * 4, 0], None);
request(&mut wire, 2, 1, &[5, 0, 0], None);
request(&mut wire, 2, 6, &[], None);
dispatch(events, state);
let layer = layer_map_for_output(output)
.layers()
.find(|layer| {
layer
.wl_surface()
.client()
.is_some_and(|owner| owner.id() == client.id())
})
.unwrap()
.clone();
(layer, wire)
}
#[test]
fn on_demand_layer_focus_follows_opening_and_outside_clicks() {
if !crate::startup_tests::private_runtime(
"input::layer_focus_tests::on_demand_layer_focus_follows_opening_and_outside_clicks",
) {
return;
}
let mut events = 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(
"focus".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));
state.register_output(&output, "focus".into());
let (panel, mut wire) = layer(&mut state, &mut events, &output);
let pointer = state.seat.get_pointer().unwrap();
let keyboard = state.seat.get_keyboard().unwrap();
let inside = Point::from((20., 20.));
pointer.motion(
&mut state,
Some((panel.wl_surface().clone(), (0., 0.).into())),
&MotionEvent {
location: inside,
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(0),
},
);
// The opening click arrives while the collapsed panel is noninteractive.
state.focus_window_at(inside, SERIAL_COUNTER.next_serial(), true);
assert_eq!(keyboard.current_focus(), None);
request(&mut wire, 3, 4, &[2], None); // OnDemand.
request(&mut wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
assert_eq!(
keyboard.current_focus(),
Some(panel.wl_surface().clone()),
"opening must not require a second click"
);
state.focus_window_at(inside, SERIAL_COUNTER.next_serial(), true);
assert_eq!(
keyboard.current_focus(),
Some(panel.wl_surface().clone()),
"inside clicks keep the panel open"
);
let outside = Point::from((400., 300.));
pointer.motion(
&mut state,
None,
&MotionEvent {
location: outside,
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(1),
},
);
state.focus_window_at(outside, SERIAL_COUNTER.next_serial(), true);
assert_eq!(
keyboard.current_focus(),
None,
"empty-desktop clicks dismiss the panel"
);
request(&mut wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
assert_eq!(
keyboard.current_focus(),
None,
"later animation commits must not reclaim focus"
);
let (wallpaper, _wallpaper_wire) = layer(&mut state, &mut events, &output);
keyboard.set_focus(
&mut state,
Some(panel.wl_surface().clone()),
SERIAL_COUNTER.next_serial(),
);
assert_eq!(state.layer_under(inside).unwrap().0, wallpaper);
state.focus_window_at(inside, SERIAL_COUNTER.next_serial(), true);
assert_eq!(
keyboard.current_focus(),
None,
"noninteractive-layer clicks dismiss the panel"
);
let (first, _first_wire) = window(&mut state, &mut events, 0xff112233);
let (second, _second_wire) = window(&mut state, &mut events, 0xff334455);
let first_id = state.windows.ids()[&first];
assert!(state.activate_managed_window(first_id));
let second_bounds = state
.presented_window_rect(state.windows.ids()[&second])
.unwrap();
let on_window = Point::from((second_bounds.x + 20., second_bounds.y + 20.));
state.input_settings.focus_follows_mouse = true;
keyboard.set_focus(
&mut state,
Some(panel.wl_surface().clone()),
SERIAL_COUNTER.next_serial(),
);
state.focus_window_under_pointer(&pointer, pointer.current_location(), on_window);
assert_eq!(
keyboard.current_focus(),
Some(panel.wl_surface().clone()),
"pointer leave must not dismiss the panel"
);
state.focus_window_at(on_window, SERIAL_COUNTER.next_serial(), true);
assert_eq!(
keyboard.current_focus(),
Some(second.toplevel().unwrap().wl_surface().clone()),
"an outside click must still focus its window"
);
request(&mut wire, 3, 4, &[0], None); // None.
request(&mut wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
request(&mut wire, 3, 4, &[2], None); // OnDemand, without a pointer on the panel.
request(&mut wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
assert_eq!(
keyboard.current_focus(),
Some(second.toplevel().unwrap().wl_surface().clone()),
"enabling a panel away from the pointer must not steal window focus"
);
}
pub(super) fn fixture() -> (Ferese, EventLoop<'static, Ferese>, Output) {
fixture_with_animations(false)
}
fn fixture_with_animations(animations: bool) -> (Ferese, EventLoop<'static, Ferese>, Output) {
let mut events = EventLoop::try_new().unwrap();
let mut config = crate::config::Config::default().runtime_config().unwrap();
config.wallpaper.path = None;
config.animations_enabled = animations;
let mut state = Ferese::new(
&mut events,
smithay::reexports::wayland_server::Display::new().unwrap(),
config,
)
.unwrap();
let output = Output::new(
"layers".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));
state.register_output(&output, "layers".into());
(state, events, output)
}
fn commit_policy(
state: &mut Ferese,
events: &mut EventLoop<'static, Ferese>,
wire: &mut UnixStream,
keyboard: u32,
) {
request(wire, 3, 4, &[keyboard], None);
request(wire, 2, 6, &[], None);
dispatch(events, state);
}
// Unmapping resets the layer's pending state. Reapply its policy and complete
// the initial bufferless configure/ack exchange before attaching again.
fn remap_layer(
state: &mut Ferese,
events: &mut EventLoop<'static, Ferese>,
wire: &mut UnixStream,
keyboard: u32,
level: u32,
) {
request(wire, 3, 0, &[65, 48], None);
request(wire, 3, 1, &[1 | 4], None);
request(wire, 3, 4, &[keyboard], None);
request(wire, 3, 8, &[level], None);
request(wire, 2, 6, &[], None);
dispatch(events, state);
ack_latest_configure(wire);
request(wire, 2, 1, &[5, 0, 0], None);
request(wire, 2, 6, &[], None);
dispatch(events, state);
}
#[test]
fn null_buffer_commit_revokes_layer_focus_without_destroying_role() {
if !crate::startup_tests::private_runtime(
"input::layer_focus_tests::null_buffer_commit_revokes_layer_focus_without_destroying_role",
) {
return;
}
let (mut state, mut events, output) = fixture();
let (window, _window_wire) = window(&mut state, &mut events, 0xff112233);
let id = state.windows.ids()[&window];
assert!(state.activate_managed_window(id));
let fallback = Some(window.toplevel().unwrap().wl_surface().clone());
let keyboard = state.seat.get_keyboard().unwrap();
for interactivity in [1, 2] {
let (panel, mut wire) = layer(&mut state, &mut events, &output);
commit_policy(&mut state, &mut events, &mut wire, interactivity);
state.focus_window_at((20., 20.).into(), SERIAL_COUNTER.next_serial(), true);
assert_eq!(keyboard.current_focus(), Some(panel.wl_surface().clone()));
request(&mut wire, 2, 1, &[0, 0, 0], None); // wl_surface.attach(NULL).
request(&mut wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
assert!(
panel.layer_surface().alive(),
"the layer role survives unmapping"
);
assert!(
state
.space
.layer_for_surface(
panel.wl_surface(),
smithay::desktop::WindowSurfaceType::TOPLEVEL
)
.is_some()
);
assert!(
smithay::backend::renderer::utils::with_renderer_surface_state(
panel.wl_surface(),
|s| s.buffer().is_none()
)
.unwrap()
);
assert_eq!(
keyboard.current_focus(),
fallback,
"null commit must revoke keyboard focus for policy {interactivity}"
);
// An unmapped surface is back in its initial state. Keep this commit
// valid while asserting that keyboard policy alone cannot remap it.
request(&mut wire, 3, 0, &[64, 48], None);
request(&mut wire, 3, 1, &[1 | 4], None);
request(&mut wire, 3, 4, &[interactivity], None);
request(&mut wire, 3, 8, &[2], None);
request(&mut wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
assert_eq!(
keyboard.current_focus(),
fallback,
"unmapped commits must not reclaim focus"
);
remap_layer(&mut state, &mut events, &mut wire, interactivity, 2);
if interactivity == 2 {
assert_eq!(
keyboard.current_focus(),
fallback,
"on-demand remap requires interaction"
);
state.focus_window_at((20., 20.).into(), SERIAL_COUNTER.next_serial(), true);
}
assert_eq!(
keyboard.current_focus(),
Some(panel.wl_surface().clone()),
"remapped layer remains focusable"
);
request(&mut wire, 3, 7, &[], None); // Destroy the role.
dispatch(&mut events, &mut state);
assert_eq!(
keyboard.current_focus(),
fallback,
"destruction restores window focus"
);
}
}
fn ack_latest_configure(wire: &mut UnixStream) {
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 serial = None;
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());
if object == 3 && header & 0xffff == 0 {
serial = Some(u32::from_ne_bytes(
bytes[offset + 8..offset + 12].try_into().unwrap(),
));
}
offset += (header >> 16) as usize;
}
request(wire, 3, 6, &[serial.expect("layer configure")], None);
}
#[test]
fn exclusive_layer_priority_and_output_removal_restore_eligible_focus() {
if !crate::startup_tests::private_runtime(
"input::layer_focus_tests::exclusive_layer_priority_and_output_removal_restore_eligible_focus",
) {
return;
}
let (mut state, mut events, output) = fixture();
let (window, _window_wire) = window(&mut state, &mut events, 0xff112233);
assert!(state.activate_managed_window(state.windows.ids()[&window]));
let fallback = Some(window.toplevel().unwrap().wl_surface().clone());
let keyboard = state.seat.get_keyboard().unwrap();
let (top, mut top_wire) = layer_with_policy(&mut state, &mut events, &output, 1, 2);
assert_eq!(keyboard.current_focus(), Some(top.wl_surface().clone()));
let (overlay, mut overlay_wire) = layer_with_policy(&mut state, &mut events, &output, 1, 3);
assert_eq!(keyboard.current_focus(), Some(overlay.wl_surface().clone()));
commit_policy(&mut state, &mut events, &mut top_wire, 1);
assert_eq!(
keyboard.current_focus(),
Some(overlay.wl_surface().clone()),
"Top commits cannot steal from an exclusive Overlay"
);
request(&mut overlay_wire, 2, 1, &[0, 0, 0], None);
request(&mut overlay_wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
assert_eq!(
keyboard.current_focus(),
Some(top.wl_surface().clone()),
"unmapping falls back to another eligible exclusive layer"
);
commit_policy(&mut state, &mut events, &mut top_wire, 0);
assert_eq!(
keyboard.current_focus(),
fallback,
"disabling keyboard interaction restores the window"
);
commit_policy(&mut state, &mut events, &mut top_wire, 1);
assert_eq!(keyboard.current_focus(), Some(top.wl_surface().clone()));
state.unregister_output(&output);
assert_eq!(
keyboard.current_focus(),
None,
"removed outputs cannot retain layer focus"
);
}
#[test]
fn layer_mapping_commits_do_not_override_session_lock() {
use smithay::reexports::wayland_protocols::ext::session_lock::v1::server::ext_session_lock_manager_v1::ExtSessionLockManagerV1;
if !crate::startup_tests::private_runtime(
"input::layer_focus_tests::layer_mapping_commits_do_not_override_session_lock",
) {
return;
}
let (mut state, mut events, output) = fixture();
let (panel, mut wire) = layer_with_policy(&mut state, &mut events, &output, 1, 3);
let client = panel.wl_surface().client().unwrap();
let manager = client
.create_resource::<ExtSessionLockManagerV1, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
1,
smithay::wayland::GlobalData,
)
.unwrap();
request(&mut wire, manager.id().protocol_id(), 1, &[6], None);
dispatch(&mut events, &mut state);
let keyboard = state.seat.get_keyboard().unwrap();
assert_eq!(keyboard.current_focus(), None);
commit_policy(&mut state, &mut events, &mut wire, 1);
assert_eq!(
keyboard.current_focus(),
None,
"an exclusive layer cannot focus during lock acquisition"
);
request(&mut wire, 2, 1, &[0, 0, 0], None);
request(&mut wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
remap_layer(&mut state, &mut events, &mut wire, 1, 3);
assert_eq!(
keyboard.current_focus(),
None,
"layer remapping cannot bypass the lock"
);
request(&mut wire, 3, 7, &[], None);
dispatch(&mut events, &mut state);
assert_eq!(
keyboard.current_focus(),
None,
"layer destruction preserves lock-owned focus policy"
);
}
#[test]
fn fullscreen_hidden_top_commits_cannot_steal_keyboard_focus() {
if !crate::startup_tests::private_runtime(
"input::layer_focus_tests::fullscreen_hidden_top_commits_cannot_steal_keyboard_focus",
) {
return;
}
let (mut state, mut events, output) = fixture();
let (window, _window_wire) = window(&mut state, &mut events, 0xff112233);
let id = state.windows.ids()[&window];
assert!(state.activate_managed_window(id));
let fallback = Some(window.toplevel().unwrap().wl_surface().clone());
let keyboard = state.seat.get_keyboard().unwrap();
state.set_window_fullscreen(id, true);
let (top, mut top_wire) = layer_with_policy(&mut state, &mut events, &output, 1, 2);
assert!(state.output_has_fullscreen(&output));
assert!(
state.layer_under((20., 20.).into()).is_none(),
"Top is excluded from hit testing by fullscreen policy"
);
assert_eq!(
keyboard.current_focus(),
fallback,
"a hidden Top layer's map commit cannot steal keyboard focus"
);
commit_policy(&mut state, &mut events, &mut top_wire, 1);
assert_eq!(
keyboard.current_focus(),
fallback,
"hidden animation commits cannot steal focus"
);
state.set_window_fullscreen(id, false);
assert_eq!(
keyboard.current_focus(),
Some(top.wl_surface().clone()),
"fullscreen exit restores visible exclusive layers"
);
assert_eq!(state.layer_under((20., 20.).into()).unwrap().0, top);
state.set_window_fullscreen(id, true);
assert_eq!(
keyboard.current_focus(),
fallback,
"fullscreen entry revokes hidden Top focus immediately"
);
let (overlay, mut overlay_wire) = layer_with_policy(&mut state, &mut events, &output, 1, 3);
assert_eq!(
keyboard.current_focus(),
Some(overlay.wl_surface().clone()),
"Overlay remains eligible over fullscreen"
);
assert_eq!(state.layer_under((20., 20.).into()).unwrap().0, overlay);
request(&mut overlay_wire, 3, 7, &[], None);
dispatch(&mut events, &mut state);
assert_eq!(
keyboard.current_focus(),
fallback,
"destroying Overlay must not restore hidden Top"
);
request(&mut top_wire, 2, 1, &[0, 0, 0], None);
request(&mut top_wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
remap_layer(&mut state, &mut events, &mut top_wire, 1, 2);
assert_eq!(
keyboard.current_focus(),
fallback,
"hidden remapping cannot reclaim focus"
);
state.set_window_fullscreen(id, false);
commit_policy(&mut state, &mut events, &mut top_wire, 2);
state.focus_window_at((20., 20.).into(), SERIAL_COUNTER.next_serial(), true);
assert_eq!(keyboard.current_focus(), Some(top.wl_surface().clone()));
let pointer = state.seat.get_pointer().unwrap();
pointer.motion(
&mut state,
Some((top.wl_surface().clone(), (0., 0.).into())),
&MotionEvent {
location: (20., 20.).into(),
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(0),
},
);
state.set_window_fullscreen(id, true);
assert_eq!(
keyboard.current_focus(),
fallback,
"fullscreen also revokes on-demand Top focus"
);
commit_policy(&mut state, &mut events, &mut top_wire, 0);
commit_policy(&mut state, &mut events, &mut top_wire, 2);
assert_eq!(
keyboard.current_focus(),
fallback,
"a stale pointer owner cannot activate a hidden panel"
);
state.set_window_fullscreen(id, false);
assert_eq!(
keyboard.current_focus(),
fallback,
"on-demand layers require interaction after fullscreen exit"
);
state.focus_window_at((20., 20.).into(), SERIAL_COUNTER.next_serial(), true);
assert_eq!(keyboard.current_focus(), Some(top.wl_surface().clone()));
}
#[test]
fn valid_layer_popup_grab_survives_commits_but_not_parent_unmapping() {
use smithay::desktop::{PopupKeyboardGrab, PopupKind};
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_wm_base::XdgWmBase;
use smithay::wayland::shell::xdg::{XdgShellHandler, XdgWmBaseUserData};
if !crate::startup_tests::private_runtime(
"input::layer_focus_tests::valid_layer_popup_grab_survives_commits_but_not_parent_unmapping",
) {
return;
}
let (mut state, mut events, output) = fixture();
let (top, mut wire) = layer_with_policy(&mut state, &mut events, &output, 2, 2);
state.focus_window_at((20., 20.).into(), SERIAL_COUNTER.next_serial(), true);
let client = top.wl_surface().client().unwrap();
let compositor = client
.create_resource::<WlCompositor, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
6,
smithay::wayland::GlobalData,
)
.unwrap();
let shell = client
.create_resource::<XdgWmBase, XdgWmBaseUserData, Ferese>(
&state.display_handle,
6,
Default::default(),
)
.unwrap();
request(&mut wire, shell.id().protocol_id(), 1, &[6], None); // Positioner.
request(&mut wire, 6, 1, &[32, 24], None);
request(&mut wire, 6, 2, &[0, 0, 32, 24], None);
request(&mut wire, compositor.id().protocol_id(), 0, &[7], None);
request(&mut wire, shell.id().protocol_id(), 2, &[8, 7], None);
request(&mut wire, 8, 2, &[9, 0, 6], None); // Popup with layer parent assigned next.
request(&mut wire, 3, 5, &[9], None);
dispatch(&mut events, &mut state);
let popup = state
.xdg_shell_state()
.popup_surfaces()
.iter()
.find(|popup| {
popup
.wl_surface()
.client()
.is_some_and(|owner| owner.id() == client.id())
})
.unwrap()
.clone();
let serial = SERIAL_COUNTER.next_serial();
let seat = state.seat.clone();
let grab = state
.popups
.grab_popup(
top.wl_surface().clone(),
PopupKind::Xdg(popup.clone()),
&seat,
serial,
)
.unwrap();
let keyboard = state.seat.get_keyboard().unwrap();
keyboard.set_grab(&mut state, PopupKeyboardGrab::new(&grab), serial);
keyboard.set_focus(&mut state, grab.current_grab(), serial);
let popup_focus = Some(popup.wl_surface().clone());
assert_eq!(keyboard.current_focus(), popup_focus);
commit_policy(&mut state, &mut events, &mut wire, 2);
assert!(keyboard.is_grabbed());
assert_eq!(
keyboard.current_focus(),
popup_focus,
"valid popup grabs survive parent commits"
);
let (_overlay, _overlay_wire) = layer_with_policy(&mut state, &mut events, &output, 1, 3);
assert_eq!(
keyboard.current_focus(),
popup_focus,
"new exclusive layers do not interrupt a valid popup grab"
);
request(&mut wire, 2, 1, &[0, 0, 0], None);
request(&mut wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
assert!(
!keyboard.is_grabbed(),
"unmapping revokes the parent popup grab"
);
assert_ne!(keyboard.current_focus(), popup_focus);
assert_ne!(keyboard.current_focus(), Some(top.wl_surface().clone()));
}
#[test]
fn fullscreen_layer_eligibility_is_per_output_and_respects_overview() {
if !crate::startup_tests::private_runtime(
"input::layer_focus_tests::fullscreen_layer_eligibility_is_per_output_and_respects_overview",
) {
return;
}
let (mut state, mut events, output) = fixture();
let (window, _window_wire) = window(&mut state, &mut events, 0xff112233);
let id = state.windows.ids()[&window];
assert!(state.activate_managed_window(id));
state.set_window_fullscreen(id, true);
let (top, _top_wire) = layer_with_policy(&mut state, &mut events, &output, 1, 2);
assert!(!state.layer_keyboard_eligible(&top));
assert!(!state.layer_visible_for_frame(
&output,
smithay::wayland::shell::wlr_layer::Layer::Top,
false
));
assert!(state.layer_visible_for_frame(
&output,
smithay::wayland::shell::wlr_layer::Layer::Top,
true
));
state.set_overview_active(true);
assert!(state.layer_keyboard_eligible(&top));
assert_eq!(
state.seat.get_keyboard().unwrap().current_focus(),
Some(top.wl_surface().clone())
);
state.set_overview_active(false);
assert!(!state.layer_keyboard_eligible(&top));
assert_eq!(
state.seat.get_keyboard().unwrap().current_focus(),
Some(window.toplevel().unwrap().wl_surface().clone())
);
let other = Output::new(
"other".into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
other.change_current_state(
Some(Mode {
size: (800, 600).into(),
refresh: 60_000,
}),
None,
None,
None,
);
state.space.map_output(&other, (800, 0));
state.register_output(&other, "other".into());
// Move the test role into the second output's map to isolate visibility
// policy from the separately tested output-selection policy.
let (other_top, mut wire) = layer_with_policy(&mut state, &mut events, &output, 0, 2);
layer_map_for_output(&output).unmap_layer(&other_top);
layer_map_for_output(&other).map_layer(&other_top).unwrap();
commit_policy(&mut state, &mut events, &mut wire, 1);
assert!(
state.layer_keyboard_eligible(&other_top),
"fullscreen on the first output does not hide Top on another"
);
assert_eq!(
state.seat.get_keyboard().unwrap().current_focus(),
Some(other_top.wl_surface().clone())
);
state.unregister_output(&other);
assert!(!state.layer_keyboard_eligible(&other_top));
assert_eq!(
state.seat.get_keyboard().unwrap().current_focus(),
Some(window.toplevel().unwrap().wl_surface().clone())
);
}
#[test]
fn animated_overview_exit_revokes_top_focus_when_it_becomes_hidden() {
if !crate::startup_tests::private_runtime(
"input::layer_focus_tests::animated_overview_exit_revokes_top_focus_when_it_becomes_hidden",
) {
return;
}
let (mut state, mut events, output) = fixture_with_animations(true);
let (window, _window_wire) = window(&mut state, &mut events, 0xff112233);
let id = state.windows.ids()[&window];
assert!(state.activate_managed_window(id));
state.set_window_fullscreen(id, true);
let (top, _wire) = layer_with_policy(&mut state, &mut events, &output, 1, 2);
let keyboard = state.seat.get_keyboard().unwrap();
state.set_overview_active(true);
assert!(state.layer_keyboard_eligible(&top));
assert_eq!(keyboard.current_focus(), Some(top.wl_surface().clone()));
let tick = std::time::Instant::now();
for step in 1..=300 {
state.advance_animations(tick + std::time::Duration::from_millis(16 * step));
}
state.set_overview_active(false);
assert!(
state.layer_keyboard_eligible(&top),
"Top remains visible during the overview exit animation"
);
for step in 1..=300 {
state.advance_animations(tick + std::time::Duration::from_millis(4800 + 16 * step));
}
assert!(!state.layer_keyboard_eligible(&top));
assert_eq!(
keyboard.current_focus(),
Some(window.toplevel().unwrap().wl_surface().clone()),
"completion of the visibility transition revokes focus without a client commit"
);
}
#[test]
fn touch_opened_on_demand_layer_does_not_need_pointer_focus() {
if !crate::startup_tests::private_runtime(
"input::layer_focus_tests::touch_opened_on_demand_layer_does_not_need_pointer_focus",
) {
return;
}
let (mut state, mut events, output) = fixture();
let (panel, mut wire) = layer(&mut state, &mut events, &output);
let pointer = state.seat.get_pointer().unwrap();
let keyboard = state.seat.get_keyboard().unwrap();
let inside = Point::from((20., 20.));
assert_eq!(pointer.current_focus(), None);
// TouchDown focuses the target before delivering the opening touch.
// Unlike the pointer regression, no pointer motion establishes focus.
state.focus_window_at(inside, SERIAL_COUNTER.next_serial(), true);
assert_eq!(keyboard.current_focus(), None);
request(&mut wire, 3, 4, &[2], None); // OnDemand.
request(&mut wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
assert_eq!(
keyboard.current_focus(),
Some(panel.wl_surface().clone()),
"opening must not require a second click"
);
state.focus_window_at(inside, SERIAL_COUNTER.next_serial(), true);
assert_eq!(
keyboard.current_focus(),
Some(panel.wl_surface().clone()),
"inside clicks keep the panel open"
);
let outside = Point::from((400., 300.));
let input_time = state.last_pointer_time;
pointer.motion(
&mut state,
None,
&MotionEvent {
location: outside,
serial: SERIAL_COUNTER.next_serial(),
time: input_time,
},
);
state.focus_window_at(outside, SERIAL_COUNTER.next_serial(), true);
assert_eq!(
keyboard.current_focus(),
None,
"empty-desktop clicks dismiss the panel"
);
request(&mut wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
assert_eq!(
keyboard.current_focus(),
None,
"later animation commits must not reclaim focus"
);
commit_policy(&mut state, &mut events, &mut wire, 0);
state.focus_window_at(inside, SERIAL_COUNTER.next_serial(), true);
state.focus_window_at(outside, SERIAL_COUNTER.next_serial(), true);
commit_policy(&mut state, &mut events, &mut wire, 2);
assert_eq!(
keyboard.current_focus(),
None,
"a later interaction cancels delayed focus"
);
commit_policy(&mut state, &mut events, &mut wire, 0);
pointer.motion(
&mut state,
Some((panel.wl_surface().clone(), (0., 0.).into())),
&MotionEvent {
location: inside,
serial: SERIAL_COUNTER.next_serial(),
time: input_time,
},
);
commit_policy(&mut state, &mut events, &mut wire, 2);
assert_eq!(
keyboard.current_focus(),
None,
"pointer hover alone cannot open keyboard focus"
);
}
+758
View File
@@ -0,0 +1,758 @@
use super::{AxisSource, BindingAction, Ferese, PointerHandle, Serial, with_pointer_constraint};
use crate::config::{PointerTrigger, ScrollDirection};
use std::collections::HashMap;
use std::time::{Duration, Instant};
#[derive(Clone, Debug, PartialEq, Eq)]
struct ScrollContext {
modifiers: u8,
mode: Option<String>,
revision: u64,
}
#[derive(Default)]
struct ScrollAxis {
remainder: f64,
context: Option<ScrollContext>,
finger_owner: Option<bool>,
last_action: Option<Instant>,
}
impl ScrollAxis {
fn feed(
&mut self,
delta: Option<f64>,
finger: bool,
context: &ScrollContext,
bound: bool,
now: Instant,
cooldown: Duration,
) -> (bool, i32) {
let owned = if finger {
*self.finger_owner.get_or_insert(bound)
} else {
bound
};
let Some(delta) = delta else {
*self = Self::default();
return (owned, 0);
};
if self.context.as_ref() != Some(context) {
self.remainder = 0.0;
self.last_action = None;
if finger && self.context.is_some() && owned {
// Finish an intercepted finger gesture without delivering its tail
// or changing actions halfway through it.
return (true, 0);
}
self.context = Some(context.clone());
}
if !owned || !bound || !delta.is_finite() {
self.remainder = 0.0;
return (owned, 0);
}
if self.remainder.signum() != delta.signum() {
self.remainder = 0.0;
}
self.remainder += delta;
let threshold = if finger { 15.0 } else { 120.0 };
let ticks = (self.remainder / threshold).trunc().clamp(-32.0, 32.0) as i32;
self.remainder %= threshold;
if ticks == 0
|| self
.last_action
.is_some_and(|last| now.duration_since(last) < cooldown)
{
return (true, 0);
}
self.last_action = Some(now);
(
true,
if cooldown.is_zero() {
ticks
} else {
ticks.signum()
},
)
}
}
#[derive(Default)]
struct DeviceScroll {
axes: [ScrollAxis; 2],
fraction: [f64; 2],
factor: f64,
}
#[derive(Default)]
pub(crate) struct ScrollBindings {
devices: HashMap<String, DeviceScroll>,
}
impl Ferese {
fn pointer_bindings_allowed(&self, pointer: &PointerHandle<Self>) -> bool {
!self.session_lock.active()
&& !self.input_capture.active()
&& !self.overview.is_active()
&& self.active_shortcuts_inhibitor.is_none()
&& !pointer.is_grabbed()
&& !pointer.current_focus().is_some_and(|surface| {
with_pointer_constraint(&surface, pointer, |constraint| {
constraint.is_some_and(|constraint| constraint.is_active())
})
})
}
pub(super) fn handle_pointer_binding(
&mut self,
pointer: &PointerHandle<Self>,
button: u32,
serial: Serial,
) -> bool {
if !self.pointer_bindings_allowed(pointer) {
return false;
}
let Some(keyboard) = self.seat.get_keyboard() else {
return false;
};
let action = self
.binding_modes
.bindings(&self.bindings)
.binding_for_pointer(PointerTrigger::Button(button), keyboard.modifier_state())
.map(|binding| binding.action.clone());
let Some(action) = action else {
return false;
};
if matches!(
action,
BindingAction::PointerMove | BindingAction::PointerResize
) {
return self.start_pointer_window_grab(
pointer,
button,
serial,
action == BindingAction::PointerResize,
);
}
// Bindings use the current keyboard focus, like ordinary shortcuts.
self.consumed_pointer_buttons.insert(button);
self.execute_binding(action);
true
}
}
impl Ferese {
pub(super) fn filter_pointer_scroll(
&mut self,
device: String,
source: AxisSource,
values: [f64; 2],
v120: [Option<f64>; 2],
stops: [bool; 2],
) -> [bool; 2] {
let Some(pointer) = self.seat.get_pointer() else {
return [false; 2];
};
let Some(keyboard) = self.seat.get_keyboard() else {
return [false; 2];
};
let modifiers = keyboard.modifier_state();
let allowed = self.pointer_bindings_allowed(&pointer);
let finger = source == AxisSource::Finger;
let wheel = matches!(source, AxisSource::Wheel | AxisSource::WheelTilt);
if !finger && !wheel {
return [false; 2];
}
let context = ScrollContext {
modifiers: (modifiers.logo as u8)
| ((modifiers.ctrl as u8) << 1)
| ((modifiers.alt as u8) << 2)
| ((modifiers.shift as u8) << 3),
mode: self.binding_modes.active().map(str::to_owned),
revision: self.ipc_events.config_revision,
};
let bindings = self.binding_modes.bindings(&self.bindings);
let actions = [
ScrollDirection::Left,
ScrollDirection::Right,
ScrollDirection::Up,
ScrollDirection::Down,
]
.map(|direction| {
allowed
.then(|| {
bindings.binding_for_pointer(
if finger {
PointerTrigger::Touchpad(direction)
} else {
PointerTrigger::Wheel(direction)
},
modifiers,
)
})
.flatten()
.map(|binding| binding.action.clone())
});
// Bound storage even for synthetic backends that invent device IDs.
if self.pointer_scroll.devices.len() >= 64
&& !self.pointer_scroll.devices.contains_key(&device)
{
self.pointer_scroll.devices.clear();
}
let axes = &mut self.pointer_scroll.devices.entry(device).or_default().axes;
let mut consumed = [false; 2];
let mut steps = [0; 2];
for axis in 0..2 {
if values[axis] == 0.0 && v120[axis].is_none() && !stops[axis] {
continue;
}
let delta = if finger {
values[axis]
} else {
v120[axis].unwrap_or(values[axis] * 8.0)
};
(consumed[axis], steps[axis]) = axes[axis].feed(
(!stops[axis]).then_some(delta),
finger,
&context,
actions[axis * 2].is_some() || actions[axis * 2 + 1].is_some(),
Instant::now(),
Duration::from_millis(self.input_settings.mouse.scroll_cooldown_ms),
);
}
for (axis, steps) in steps.into_iter().enumerate() {
let index = axis * 2 + usize::from(steps > 0);
if let Some(action) = &actions[index] {
for _ in 0..steps.unsigned_abs() {
if self.session_lock.active() || self.input_capture.active() {
break;
}
self.execute_binding(action.clone());
}
}
}
consumed
}
}
#[cfg(test)]
mod tests {
use super::*;
fn context(modifiers: u8) -> ScrollContext {
ScrollContext {
modifiers,
mode: None,
revision: 0,
}
}
#[test]
fn wheel_accumulates_high_resolution_steps_and_discards_reversed_remainder() {
let mut axis = ScrollAxis::default();
let now = Instant::now();
let feed = |axis: &mut ScrollAxis, amount| {
axis.feed(Some(amount), false, &context(1), true, now, Duration::ZERO)
};
assert_eq!(feed(&mut axis, 60.0), (true, 0));
assert_eq!(feed(&mut axis, 60.0), (true, 1));
assert_eq!(feed(&mut axis, 90.0), (true, 0));
assert_eq!(feed(&mut axis, -60.0), (true, 0));
assert_eq!(feed(&mut axis, -60.0), (true, -1));
assert_eq!(feed(&mut axis, f64::NAN), (true, 0));
}
#[test]
fn wheel_remainder_does_not_cross_modifier_or_config_changes() {
let mut axis = ScrollAxis::default();
let now = Instant::now();
axis.feed(Some(60.0), false, &context(1), true, now, Duration::ZERO);
assert_eq!(
axis.feed(Some(60.0), false, &context(2), true, now, Duration::ZERO),
(true, 0)
);
let mut updated = context(2);
updated.revision = 1;
assert_eq!(
axis.feed(Some(60.0), false, &updated, true, now, Duration::ZERO),
(true, 0)
);
}
#[test]
fn finger_gesture_keeps_ownership_until_stop() {
let now = Instant::now();
let mut axis = ScrollAxis::default();
assert_eq!(
axis.feed(Some(15.0), true, &context(1), true, now, Duration::ZERO),
(true, 1)
);
assert_eq!(
axis.feed(Some(15.0), true, &context(0), false, now, Duration::ZERO),
(true, 0)
);
assert_eq!(
axis.feed(None, true, &context(0), false, now, Duration::ZERO),
(true, 0)
);
assert_eq!(
axis.feed(Some(15.0), true, &context(0), false, now, Duration::ZERO),
(false, 0)
);
assert_eq!(
axis.feed(Some(15.0), true, &context(1), true, now, Duration::ZERO),
(false, 0)
);
assert_eq!(
axis.feed(None, true, &context(1), true, now, Duration::ZERO),
(false, 0)
);
}
#[test]
fn cooldown_consumes_ticks_without_replaying_them_later() {
let mut axis = ScrollAxis::default();
let now = Instant::now();
let cooldown = Duration::from_millis(150);
assert_eq!(
axis.feed(Some(120.0), false, &context(1), true, now, cooldown),
(true, 1)
);
assert_eq!(
axis.feed(Some(240.0), false, &context(1), true, now, cooldown),
(true, 0)
);
assert_eq!(
axis.feed(
Some(120.0),
false,
&context(1),
true,
now + cooldown,
cooldown
),
(true, 1)
);
}
}
impl Ferese {
pub(super) fn scale_mouse_scroll(
&mut self,
device: String,
source: AxisSource,
values: [f64; 2],
v120: [Option<f64>; 2],
) -> ([f64; 2], [Option<i32>; 2]) {
if !matches!(source, AxisSource::Wheel | AxisSource::WheelTilt) {
return (values, v120.map(|value| value.map(|value| value as i32)));
}
if self.pointer_scroll.devices.len() >= 64
&& !self.pointer_scroll.devices.contains_key(&device)
{
self.pointer_scroll.devices.clear();
}
let device = self.pointer_scroll.devices.entry(device).or_default();
let factor = self.input_settings.mouse.scroll_factor;
if device.factor != factor {
device.factor = factor;
device.fraction = [0.0; 2];
}
let scaled = std::array::from_fn(|axis| {
v120[axis].filter(|value| value.is_finite()).map(|value| {
let value = value * factor + device.fraction[axis];
device.fraction[axis] = value.fract();
value as i32
})
});
(values.map(|value| value * factor), scaled)
}
}
#[cfg(test)]
mod routing_tests {
use super::*;
use crate::input::{
AbsolutePositionEvent, ButtonEvent, ButtonState, Event, InputBackend, InputEvent,
PointerButtonEvent, PointerMotionEvent, SERIAL_COUNTER,
};
use smithay::backend::input::UnusedEvent;
use smithay::backend::winit::WinitVirtualDevice;
#[derive(Debug)]
struct Input;
impl InputBackend for Input {
type Device = WinitVirtualDevice;
type PointerButtonEvent = Button;
type KeyboardKeyEvent = UnusedEvent;
type PointerAxisEvent = UnusedEvent;
type PointerMotionEvent = Motion;
type PointerMotionAbsoluteEvent = Motion;
type GestureSwipeBeginEvent = UnusedEvent;
type GestureSwipeUpdateEvent = UnusedEvent;
type GestureSwipeEndEvent = UnusedEvent;
type GesturePinchBeginEvent = UnusedEvent;
type GesturePinchUpdateEvent = UnusedEvent;
type GesturePinchEndEvent = UnusedEvent;
type GestureHoldBeginEvent = UnusedEvent;
type GestureHoldEndEvent = UnusedEvent;
type TouchDownEvent = UnusedEvent;
type TouchUpEvent = UnusedEvent;
type TouchMotionEvent = UnusedEvent;
type TouchCancelEvent = UnusedEvent;
type TouchFrameEvent = UnusedEvent;
type TabletToolAxisEvent = UnusedEvent;
type TabletToolProximityEvent = UnusedEvent;
type TabletToolTipEvent = UnusedEvent;
type TabletToolButtonEvent = UnusedEvent;
type SwitchToggleEvent = UnusedEvent;
type SpecialEvent = UnusedEvent;
}
#[derive(Debug)]
struct Motion(f64, f64);
impl Event<Input> for Motion {
fn time(&self) -> smithay::backend::input::InputTime {
smithay::backend::input::InputTime::from_millis(1)
}
fn device(&self) -> WinitVirtualDevice {
WinitVirtualDevice
}
}
impl PointerMotionEvent<Input> for Motion {
fn delta_x(&self) -> f64 {
self.0
}
fn delta_y(&self) -> f64 {
self.1
}
fn delta_x_unaccel(&self) -> f64 {
self.0
}
fn delta_y_unaccel(&self) -> f64 {
self.1
}
}
impl AbsolutePositionEvent<Input> for Motion {
fn x(&self) -> f64 {
self.0
}
fn y(&self) -> f64 {
self.1
}
fn x_transformed(&self, _width: i32) -> f64 {
self.0
}
fn y_transformed(&self, _height: i32) -> f64 {
self.1
}
}
impl smithay::backend::input::PointerMotionAbsoluteEvent<Input> for Motion {}
#[derive(Debug)]
struct Button(bool);
impl Event<Input> for Button {
fn time(&self) -> smithay::backend::input::InputTime {
smithay::backend::input::InputTime::from_millis(1)
}
fn device(&self) -> WinitVirtualDevice {
WinitVirtualDevice
}
}
impl PointerButtonEvent<Input> for Button {
fn button_code(&self) -> u32 {
275
}
fn state(&self) -> ButtonState {
if self.0 {
ButtonState::Pressed
} else {
ButtonState::Released
}
}
}
#[test]
fn mouse_motion_within_a_window_preserves_shortcut_focus() {
if !crate::startup_tests::private_runtime(
"input::pointer::routing_tests::mouse_motion_within_a_window_preserves_shortcut_focus",
) {
return;
}
for absolute in [false, true] {
let (mut state, mut events, _output) = crate::input::layer_focus_tests::fixture();
state
.reload_config_source(
r#"
animations { enabled #false; }
input { focus-follows-mouse #true; }
scrolling { default-column-width 0.3; }
binding "Super+Shift+WheelUp" action="focus" argument="left"
binding "Super+MouseBack" action="focus" argument="left"
"#
.into(),
)
.unwrap();
let (first, _first_wire) =
crate::state::test_window(&mut state, &mut events, 0xff112233);
let (second, _second_wire) =
crate::state::test_window(&mut state, &mut events, 0xff334455);
let (third, _third_wire) =
crate::state::test_window(&mut state, &mut events, 0xff556677);
let first_id = state.windows.ids()[&first];
let second_id = state.windows.ids()[&second];
let third_id = state.windows.ids()[&third];
let pointer = state.seat.get_pointer().unwrap();
let keyboard = state.seat.get_keyboard().unwrap();
let motion = |state: &mut Ferese, x, y| {
let current = pointer.current_location();
state.process_input_event::<Input>(if absolute {
InputEvent::PointerMotionAbsolute {
event: Motion(x, y),
}
} else {
InputEvent::PointerMotion {
event: Motion(x - current.x, y - current.y),
}
});
};
let rect = state.presented_window_rect(third_id).unwrap();
let (x, y) = (rect.x + 20., rect.y + 20.);
motion(&mut state, x, y);
assert_eq!(state.focused_window, Some(third_id));
crate::input::binding_mode_tests::key(&mut state, 133, true); // Super.
crate::input::binding_mode_tests::key(&mut state, 50, true); // Shift.
assert_eq!(
state.filter_pointer_scroll(
"mouse".into(),
AxisSource::Wheel,
[0., -15.],
[None, Some(-120.)],
[false; 2],
),
[false, true]
);
assert_eq!(state.focused_window, Some(second_id));
motion(&mut state, x + 1., y);
assert_eq!(
state.focused_window,
Some(second_id),
"wheel focus must survive mouse jitter"
);
crate::input::binding_mode_tests::key(&mut state, 50, false);
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(true),
});
assert_eq!(state.focused_window, Some(first_id));
motion(&mut state, x + 2., y);
assert_eq!(
state.focused_window,
Some(first_id),
"side-button focus must survive mouse jitter"
);
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(false),
});
crate::input::binding_mode_tests::key(&mut state, 133, false);
motion(&mut state, x + 3., y);
assert_eq!(
state.focused_window,
Some(first_id),
"releasing modifiers must preserve the selection"
);
assert_eq!(
keyboard.current_focus(),
Some(first.toplevel().unwrap().wl_surface().clone())
);
let rect = state.presented_window_rect(second_id).unwrap();
motion(&mut state, rect.x + 20., rect.y + 20.);
assert_eq!(
state.focused_window,
Some(second_id),
"entering a different window must focus it"
);
motion(&mut state, x, y);
assert_eq!(state.focused_window, Some(third_id));
state.focus_direction(ferese_layout::Direction::Left);
motion(&mut state, x + 1., y);
assert_eq!(state.focused_window, Some(second_id));
// A click still explicitly focuses the window under the pointer.
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(true),
});
assert_eq!(state.focused_window, Some(third_id));
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(false),
});
}
}
#[test]
fn viewport_scroll_under_pointer_does_not_restore_hover_focus() {
if !crate::startup_tests::private_runtime(
"input::pointer::routing_tests::viewport_scroll_under_pointer_does_not_restore_hover_focus",
) {
return;
}
let (mut state, mut events, _output) = crate::input::layer_focus_tests::fixture();
state.input_settings.focus_follows_mouse = true;
let (first, _first_wire) = crate::state::test_window(&mut state, &mut events, 0xff112233);
let (_second, _second_wire) =
crate::state::test_window(&mut state, &mut events, 0xff334455);
let (third, _third_wire) = crate::state::test_window(&mut state, &mut events, 0xff556677);
let first_id = state.windows.ids()[&first];
let rect = state
.presented_window_rect(state.windows.ids()[&third])
.unwrap();
let pointer = state.seat.get_pointer().unwrap();
state.process_input_event::<Input>(InputEvent::PointerMotionAbsolute {
event: Motion(rect.x + 20., rect.y + 20.),
});
assert_eq!(
state.window_under_visual(pointer.current_location()),
Some(third.clone())
);
state.focus_direction(ferese_layout::Direction::Left);
state.focus_direction(ferese_layout::Direction::Left);
assert_eq!(state.focused_window, Some(first_id));
assert_ne!(
state.window_under_visual(pointer.current_location()),
Some(third)
);
state.process_input_event::<Input>(InputEvent::PointerMotion {
event: Motion(1., 0.),
});
assert_eq!(
state.focused_window,
Some(first_id),
"layout movement beneath the pointer is not a hover entry"
);
}
#[test]
fn pointer_bindings_use_modes_and_consume_releases_after_modifier_changes() {
if !crate::startup_tests::private_runtime(
"input::pointer::routing_tests::pointer_bindings_use_modes_and_consume_releases_after_modifier_changes",
) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
state
.reload_config_source(
r#"
binding "Super+MouseBack" action="binding-mode" argument="browse"
binding-mode "browse" { binding "MouseBack" action="binding-mode" argument="default"; }
"#
.into(),
)
.unwrap();
let pointer = state.seat.get_pointer().unwrap();
super::super::binding_mode_tests::key(&mut state, 133, true);
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(true),
});
assert_eq!(state.binding_modes.active(), Some("browse"));
assert!(state.consumed_pointer_buttons.contains(&275));
assert!(!pointer.is_grabbed());
super::super::binding_mode_tests::key(&mut state, 133, false);
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(false),
});
assert!(!state.consumed_pointer_buttons.contains(&275));
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(true),
});
assert_eq!(state.binding_modes.active(), None);
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(false),
});
// A missing release after unplugging must not poison the next click.
state.consumed_pointer_buttons.insert(275);
// With no matching modifiers, the application receives an ordinary click grab.
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(true),
});
assert!(
pointer
.with_grab(|_, grab| grab.is::<smithay::input::pointer::ClickGrab<Ferese>>())
.unwrap()
);
assert!(!state.consumed_pointer_buttons.contains(&275));
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(false),
});
assert!(!pointer.is_grabbed());
}
#[test]
fn pointer_scroll_routes_axes_independently_and_preserves_fractional_ticks() {
if !crate::startup_tests::private_runtime(
"input::pointer::routing_tests::pointer_scroll_routes_axes_independently_and_preserves_fractional_ticks",
) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
state
.reload_config_source(
r#"
binding "WheelUp" action="none"
binding "Super+TouchpadScrollLeft" action="none"
input { mouse { scroll-factor 0.25; } }
"#
.into(),
)
.unwrap();
let filtered = state.filter_pointer_scroll(
"mouse".into(),
AxisSource::Wheel,
[15.0, -15.0],
[Some(120.0), Some(-120.0)],
[false; 2],
);
assert_eq!(filtered, [false, true]);
for index in 1..=4 {
let (values, ticks) = state.scale_mouse_scroll(
"mouse".into(),
AxisSource::Wheel,
[1.0, 0.0],
[Some(1.0), None],
);
assert_eq!(values, [0.25, 0.0]);
assert_eq!(ticks, [Some(i32::from(index == 4)), None]);
}
let (values, ticks) =
state.scale_mouse_scroll("touchpad".into(), AxisSource::Finger, [1.0, 2.0], [None; 2]);
assert_eq!(values, [1.0, 2.0]);
assert_eq!(ticks, [None; 2]);
// Pointer grabs retain their scroll events even when a binding exists.
let pointer = state.seat.get_pointer().unwrap();
pointer.button(
&mut state,
&ButtonEvent {
button: 272,
state: ButtonState::Pressed,
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(1),
},
);
assert_eq!(
state.filter_pointer_scroll(
"mouse".into(),
AxisSource::Wheel,
[0.0, -15.0],
[None, Some(-120.0)],
[false; 2]
),
[false; 2]
);
}
}
+88
View File
@@ -0,0 +1,88 @@
//! A modifier tap is armed only by an otherwise isolated Super press. It never
//! owns the keyboard event: normal clients, IMEs and Super chords keep theirs.
use std::collections::HashSet;
use smithay::backend::input::ButtonState;
use smithay::input::keyboard::{KeyboardHandle, KeyboardSource, Keycode};
use smithay::reexports::wayland_server::backend::ObjectId;
use smithay::wayland::input_method::InputMethodKeyboardGrab;
use smithay::wayland::keyboard_shortcuts_inhibit::KeyboardShortcutsInhibitorSeat;
#[derive(Default)]
pub(crate) struct SuperTap {
candidate: Option<(KeyboardSource, Keycode, Option<ObjectId>)>,
pointer_buttons: HashSet<(String, u32)>,
}
impl SuperTap {
pub(crate) fn cancel(&mut self) {
self.candidate = None;
}
pub(crate) fn reset(&mut self) {
self.cancel();
self.pointer_buttons.clear();
}
pub(crate) fn device_removed(&mut self, device: &str) {
self.cancel();
self.pointer_buttons.retain(|(owner, _)| owner != device);
}
pub(super) fn pointer_button(&mut self, device: String, button: u32, state: ButtonState) {
self.cancel();
if state == ButtonState::Pressed {
self.pointer_buttons.insert((device, button));
} else {
self.pointer_buttons.remove(&(device, button));
}
}
// Observe before Smithay deduplicates a second input source's identical
// keycode. That second source must invalidate an isolated tap as well.
pub(super) fn observe_key(&mut self, source: KeyboardSource, key: Keycode) {
if self
.candidate
.as_ref()
.is_some_and(|(owner, held, _)| *owner != source || *held != key)
{
self.cancel();
}
}
pub(super) fn arm(&mut self, source: KeyboardSource, key: Keycode, focus: Option<ObjectId>) {
self.candidate = Some((source, key, focus));
}
pub(super) fn release(
&mut self,
source: KeyboardSource,
key: Keycode,
allowed: bool,
focus: Option<ObjectId>,
) -> bool {
self.candidate.take().is_some_and(|(owner, held, initial)| {
allowed && owner == source && held == key && focus == initial
})
}
}
impl crate::Ferese {
pub(super) fn super_tap_allowed(&self, keyboard: &KeyboardHandle<Self>) -> bool {
!self.session_lock.active()
&& !self.input_capture.active()
&& !self.seat.keyboard_shortcuts_inhibited()
&& self.active_shortcuts_inhibitor.is_none()
&& self.pointer_operation.is_none()
&& self.super_tap.pointer_buttons.is_empty()
&& self.seat.get_pointer().is_none_or(|pointer| !pointer.is_grabbed())
// A native Rime grab is part of normal text input. Popup/client
// grabs still retain their exclusive keyboard interaction.
&& keyboard.with_grab(|_, grab| grab.is::<InputMethodKeyboardGrab>()).unwrap_or(true)
}
}
#[cfg(test)]
mod security_tests;
#[cfg(test)]
mod tests;
@@ -0,0 +1,181 @@
use crate::Ferese;
use crate::input::binding_mode_tests::key;
use crate::state::{dispatch_test_client as dispatch, test_client_request as request, test_window};
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_protocols::ext::session_lock::v1::server::{
ext_session_lock_manager_v1::ExtSessionLockManagerV1, ext_session_lock_v1::ExtSessionLockV1,
};
use smithay::reexports::wayland_protocols::wp::keyboard_shortcuts_inhibit::zv1::server::zwp_keyboard_shortcuts_inhibit_manager_v1::ZwpKeyboardShortcutsInhibitManagerV1;
use smithay::reexports::wayland_server::{Client, Resource};
use smithay::utils::SERIAL_COUNTER;
use smithay::wayland::keyboard_shortcuts_inhibit::KeyboardShortcutsInhibitorSeat;
use std::io::Read;
use std::os::unix::net::UnixStream;
const SOURCE: &str = "binding Super binding-mode tapped\nbinding-mode tapped { binding a none; }\n";
fn fixture() -> (Ferese, EventLoop<'static, Ferese>) {
let (mut state, events, _) = crate::input::layer_focus_tests::fixture();
state.reload_config_source(SOURCE.into()).unwrap();
(state, events)
}
fn bind_seat(state: &mut Ferese, events: &mut EventLoop<'static, Ferese>, wire: &mut UnixStream) {
request(wire, 1, 1, &[7], None);
dispatch(events, state);
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 seat_global = None;
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());
if object == 7 && header as u16 == 0 {
let length =
u32::from_ne_bytes(bytes[offset + 12..offset + 16].try_into().unwrap()) as usize;
if &bytes[offset + 16..offset + 16 + length] == b"wl_seat\0" {
seat_global = Some(u32::from_ne_bytes(
bytes[offset + 8..offset + 12].try_into().unwrap(),
));
}
}
offset += (header >> 16) as usize;
}
let mut args = vec![seat_global.unwrap(), 8];
args.extend(
b"wl_seat\0"
.chunks_exact(4)
.map(|word| u32::from_ne_bytes(word.try_into().unwrap())),
);
args.extend([7, 8]);
request(wire, 7, 0, &args, None);
dispatch(events, state);
}
fn create_manager<R: Resource + 'static>(state: &mut Ferese, client: &Client) -> R
where
Ferese: smithay::reexports::wayland_server::Dispatch<R, smithay::wayland::GlobalData>,
{
client
.create_resource::<R, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
1,
smithay::wayland::GlobalData,
)
.unwrap()
}
#[test]
fn inhibitor_protocol_cancels_a_held_tap_even_if_destroyed_before_release() {
if !crate::startup_tests::private_runtime(
"input::super_tap::security_tests::inhibitor_protocol_cancels_a_held_tap_even_if_destroyed_before_release",
) {
return;
}
let (mut state, mut events) = fixture();
let (window, mut wire) = test_window(&mut state, &mut events, 0xff102030);
let surface = window.toplevel().unwrap().wl_surface().clone();
let client = surface.client().unwrap();
let keyboard = state.seat.get_keyboard().unwrap();
keyboard.set_focus(
&mut state,
Some(surface.clone()),
SERIAL_COUNTER.next_serial(),
);
bind_seat(&mut state, &mut events, &mut wire);
let manager = create_manager::<ZwpKeyboardShortcutsInhibitManagerV1>(&mut state, &client);
key(&mut state, 133, true);
assert!(state.super_tap.candidate.is_some());
let opcode = ZwpKeyboardShortcutsInhibitManagerV1::interface()
.requests
.iter()
.position(|request| request.name == "inhibit_shortcuts")
.unwrap() as u32;
request(
&mut wire,
manager.id().protocol_id(),
opcode,
&[9, surface.id().protocol_id(), 8],
None,
);
dispatch(&mut events, &mut state);
assert!(state.seat.keyboard_shortcuts_inhibited());
assert!(
state.super_tap.candidate.is_none(),
"activation must cancel immediately"
);
request(&mut wire, 9, 0, &[], None);
dispatch(&mut events, &mut state);
assert!(!state.seat.keyboard_shortcuts_inhibited());
key(&mut state, 133, false);
assert_eq!(state.binding_modes.active(), None);
key(&mut state, 133, true);
key(&mut state, 133, false);
assert_eq!(state.binding_modes.active(), Some("tapped"));
}
#[test]
fn session_lock_protocol_cancels_pending_tap_before_further_keyboard_input() {
if !crate::startup_tests::private_runtime(
"input::super_tap::security_tests::session_lock_protocol_cancels_pending_tap_before_further_keyboard_input",
) {
return;
}
let (mut state, mut events) = fixture();
let (window, mut wire) = test_window(&mut state, &mut events, 0xff102030);
let surface = window.toplevel().unwrap().wl_surface().clone();
let client = surface.client().unwrap();
state.seat.get_keyboard().unwrap().set_focus(
&mut state,
Some(surface),
SERIAL_COUNTER.next_serial(),
);
let manager = create_manager::<ExtSessionLockManagerV1>(&mut state, &client);
key(&mut state, 133, true);
assert!(state.super_tap.candidate.is_some());
let opcode = ExtSessionLockManagerV1::interface()
.requests
.iter()
.position(|request| request.name == "lock")
.unwrap() as u32;
request(&mut wire, manager.id().protocol_id(), opcode, &[7], None);
dispatch(&mut events, &mut state);
assert!(state.session_lock.active());
assert!(
client
.object_from_protocol_id::<ExtSessionLockV1>(&state.display_handle, 7)
.is_ok()
);
assert!(state.super_tap.candidate.is_none());
key(&mut state, 133, false);
key(&mut state, 133, true);
key(&mut state, 133, false);
assert_eq!(state.binding_modes.active(), None);
}
#[test]
fn focus_departure_and_return_do_not_restore_a_held_tap() {
if !crate::startup_tests::private_runtime(
"input::super_tap::security_tests::focus_departure_and_return_do_not_restore_a_held_tap",
) {
return;
}
let (mut state, mut events) = fixture();
let (window, _wire) = test_window(&mut state, &mut events, 0xff102030);
let surface = window.toplevel().unwrap().wl_surface().clone();
let keyboard = state.seat.get_keyboard().unwrap();
keyboard.set_focus(
&mut state,
Some(surface.clone()),
SERIAL_COUNTER.next_serial(),
);
key(&mut state, 133, true);
assert!(state.super_tap.candidate.is_some());
keyboard.set_focus(&mut state, None, SERIAL_COUNTER.next_serial());
keyboard.set_focus(&mut state, Some(surface), SERIAL_COUNTER.next_serial());
assert!(state.super_tap.candidate.is_none());
key(&mut state, 133, false);
assert_eq!(state.binding_modes.active(), None);
}
+508
View File
@@ -0,0 +1,508 @@
use super::*;
use crate::Ferese;
use crate::config::{BindingAction, Config};
use crate::input::binding_mode_tests::key;
use crate::input::{Event, InputBackend, InputEvent, PointerButtonEvent, PointerMotionEvent};
use ferese_protocols::shell::v1::server::ferese_shell_v1::FereseShellV1;
use smithay::backend::input::{
Axis, AxisRelativeDirection, AxisSource, InputTime, KeyState, PointerAxisEvent, UnusedEvent,
};
use smithay::backend::winit::WinitVirtualDevice;
use smithay::input::keyboard::{GrabStartData, KeyboardGrab, KeyboardInnerHandle, ModifiersState};
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_server::{Resource, protocol::wl_surface::WlSurface};
use smithay::utils::{SERIAL_COUNTER, Serial};
use std::io::Read;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
macro_rules! private {
($test:ident) => {
if !crate::startup_tests::private_runtime(concat!(
"input::super_tap::tests::",
stringify!($test)
)) {
return;
}
};
}
const SOURCE: &str = "binding Super binding-mode tapped\nbinding-mode tapped { binding a none; }\nbinding Super+Space binding-mode chord\nbinding-mode chord { binding a none; }\n";
fn fixture(source: &str) -> (Ferese, EventLoop<'static, Ferese>) {
let (mut state, events, _) = crate::input::layer_focus_tests::fixture();
state.reload_config_source(source.into()).unwrap();
(state, events)
}
fn tap(state: &mut Ferese, code: u32) {
key(state, code, true);
key(state, code, false);
}
fn tapped(state: &Ferese) -> bool {
state.binding_modes.active() == Some("tapped")
}
#[test]
fn parsing_indexes_taps_separately_and_preserves_space_chord() {
let config = Config::parse_source("binding Super toggle-launcher\n").unwrap();
let bindings: crate::config::BindingSet = config
.bindings(&config.input_settings().unwrap())
.unwrap()
.into();
assert!(matches!(
bindings.super_tap().unwrap().action,
BindingAction::ToggleLauncher
));
assert!(
bindings
.binding_for_key(
Keycode::new(133),
&[smithay::input::keyboard::keysyms::KEY_Super_L],
true,
false,
false,
false
)
.is_none()
);
assert!(matches!(
bindings
.binding_for_key(
Keycode::new(65),
&[smithay::input::keyboard::keysyms::KEY_space],
true,
false,
false,
false
)
.unwrap()
.action,
BindingAction::ToggleLauncher
));
let guide = bindings.super_tap().unwrap().guide_entry(None).unwrap();
assert_eq!(guide["keys"], "Super (tap)");
for source in [
"binding Super toggle-launcher\nbinding Logo none\n",
"binding Super match=physical action=toggle-launcher\n",
"binding Ctrl toggle-launcher\n",
] {
let config = Config::parse_source(source).unwrap();
assert!(
config.bindings(&config.input_settings().unwrap()).is_err(),
"{source}"
);
}
let disabled = Config::parse_source("binding Super disabled=#true\n").unwrap();
let bindings: crate::config::BindingSet = disabled
.bindings(&disabled.input_settings().unwrap())
.unwrap()
.into();
assert!(bindings.super_tap().is_none());
}
#[test]
fn left_right_taps_execute_on_release_and_repeat_does_not_rearm() {
private!(left_right_taps_execute_on_release_and_repeat_does_not_rearm);
let (mut state, _events) = fixture(SOURCE);
for code in [133, 134] {
key(&mut state, code, true);
assert!(!tapped(&state));
key(&mut state, code, true); // backend repeat, not another isolated tap
assert!(!tapped(&state));
key(&mut state, code, false);
assert!(tapped(&state));
state.set_binding_mode("default");
key(&mut state, code, false); // duplicate release
assert!(!tapped(&state));
assert!(state.seat.get_keyboard().unwrap().pressed_keys().is_empty());
assert!(
state.intercepted_keys.is_empty(),
"tap must not own either half of Super"
);
}
}
#[test]
fn existing_chords_held_keys_and_both_super_keys_never_become_taps() {
private!(existing_chords_held_keys_and_both_super_keys_never_become_taps);
let (mut state, _events) = fixture(SOURCE);
for sequence in [
vec![(133, true), (38, true), (38, false), (133, false)],
vec![(38, true), (133, true), (38, false), (133, false)],
vec![(133, true), (134, true), (133, false), (134, false)],
vec![(134, true), (133, true), (134, false), (133, false)],
vec![(37, true), (133, true), (37, false), (133, false)],
vec![(133, true), (50, true), (50, false), (133, false)],
] {
for (code, down) in sequence {
key(&mut state, code, down);
}
assert!(!tapped(&state));
assert!(state.seat.get_keyboard().unwrap().pressed_keys().is_empty());
}
key(&mut state, 133, true);
tap(&mut state, 65); // existing Super+Space, action is deliberately distinct
assert_eq!(state.binding_modes.active(), Some("chord"));
key(&mut state, 133, false);
assert_eq!(state.binding_modes.active(), Some("chord"));
}
#[derive(Debug)]
struct PointerInput;
impl InputBackend for PointerInput {
type Device = WinitVirtualDevice;
type KeyboardKeyEvent = UnusedEvent;
type PointerButtonEvent = Button;
type PointerMotionEvent = Motion;
type PointerAxisEvent = Scroll;
type PointerMotionAbsoluteEvent = UnusedEvent;
type GestureSwipeBeginEvent = UnusedEvent;
type GestureSwipeUpdateEvent = UnusedEvent;
type GestureSwipeEndEvent = UnusedEvent;
type GesturePinchBeginEvent = UnusedEvent;
type GesturePinchUpdateEvent = UnusedEvent;
type GesturePinchEndEvent = UnusedEvent;
type GestureHoldBeginEvent = UnusedEvent;
type GestureHoldEndEvent = UnusedEvent;
type TouchDownEvent = UnusedEvent;
type TouchUpEvent = UnusedEvent;
type TouchMotionEvent = UnusedEvent;
type TouchCancelEvent = UnusedEvent;
type TouchFrameEvent = UnusedEvent;
type TabletToolAxisEvent = UnusedEvent;
type TabletToolProximityEvent = UnusedEvent;
type TabletToolTipEvent = UnusedEvent;
type TabletToolButtonEvent = UnusedEvent;
type SwitchToggleEvent = UnusedEvent;
type SpecialEvent = UnusedEvent;
}
#[derive(Debug)]
struct Button(ButtonState);
#[derive(Debug)]
struct Motion;
#[derive(Debug)]
struct Scroll;
macro_rules! pointer_event {
($event:ty) => {
impl Event<PointerInput> for $event {
fn time(&self) -> InputTime {
InputTime::from_millis(1)
}
fn device(&self) -> WinitVirtualDevice {
WinitVirtualDevice
}
}
};
}
pointer_event!(Button);
pointer_event!(Motion);
pointer_event!(Scroll);
impl PointerButtonEvent<PointerInput> for Button {
fn button_code(&self) -> u32 {
0x111
}
fn state(&self) -> ButtonState {
self.0
}
}
impl PointerMotionEvent<PointerInput> for Motion {
fn delta_x(&self) -> f64 {
1.
}
fn delta_y(&self) -> f64 {
0.
}
fn delta_x_unaccel(&self) -> f64 {
1.
}
fn delta_y_unaccel(&self) -> f64 {
0.
}
}
impl PointerAxisEvent<PointerInput> for Scroll {
fn amount(&self, axis: Axis) -> Option<f64> {
(axis == Axis::Vertical).then_some(15.)
}
fn amount_v120(&self, axis: Axis) -> Option<f64> {
(axis == Axis::Vertical).then_some(120.)
}
fn source(&self) -> AxisSource {
AxisSource::Wheel
}
fn relative_direction(&self, _: Axis) -> AxisRelativeDirection {
AxisRelativeDirection::Identical
}
}
fn button(state: &mut Ferese, down: bool) {
state.process_input_event::<PointerInput>(InputEvent::PointerButton {
event: Button(if down {
ButtonState::Pressed
} else {
ButtonState::Released
}),
});
}
#[test]
fn mouse_motion_buttons_scroll_and_already_held_button_cancel_taps() {
private!(mouse_motion_buttons_scroll_and_already_held_button_cancel_taps);
let (mut state, _events) = fixture(SOURCE);
key(&mut state, 133, true);
button(&mut state, true);
button(&mut state, false);
key(&mut state, 133, false);
assert!(!tapped(&state));
button(&mut state, true);
tap(&mut state, 133);
assert!(!tapped(&state));
button(&mut state, false);
key(&mut state, 133, true);
state.process_input_event::<PointerInput>(InputEvent::PointerMotion { event: Motion });
key(&mut state, 133, false);
assert!(!tapped(&state));
key(&mut state, 133, true);
state.process_input_event::<PointerInput>(InputEvent::PointerAxis { event: Scroll });
key(&mut state, 133, false);
assert!(!tapped(&state));
tap(&mut state, 133);
assert!(
tapped(&state),
"fresh isolated tap must recover after mouse use"
);
}
#[test]
fn reload_focus_loss_and_second_source_do_not_resurrect_pending_taps() {
private!(reload_focus_loss_and_second_source_do_not_resurrect_pending_taps);
let (mut state, _events) = fixture(SOURCE);
key(&mut state, 133, true);
state.reload_config_source(SOURCE.into()).unwrap();
// An unchanged config need not replace bindings; an actual input/binding
// edit must invalidate the press even if its action stays the same.
state
.reload_config_source(format!("{SOURCE}\nbinding b none\n"))
.unwrap();
key(&mut state, 133, false);
assert!(!tapped(&state));
key(&mut state, 133, true);
state.release_nested_keyboard();
key(&mut state, 133, false);
assert!(!tapped(&state));
let auxiliary = KeyboardSource::new_auxiliary();
state
.super_tap
.arm(KeyboardSource::MAIN, Keycode::new(133), None);
state.super_tap.observe_key(auxiliary, Keycode::new(133));
assert!(
!state
.super_tap
.release(KeyboardSource::MAIN, Keycode::new(133), true, None)
);
tap(&mut state, 134);
assert!(tapped(&state));
}
struct ClientGrab(GrabStartData<Ferese>);
impl KeyboardGrab<Ferese> for ClientGrab {
fn input(
&mut self,
data: &mut Ferese,
handle: &mut KeyboardInnerHandle<'_, Ferese>,
key: Keycode,
state: KeyState,
mods: Option<ModifiersState>,
serial: Serial,
time: InputTime,
) {
handle.input(data, key, state, mods, serial, time);
}
fn set_focus(
&mut self,
data: &mut Ferese,
handle: &mut KeyboardInnerHandle<'_, Ferese>,
focus: Option<WlSurface>,
serial: Serial,
) {
handle.set_focus(data, focus, serial);
}
fn start_data(&self) -> &GrabStartData<Ferese> {
&self.0
}
fn unset(&mut self, _: &mut Ferese) {}
}
#[test]
fn native_ime_grab_allows_tap_but_client_grab_keeps_keyboard() {
private!(native_ime_grab_allows_tap_but_client_grab_keeps_keyboard);
let (mut state, mut _events) = fixture(SOURCE);
let keyboard = state.seat.get_keyboard().unwrap();
// Use a real input-method grab and its own virtual keyboard, as Rime
// does, rather than an empty grab whose protocol resources do not exist.
use smithay::reexports::wayland_protocols_misc::zwp_input_method_v2::server::{
zwp_input_method_manager_v2::ZwpInputMethodManagerV2,
zwp_input_method_v2::ZwpInputMethodV2,
};
use smithay::reexports::wayland_protocols_misc::zwp_virtual_keyboard_v1::server::zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1;
use smithay::wayland::input_method::InputMethodSeat;
use std::os::fd::AsRawFd;
let (server, mut wire) = UnixStream::pair().unwrap();
let client = state
.display_handle
.insert_client(server, Arc::new(crate::state::ClientState::default()))
.unwrap();
let request = crate::state::test_client_request;
request(&mut wire, 1, 1, &[2], None); // display.get_registry
crate::state::dispatch_test_client(&mut _events, &mut state);
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 seat_global = None;
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());
if object == 2 && header as u16 == 0 {
let length =
u32::from_ne_bytes(bytes[offset + 12..offset + 16].try_into().unwrap()) as usize;
if &bytes[offset + 16..offset + 16 + length] == b"wl_seat\0" {
seat_global = Some(u32::from_ne_bytes(
bytes[offset + 8..offset + 12].try_into().unwrap(),
));
}
}
offset += (header >> 16) as usize;
}
let mut bind = vec![seat_global.unwrap(), 8];
bind.extend(
b"wl_seat\0"
.chunks_exact(4)
.map(|word| u32::from_ne_bytes(word.try_into().unwrap())),
);
bind.extend([7, 3]);
request(&mut wire, 2, 0, &bind, None);
crate::state::dispatch_test_client(&mut _events, &mut state);
let manager = client
.create_resource::<ZwpInputMethodManagerV2, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
1,
smithay::wayland::GlobalData,
)
.unwrap();
let vk_manager = client
.create_resource::<ZwpVirtualKeyboardManagerV1, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
1,
smithay::wayland::GlobalData,
)
.unwrap();
request(&mut wire, manager.id().protocol_id(), 0, &[3, 4], None);
crate::state::dispatch_test_client(&mut _events, &mut state);
let grab_opcode = ZwpInputMethodV2::interface()
.requests
.iter()
.position(|request| request.name == "grab_keyboard")
.unwrap() as u32;
request(&mut wire, 4, grab_opcode, &[5], None);
request(&mut wire, vk_manager.id().protocol_id(), 0, &[3, 6], None);
let fd = tempfile::tempfile().unwrap();
request(&mut wire, 6, 0, &[0, 0], Some(fd.as_raw_fd())); // no_keymap, retain seat mapping
crate::state::dispatch_test_client(&mut _events, &mut state);
assert_eq!(
state.seat.input_method().keyboard_grab_client(),
Some(client)
);
key(&mut state, 133, true);
assert!(state.super_tap.candidate.is_some());
request(&mut wire, 6, 1, &[1, 125, 1], None); // trusted Rime echo of physical Super press
crate::state::dispatch_test_client(&mut _events, &mut state);
assert!(
state.super_tap.candidate.is_some(),
"trusted IME echo must bypass auxiliary tap cancellation"
);
assert!(!tapped(&state));
key(&mut state, 133, false);
assert!(tapped(&state));
request(&mut wire, 6, 1, &[2, 125, 0], None); // trusted release echo after the physical tap
crate::state::dispatch_test_client(&mut _events, &mut state);
assert!(tapped(&state));
assert!(keyboard.pressed_keys().is_empty());
state.set_binding_mode("default");
keyboard.unset_grab(&mut state);
keyboard.set_grab(
&mut state,
ClientGrab(GrabStartData { focus: None }),
SERIAL_COUNTER.next_serial(),
);
tap(&mut state, 133);
assert!(!tapped(&state));
keyboard.unset_grab(&mut state);
tap(&mut state, 133);
assert!(tapped(&state));
}
fn launcher_events(state: &mut Ferese, wire: &mut UnixStream, shell: &FereseShellV1) -> usize {
state.display_handle.flush_clients().unwrap();
wire.set_nonblocking(true).unwrap();
let mut bytes = Vec::new();
let _ = wire.read_to_end(&mut bytes);
let opcode = FereseShellV1::interface()
.events
.iter()
.position(|e| e.name == "toggle_launcher")
.unwrap() as u16;
let (mut offset, mut count) = (0, 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());
if object == shell.id().protocol_id() && header as u16 == opcode {
count += 1;
}
offset += (header >> 16) as usize;
}
count
}
#[test]
fn actual_launcher_protocol_is_sent_once_after_release_and_space_still_works() {
private!(actual_launcher_protocol_is_sent_once_after_release_and_space_still_works);
let (mut state, _events) = fixture("binding Super toggle-launcher\n");
let (server, mut wire) = UnixStream::pair().unwrap();
let client = state
.display_handle
.insert_client(server, Arc::new(crate::state::ClientState::default()))
.unwrap();
let shell = client
.create_resource::<FereseShellV1, crate::shell_control::ShellData, Ferese>(
&state.display_handle,
10,
Default::default(),
)
.unwrap();
state.shell_resources.push(shell.downgrade());
for code in [133, 134] {
key(&mut state, code, true);
assert_eq!(launcher_events(&mut state, &mut wire, &shell), 0);
key(&mut state, code, false);
assert_eq!(launcher_events(&mut state, &mut wire, &shell), 1);
}
key(&mut state, 133, true);
tap(&mut state, 65);
key(&mut state, 133, false);
assert_eq!(
launcher_events(&mut state, &mut wire, &shell),
1,
"Space chord must not also produce a tap"
);
}
#[test]
fn lost_pointer_devices_and_session_reset_do_not_leave_taps_disabled() {
let mut tap = SuperTap::default();
tap.pointer_button("mouse-a".into(), 0x110, ButtonState::Pressed);
tap.pointer_button("mouse-b".into(), 0x110, ButtonState::Pressed);
tap.device_removed("mouse-a");
assert_eq!(tap.pointer_buttons.len(), 1);
tap.device_removed("mouse-b");
assert!(tap.pointer_buttons.is_empty());
tap.pointer_button("mouse-a".into(), 0x111, ButtonState::Pressed);
tap.arm(KeyboardSource::MAIN, Keycode::new(133), None);
tap.reset();
assert!(tap.pointer_buttons.is_empty());
assert!(tap.candidate.is_none());
}
+728
View File
@@ -0,0 +1,728 @@
use super::{
ButtonEvent, ButtonState, Direction, Ferese, Focus, GrabStartData, Logical, MotionEvent, Point,
PointerHandle, ResizeEdge, SERIAL_COUNTER, Serial, with_pointer_constraint,
};
use ferese_core::{WindowPlacement, WorkspaceId, WorkspaceLayout};
use ferese_layout::{Axis as LayoutAxis, DropPlacement, Rect, WindowId};
use smithay::desktop::Window;
use smithay::input::pointer::{CursorIcon, CursorImageStatus};
use smithay::output::Output;
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::SurfaceCachedState;
use std::collections::HashSet;
pub(crate) struct WindowInteraction {
pub id: WindowId,
window: Window,
workspace: WorkspaceId,
output: Output,
output_geometry: smithay::utils::Rectangle<i32, Logical>,
start: Point<f64, Logical>,
initial: smithay::utils::Rectangle<i32, Logical>,
placement: WindowPlacement,
applied_placement: WindowPlacement,
edges: Option<ResizeEdge>,
base_layout: WorkspaceLayout,
applied_layout: WorkspaceLayout,
revision: u64,
serial: Serial,
last_tick: std::time::Instant,
scroll: f64,
drop_floating: bool,
pub location: Point<f64, Logical>,
pub pressed: HashSet<u32>,
pub finished: bool,
pub cancelled: bool,
started: bool,
last_location: Point<f64, Logical>,
was_maximized: bool,
pub visual: Option<Rect>,
snap_x: crate::floating::AxisSnap,
snap_y: crate::floating::AxisSnap,
}
impl WindowInteraction {
pub(crate) fn toggle_drop_mode(&mut self) {
if self.edges.is_none() && !self.finished {
self.drop_floating = !self.drop_floating;
}
}
pub(crate) fn scroll(&mut self, amount: f64) {
if self.edges.is_none() && amount.is_finite() && !self.finished {
self.scroll += amount;
}
}
}
impl Ferese {
pub(crate) fn begin_window_interaction(
&mut self,
pointer: &PointerHandle<Self>,
window: Window,
start: GrabStartData<Self>,
serial: Serial,
edges: Option<ResizeEdge>,
) -> bool {
if self.session_lock.active()
|| self.input_capture.active()
|| self.overview.is_active()
|| self.pointer_operation.is_some()
|| self.keyboard_focused_layer().is_some()
|| !self.window_content_ready(&window)
|| pointer
.with_grab(|_, grab| !grab.is::<smithay::input::pointer::ClickGrab<Self>>())
.unwrap_or(false)
|| pointer.current_focus().is_some_and(|surface| {
with_pointer_constraint(&surface, pointer, |constraint| {
constraint.is_some_and(|constraint| constraint.is_active())
})
})
{
return false;
}
let Some(id) = self.windows.ids().get(&window).copied() else {
return false;
};
let Some(workspace) = self.workspaces.workspace_for_window(id) else {
return false;
};
let Some(placement) = self.workspaces.placement(id) else {
return false;
};
if !self.window_interaction_allowed(id, workspace) {
return false;
}
let Some(output) = self
.space
.outputs()
.find(|output| self.window_belongs_to_output(id, output))
.cloned()
else {
return false;
};
let Some(output_geometry) = self.space.output_geometry(&output) else {
return false;
};
let Some(mut initial) = self.visual_rect_for_window(&window) else {
return false;
};
let was_maximized = self
.windows
.record(id)
.is_some_and(|record| record.maximized);
if was_maximized {
self.set_window_maximized(id, false);
if let Some(geometry) = self.windows.geometry(&id) {
let restored = geometry.logical;
let size = (
restored.width.round() as i32,
restored.height.round() as i32,
)
.into();
let x = ((start.location.x - initial.loc.x as f64) / initial.size.w.max(1) as f64)
.clamp(0.0, 1.0);
let y = ((start.location.y - initial.loc.y as f64) / initial.size.h.max(1) as f64)
.clamp(0.0, 1.0);
initial = smithay::utils::Rectangle::new(
(
(start.location.x - x * restored.width).round() as i32,
(start.location.y - y * restored.height).round() as i32,
)
.into(),
size,
);
}
}
let layout = self
.workspaces
.workspace(workspace)
.expect("managed workspace")
.layout
.clone();
self.pointer_operation = Some(WindowInteraction {
id,
window,
workspace,
output,
output_geometry,
initial,
placement,
applied_placement: placement,
edges,
base_layout: layout.clone(),
applied_layout: layout,
revision: self.ipc_events.config_revision,
serial,
last_tick: std::time::Instant::now(),
scroll: 0.0,
drop_floating: matches!(placement, WindowPlacement::Floating { .. }),
start: start.location,
location: start.location,
last_location: start.location,
pressed: HashSet::from([start.button]),
finished: false,
cancelled: false,
started: false,
was_maximized,
visual: None,
snap_x: Default::default(),
snap_y: Default::default(),
});
self.cursor_status =
CursorImageStatus::Named(edges.map_or(CursorIcon::Grabbing, ResizeEdge::cursor));
pointer.set_grab(
self,
crate::grabs::WindowPointerGrab {
start_data: start,
finished: false,
},
serial,
Focus::Clear,
);
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
let interval = std::time::Duration::from_millis(16);
if let Err(error) =
self.loop_handle
.insert_source(Timer::from_duration(interval), move |_, _, state| {
if state
.pointer_operation
.as_ref()
.is_none_or(|operation| operation.serial != serial)
{
return TimeoutAction::Drop;
}
state.poll_window_interaction();
if state.pointer_operation.is_some() {
TimeoutAction::ToDuration(interval)
} else {
TimeoutAction::Drop
}
})
{
tracing::warn!(%error, "failed to schedule drag edge scrolling");
}
true
}
pub(crate) fn cancel_window_interaction(&mut self) {
if let Some(operation) = self.pointer_operation.as_mut() {
operation.cancelled = true;
}
self.poll_window_interaction();
}
pub(crate) fn poll_window_interaction(&mut self) {
self.update_window_interaction();
if self.window_grab_focus_pending
&& let Some(pointer) = self.seat.get_pointer()
&& !pointer.is_grabbed()
{
self.window_grab_focus_pending = false;
let location = pointer.current_location();
pointer.motion(
self,
self.surface_under(location),
&MotionEvent {
location,
serial: SERIAL_COUNTER.next_serial(),
time: self.last_pointer_time,
},
);
pointer.frame(self);
}
}
fn update_window_interaction(&mut self) {
let Some(mut operation) = self.pointer_operation.take() else {
return;
};
let valid = !self.session_lock.active()
&& !self.input_capture.active()
&& self.keyboard_focused_layer().is_none()
&& self
.windows
.record(operation.id)
.is_some_and(|record| !record.maximized)
&& operation.location.x.is_finite()
&& operation.location.y.is_finite()
&& self
.windows
.window(operation.id)
.is_some_and(|window| self.window_content_ready(window))
&& self.workspaces.workspace_for_window(operation.id) == Some(operation.workspace)
&& self.space.output_geometry(&operation.output) == Some(operation.output_geometry)
&& self
.output_id(&operation.output)
.and_then(|id| self.output_workspaces.active_workspace(id))
== Some(operation.workspace)
&& self.window_interaction_allowed(operation.id, operation.workspace)
&& self
.workspaces
.placement(operation.id)
.is_some_and(|placement| {
matches!(
(placement, operation.placement),
(WindowPlacement::Tiled, WindowPlacement::Tiled)
| (
WindowPlacement::Floating { .. },
WindowPlacement::Floating { .. }
)
)
})
&& self.ipc_events.config_revision == operation.revision;
if !valid {
operation.cancelled = true;
}
if self.overview.is_active() {
operation.cancelled = true;
}
let layout_matches = operation.edges.is_none()
|| operation.placement != WindowPlacement::Tiled
|| self
.workspaces
.workspace(operation.workspace)
.is_some_and(|workspace| same_layout(&workspace.layout, &operation.applied_layout));
if operation.edges.is_some()
&& operation.placement == WindowPlacement::Tiled
&& !layout_matches
{
operation.cancelled = true;
}
let placement_matches =
self.workspaces.placement(operation.id) == Some(operation.applied_placement);
if operation.edges.is_some() && !placement_matches {
operation.cancelled = true;
}
let delta = operation.location - operation.start;
let distance = delta.x.hypot(delta.y);
operation.started |= distance > 0.0 && distance >= self.input_settings.mouse.drag_threshold;
if operation.cancelled {
if operation.started
&& self.workspaces.workspace_for_window(operation.id) == Some(operation.workspace)
{
match operation.placement {
WindowPlacement::Tiled if operation.edges.is_some() && layout_matches => {
if let Some(workspace) = self.workspaces.workspace_mut(operation.workspace)
{
workspace.layout = operation.base_layout.clone();
}
}
WindowPlacement::Floating { rect }
if operation.edges.is_some() && placement_matches =>
{
self.set_floating_window_rect(&operation.window, rect);
}
_ => {}
}
}
if let Some(pointer) = self.seat.get_pointer() {
// Flush only a compositor-owned grab. The physical releases are
// then consumed, preserving button state without sending them to clients.
if pointer
.with_grab(|_, grab| grab.is::<crate::grabs::WindowPointerGrab>())
.unwrap_or(false)
{
for button in &operation.pressed {
pointer.button(
self,
&ButtonEvent {
button: *button,
state: ButtonState::Released,
serial: SERIAL_COUNTER.next_serial(),
time: self.last_pointer_time,
},
);
self.consumed_pointer_buttons.insert(*button);
}
if pointer
.with_grab(|_, grab| grab.is::<crate::grabs::WindowPointerGrab>())
.unwrap_or(false)
{
pointer.unset_grab(
self,
SERIAL_COUNTER.next_serial(),
self.last_pointer_time,
);
}
}
}
self.finish_window_interaction(&operation, true);
return;
}
if operation.started && operation.location != operation.last_location {
let previous_visual = operation.visual;
if let Some(edges) = operation.edges {
self.resize_interaction(&mut operation, edges, delta);
} else {
let mut rect = Rect::new(
operation.initial.loc.x as f64 + delta.x,
operation.initial.loc.y as f64 + delta.y,
operation.initial.size.w as f64,
operation.initial.size.h as f64,
);
if operation.drop_floating {
let (xs, ys) = self.floating_snap_lines(&operation.window, rect);
if self.floating_snap_bypassed() {
operation.snap_x.clear();
operation.snap_y.clear();
} else {
rect.x = operation.snap_x.apply(rect.x, rect.width, &xs);
rect.y = operation.snap_y.apply(rect.y, rect.height, &ys);
}
}
operation.visual = Some(rect);
self.space.raise_element(&operation.window, false);
}
operation.last_location = operation.location;
let outputs = self
.space
.outputs()
.filter(|output| {
*output == &operation.output
|| self.space.output_geometry(output).is_some_and(|geometry| {
let bounds = Rect::new(
geometry.loc.x as f64,
geometry.loc.y as f64,
geometry.size.w as f64,
geometry.size.h as f64,
);
[previous_visual, operation.visual]
.into_iter()
.flatten()
.any(|rect| crate::floating::intersection(rect, bounds).is_some())
})
})
.cloned()
.collect::<Vec<_>>();
for output in outputs {
self.defer_output_redraw(output);
}
self.cursor_redraw_pending = true;
}
if operation.started && operation.edges.is_none() && !operation.finished {
self.scroll_drag_viewport(&mut operation);
}
if operation.finished {
let committed = operation.started
&& (operation.edges.is_some() || self.drop_interaction(&operation));
self.finish_window_interaction(&operation, !committed);
} else {
self.pointer_operation = Some(operation);
}
}
fn scroll_drag_viewport(&mut self, operation: &mut WindowInteraction) {
let now = std::time::Instant::now();
let elapsed = now
.duration_since(operation.last_tick)
.as_secs_f64()
.min(0.05);
operation.last_tick = now;
let wheel = std::mem::take(&mut operation.scroll);
let Some(output) = self.space.output_under(operation.location).next().cloned() else {
return;
};
let Some(bounds) = self.output_bounds_for(&output) else {
return;
};
let Some(workspace) = self
.output_id(&output)
.and_then(|id| self.output_workspaces.active_workspace(id))
else {
return;
};
// The pointer can be held at an edge to reach columns outside the viewport.
let edge = if operation.drop_floating
|| self
.layer_under(operation.location)
.is_some_and(|(layer, ..)| {
matches!(
layer.layer(),
smithay::wayland::shell::wlr_layer::Layer::Top
| smithay::wayland::shell::wlr_layer::Layer::Overlay
)
}) {
0.0
} else if operation.location.x < bounds.x + 32.0 {
-1.0
} else if operation.location.x > bounds.x + bounds.width - 32.0 {
1.0
} else {
0.0
};
if let Some(workspace) = self.workspaces.workspace_mut(workspace)
&& workspace.fullscreen.is_none()
&& let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout
&& layout.scroll_viewport(wheel + edge * elapsed * 800.0)
{
self.relayout();
}
}
fn resize_interaction(
&mut self,
operation: &mut WindowInteraction,
edges: ResizeEdge,
delta: Point<f64, Logical>,
) {
if operation.placement == WindowPlacement::Tiled {
let Some(bounds) = self.output_bounds_for(&operation.output) else {
return;
};
let mut layout = operation.base_layout.clone();
if edges.left() || edges.right() {
let edge = if edges.left() {
Direction::Left
} else {
Direction::Right
};
let _ = layout.resize_edge(operation.id, edge, delta.x, bounds);
}
if edges.top() || edges.bottom() {
let edge = if edges.top() {
Direction::Up
} else {
Direction::Down
};
let _ = layout.resize_edge(operation.id, edge, delta.y, bounds);
}
if let Some(workspace) = self.workspaces.workspace_mut(operation.workspace) {
workspace.layout = layout;
}
self.relayout();
if let Some(workspace) = self.workspaces.workspace(operation.workspace) {
operation.applied_layout = workspace.layout.clone();
}
} else if let Some(surface) = operation.window.toplevel() {
let initial = operation.initial;
let mut delta = delta;
let left = initial.loc.x as f64 + if edges.left() { delta.x } else { 0.0 };
let top = initial.loc.y as f64 + if edges.top() { delta.y } else { 0.0 };
let right = initial.loc.x as f64
+ initial.size.w as f64
+ if edges.right() { delta.x } else { 0.0 };
let bottom = initial.loc.y as f64
+ initial.size.h as f64
+ if edges.bottom() { delta.y } else { 0.0 };
let raw = Rect::new(left, top, (right - left).max(1.0), (bottom - top).max(1.0));
let (xs, ys) = self.floating_snap_lines(&operation.window, raw);
if self.floating_snap_bypassed() {
operation.snap_x.clear();
operation.snap_y.clear();
} else {
if edges.left() || edges.right() {
let moving = if edges.left() { left } else { right };
delta.x += operation.snap_x.apply(moving, 0.0, &xs) - moving;
}
if edges.top() || edges.bottom() {
let moving = if edges.top() { top } else { bottom };
delta.y += operation.snap_y.apply(moving, 0.0, &ys) - moving;
}
}
let (min, max) = with_states(surface.wl_surface(), |states| {
let mut cache = states.cached_state.get::<SurfaceCachedState>();
let cached = cache.current();
(cached.min_size, cached.max_size)
});
let (min, max) =
self.effective_size_constraints(&operation.window, (min.w, min.h), (max.w, max.h));
let size =
crate::grabs::constrained_size(operation.initial.size, delta, edges, min, max);
let rect = crate::grabs::resized_rect(operation.initial, size, edges);
self.set_floating_window_geometry(&operation.window, rect.loc, rect.size);
operation.applied_placement = self
.workspaces
.placement(operation.id)
.unwrap_or(operation.applied_placement);
let initial = operation.initial;
self.windows.update(operation.id, |record| {
record.resize_anchor = Some((
edges.left(),
edges.top(),
Rect::new(
initial.loc.x as f64,
initial.loc.y as f64,
initial.size.w as f64,
initial.size.h as f64,
),
))
});
}
if let Some(surface) = operation.window.toplevel() {
surface.with_pending_state(|state| {
state.states.set(xdg_toplevel::State::Resizing);
});
surface.send_pending_configure();
}
}
fn drop_interaction(&mut self, operation: &WindowInteraction) -> bool {
if self
.layer_under(operation.location)
.is_some_and(|(layer, ..)| {
matches!(
layer.layer(),
smithay::wayland::shell::wlr_layer::Layer::Top
| smithay::wayland::shell::wlr_layer::Layer::Overlay
)
})
{
return false;
}
let Some(output) = self.space.output_under(operation.location).next().cloned() else {
return false;
};
let Some(destination) = self
.output_id(&output)
.and_then(|id| self.output_workspaces.active_workspace(id))
else {
return false;
};
if self
.workspaces
.workspace(destination)
.is_none_or(|workspace| workspace.fullscreen.is_some())
&& !(destination == operation.workspace
&& operation.drop_floating
&& self.window_interaction_allowed(operation.id, destination))
{
return false;
}
let target = self
.space
.elements()
.rev()
.filter_map(|window| {
let id = self.windows.ids().get(window).copied()?;
if id == operation.id
|| self.workspaces.workspace_for_window(id) != Some(destination)
|| self.workspaces.placement(id) != Some(WindowPlacement::Tiled)
{
return None;
}
let rect = self.visual_rect_for_window(window)?;
rect.to_f64()
.contains(operation.location)
.then_some((id, rect))
})
.next();
// Apply a drop transactionally, preserving all changes made while dragging.
let mut next = self.workspaces.clone();
let floating = matches!(operation.placement, WindowPlacement::Floating { .. });
if floating != operation.drop_floating
&& next
.toggle_floating(
operation.id,
operation
.visual
.expect("started move has a visual rectangle"),
LayoutAxis::Horizontal,
0.5,
)
.is_err()
{
return false;
}
if next
.move_window_to_workspace(operation.id, destination, LayoutAxis::Horizontal, 0.5)
.is_err()
{
return false;
}
match next
.placement(operation.id)
.expect("validated dragged window")
{
WindowPlacement::Tiled => {
if let Some((target, rect)) = target {
let placement = drop_placement(operation.location, rect);
if next
.workspace_mut(destination)
.expect("validated destination")
.layout
.place_window(operation.id, target, placement)
.is_err()
{
return false;
}
}
}
WindowPlacement::Floating { .. } => {
if let Some(rect) = operation.visual
&& next.set_floating_rect(operation.id, rect).is_err()
{
return false;
}
}
}
if next.validate().is_err() {
return false;
}
self.workspaces = next;
// Start the landing animation at the position actually being displayed.
if let Some(rect) = operation.visual {
self.windows.clear_transaction(&operation.id);
self.windows.update(operation.id, |record| {
record.world_x = None;
record.coupled_width = None;
if let Some(geometry) = record.geometry.as_mut() {
geometry.visual = ferese_animation::AnimatedRect::new(rect);
geometry.presentation_changed = true;
}
});
}
self.activate_managed_window(operation.id);
true
}
fn finish_window_interaction(&mut self, operation: &WindowInteraction, cancelled: bool) {
if let Some(surface) = operation.window.toplevel() {
surface.with_pending_state(|state| {
state.states.unset(xdg_toplevel::State::Resizing);
});
surface.send_pending_configure();
}
if cancelled && operation.was_maximized && self.windows.window(operation.id).is_some() {
self.set_window_maximized(operation.id, true);
}
if !cancelled {
self.remember_floating(&operation.window);
}
self.cursor_status = CursorImageStatus::default_named();
self.relayout();
self.cursor_redraw_pending = true;
}
}
fn drop_placement(
location: Point<f64, Logical>,
rect: smithay::utils::Rectangle<i32, Logical>,
) -> DropPlacement {
let x = (location.x - rect.loc.x as f64) / rect.size.w.max(1) as f64;
let y = (location.y - rect.loc.y as f64) / rect.size.h.max(1) as f64;
if (0.25..=0.75).contains(&x) && (0.25..=0.75).contains(&y) {
return DropPlacement::Tab;
}
let edges = [
(x, DropPlacement::Left),
(1.0 - x, DropPlacement::Right),
(y, DropPlacement::Above),
(1.0 - y, DropPlacement::Below),
];
edges
.into_iter()
.min_by(|a, b| a.0.total_cmp(&b.0))
.expect("four edges")
.1
}
fn same_layout(a: &WorkspaceLayout, b: &WorkspaceLayout) -> bool {
match (a, b) {
(WorkspaceLayout::Scrolling(a), WorkspaceLayout::Scrolling(b)) => {
a.columns() == b.columns()
}
_ => a == b,
}
}
+102 -26
View File
@@ -89,14 +89,24 @@ fn edge(barrier: &Barrier, zones: &[Zone]) -> Option<Edge> {
}
match found {
Edge::Top | Edge::Bottom => {
let outside = y1 + if matches!(found, Edge::Top) { -0.5 } else { 0.5 };
let outside = y1
+ if matches!(found, Edge::Top) {
-0.5
} else {
0.5
};
outside >= f64::from(other.y)
&& outside < other.bottom()
&& x1 < other.right()
&& x2 + 1.0 > f64::from(other.x)
}
Edge::Left | Edge::Right => {
let outside = x1 + if matches!(found, Edge::Left) { -0.5 } else { 0.5 };
let outside = x1
+ if matches!(found, Edge::Left) {
-0.5
} else {
0.5
};
outside >= f64::from(other.x)
&& outside < other.right()
&& y1 < other.bottom()
@@ -164,7 +174,11 @@ impl InputCapture {
self.disable_all();
let ids = self.sessions.keys().copied().collect::<Vec<_>>();
for id in ids {
self.sessions.get_mut(&id).unwrap().barriers.clear();
self.sessions
.get_mut(&id)
.expect("input capture operations validate their session or waiter before mutation")
.barriers
.clear();
self.enqueue(id, json!({"type":"zones", "zone_set":self.zone_set}));
}
self.flush();
@@ -174,7 +188,10 @@ impl InputCapture {
if self.sessions.len() >= MAX_SESSIONS || capabilities == 0 || capabilities & !3 != 0 {
return Err("Unsupported capabilities or input capture session limit reached".into());
}
self.next_session = self.next_session.checked_add(1).ok_or("Input capture IDs exhausted")?;
self.next_session = self
.next_session
.checked_add(1)
.ok_or("Input capture IDs exhausted")?;
let id = self.next_session;
self.sessions.insert(
id,
@@ -193,15 +210,24 @@ impl InputCapture {
pub(crate) fn zones(&self, id: u64) -> Result<Value, String> {
self.session(id)?;
Ok(json!({"zone_set":self.zone_set, "zones":self.zones.iter().map(|zone|
(zone.width, zone.height, zone.x, zone.y)).collect::<Vec<_>>()}))
Ok(
json!({"zone_set":self.zone_set, "zones":self.zones.iter().map(|zone|
(zone.width, zone.height, zone.x, zone.y)).collect::<Vec<_>>()}),
)
}
fn session(&self, id: u64) -> Result<&Session, String> {
self.sessions.get(&id).ok_or("Unknown input capture session".into())
self.sessions
.get(&id)
.ok_or("Unknown input capture session".into())
}
pub(crate) fn set_barriers(&mut self, id: u64, zone_set: u32, barriers: Vec<Barrier>) -> Result<Vec<u32>, String> {
pub(crate) fn set_barriers(
&mut self,
id: u64,
zone_set: u32,
barriers: Vec<Barrier>,
) -> Result<Vec<u32>, String> {
self.session(id)?;
if barriers.len() > MAX_BARRIERS {
return Err("Too many pointer barriers".into());
@@ -224,12 +250,18 @@ impl InputCapture {
failed.push(barrier.id);
}
}
self.sessions.get_mut(&id).unwrap().barriers = accepted;
self.sessions
.get_mut(&id)
.expect("input capture operations validate their session or waiter before mutation")
.barriers = accepted;
Ok(failed)
}
pub(crate) fn enable(&mut self, id: u64) -> Result<(), String> {
let session = self.sessions.get_mut(&id).ok_or("Unknown input capture session")?;
let session = self
.sessions
.get_mut(&id)
.ok_or("Unknown input capture session")?;
if session.closed {
return Err("Input capture session is closed".into());
}
@@ -255,7 +287,11 @@ impl InputCapture {
if self.active() {
return true;
}
if !allowed || ![from.0, from.1, to.0, to.1].iter().all(|value| value.is_finite()) {
if !allowed
|| ![from.0, from.1, to.0, to.1]
.iter()
.all(|value| value.is_finite())
{
return false;
}
let hit = self
@@ -274,7 +310,10 @@ impl InputCapture {
self.next_activation = self.next_activation.wrapping_add(1);
let activation = self.next_activation;
self.active = Some(id);
let session = self.sessions.get_mut(&id).unwrap();
let session = self
.sessions
.get_mut(&id)
.expect("input capture operations validate their session or waiter before mutation");
session.activation = Some(activation);
self.enqueue(
id,
@@ -338,9 +377,14 @@ impl InputCapture {
self.active = None;
self.last_absolute = None;
self.restore_focus = true;
let session = self.sessions.get_mut(&id).unwrap();
let session = self.sessions.get_mut(&id).expect(
"input capture operations validate their session or waiter before mutation",
);
let activation = session.activation.take();
self.enqueue(id, json!({"type":"deactivated", "activation_id":activation}));
self.enqueue(
id,
json!({"type":"deactivated", "activation_id":activation}),
);
self.flush();
}
Ok(())
@@ -348,7 +392,10 @@ impl InputCapture {
pub(crate) fn disable(&mut self, id: u64) -> Result<(), String> {
self.release(id, None)?;
let session = self.sessions.get_mut(&id).unwrap();
let session = self
.sessions
.get_mut(&id)
.expect("input capture operations validate their session or waiter before mutation");
if session.enabled {
session.enabled = false;
self.enqueue(id, json!({"type":"disabled"}));
@@ -365,10 +412,18 @@ impl InputCapture {
}
pub(crate) fn valid_position(&self, point: (f64, f64)) -> bool {
point.0.is_finite() && point.1.is_finite() && self.zones.iter().any(|zone| zone.contains(point))
point.0.is_finite()
&& point.1.is_finite()
&& self.zones.iter().any(|zone| zone.contains(point))
}
pub(crate) fn watch(&mut self, owner: u64, request: u64, id: u64, response: SyncSender<Response>) {
pub(crate) fn watch(
&mut self,
owner: u64,
request: u64,
id: u64,
response: SyncSender<Response>,
) {
if !self.sessions.contains_key(&id)
|| self.waiters.len() >= MAX_SESSIONS
|| self.waiters.values().any(|(_, token, _)| *token == id)
@@ -396,7 +451,9 @@ impl InputCapture {
})
.collect::<Vec<_>>();
for owner in ready {
let (request, id, response) = self.waiters.remove(&owner).unwrap();
let (request, id, response) = self.waiters.remove(&owner).expect(
"input capture operations validate their session or waiter before mutation",
);
let result = if let Some(session) = self.sessions.get_mut(&id) {
json!({"events":session.events.drain(..).collect::<Vec<_>>()})
} else {
@@ -459,7 +516,7 @@ impl crate::Ferese {
&smithay::input::pointer::MotionEvent {
location,
serial: smithay::utils::SERIAL_COUNTER.next_serial(),
time: 0,
time: smithay::backend::input::InputTime::from_millis(0),
},
);
pointer.frame(self);
@@ -479,10 +536,19 @@ impl crate::Ferese {
&& !self.overview.is_presenting()
&& !pointer.is_grabbed()
&& !self.swipe.active()
&& self.seat.get_keyboard().is_none_or(|keyboard| !keyboard.is_grabbed())
&& self.seat.get_touch().is_none_or(|touch| !touch.is_grabbed())
&& self
.seat
.get_keyboard()
.is_none_or(|keyboard| !keyboard.is_grabbed())
&& self
.seat
.get_touch()
.is_none_or(|touch| !touch.is_grabbed())
&& !self.seat.keyboard_shortcuts_inhibited();
if !self.input_capture.motion((from.x, from.y), (to.x, to.y), allowed) {
if !self
.input_capture
.motion((from.x, from.y), (to.x, to.y), allowed)
{
return false;
}
let delta = if absolute {
@@ -499,7 +565,7 @@ impl crate::Ferese {
&smithay::input::pointer::MotionEvent {
location: from,
serial: smithay::utils::SERIAL_COUNTER.next_serial(),
time: 0,
time: smithay::backend::input::InputTime::from_millis(0),
},
);
pointer.frame(self);
@@ -553,7 +619,10 @@ mod tests {
[3840, 0, 3840, 1079],
[0, 0, 0, 1079],
] {
assert!(edge(&Barrier { id: 1, position }, &zones).is_some(), "{position:?}");
assert!(
edge(&Barrier { id: 1, position }, &zones).is_some(),
"{position:?}"
);
}
for position in [
[1920, 0, 1920, 1079],
@@ -562,7 +631,10 @@ mod tests {
[50, 50, 50, 200],
[0, 5, 0, 4],
] {
assert!(edge(&Barrier { id: 1, position }, &zones).is_none(), "{position:?}");
assert!(
edge(&Barrier { id: 1, position }, &zones).is_none(),
"{position:?}"
);
}
let barrier = Barrier {
id: 1,
@@ -651,7 +723,11 @@ mod tests {
capture.enable(id).unwrap();
assert!(capture.motion((3839.0, 50.0), (3900.0, 50.0), true));
let activation = capture.sessions[&id].activation.unwrap();
assert!(capture.release(id, Some(activation.wrapping_add(1))).is_err());
assert!(
capture
.release(id, Some(activation.wrapping_add(1)))
.is_err()
);
capture.release(id, Some(activation)).unwrap();
assert!(capture.sessions[&id].enabled);
capture.update_zones(vec![zones()[0]]);
+502 -150
View File
File diff suppressed because it is too large. Load diff
+42 -9
View File
@@ -25,7 +25,9 @@ impl VerifiedOwner {
let mut next = self.owner.pid;
let mut child = None;
for _ in 0..16 {
let Ok((birth, parent)) = identity(next) else { break };
let Ok((birth, parent)) = identity(next) else {
break;
};
if next == self.owner.pid && birth != self.owner.birth {
break;
}
@@ -59,7 +61,10 @@ impl VerifiedOwner {
pub(crate) struct Monitor(Arc<(Mutex<BTreeMap<Owner, OwnedFd>>, Condvar)>);
impl Monitor {
pub fn new(sender: channel::SyncSender<IpcEvent>) -> Self {
let shared = Arc::new((Mutex::new(BTreeMap::<Owner, OwnedFd>::new()), Condvar::new()));
let shared = Arc::new((
Mutex::new(BTreeMap::<Owner, OwnedFd>::new()),
Condvar::new(),
));
let worker = shared.clone();
std::thread::Builder::new()
.name("ferese-activity-owners".into())
@@ -104,7 +109,11 @@ impl Monitor {
.expect("activity owners")
.retain(|owner, _| owners.contains(owner));
}
pub fn verify(&self, stream: &UnixStream, parent: bool) -> io::Result<(VerifiedOwner, Vec<String>)> {
pub fn verify(
&self,
stream: &UnixStream,
parent: bool,
) -> io::Result<(VerifiedOwner, Vec<String>)> {
let peer = credentials(stream)?;
let peer_pid = u32::try_from(peer.pid).map_err(|_| io::Error::other("invalid peer PID"))?;
let peer_identity = identity(peer_pid)?;
@@ -115,7 +124,10 @@ impl Monitor {
let pid = if parent { peer_identity.1 } else { peer_pid };
let (birth, _) = identity(pid)?;
let fd = pidfd(pid)?;
if identity(pid)?.0 != birth || exited(&fd) || (parent && identity(peer_pid)? != peer_identity) {
if identity(pid)?.0 != birth
|| exited(&fd)
|| (parent && identity(peer_pid)? != peer_identity)
{
return Err(io::Error::other("owner identity changed"));
}
let metadata = std::fs::metadata(format!("/proc/{pid}"))?;
@@ -202,6 +214,8 @@ fn pidfd(pid: u32) -> io::Result<OwnedFd> {
if fd < 0 {
Err(io::Error::last_os_error())
} else {
// SAFETY: pidfd_open succeeded above and returned a fresh descriptor whose sole ownership
// transfers here.
Ok(unsafe { OwnedFd::from_raw_fd(fd as i32) })
}
}
@@ -224,7 +238,11 @@ fn parse_stat(stat: &str) -> io::Result<(u64, u32)> {
Ok((birth, parent))
}
fn credentials(stream: &UnixStream) -> io::Result<libc::ucred> {
let mut credentials = libc::ucred { pid: 0, uid: 0, gid: 0 };
let mut credentials = libc::ucred {
pid: 0,
uid: 0,
gid: 0,
};
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
// SAFETY: both output buffers have the sizes expected by SO_PEERCRED.
if unsafe {
@@ -244,6 +262,7 @@ fn credentials(stream: &UnixStream) -> io::Result<libc::ucred> {
}
pub(crate) fn inspect(pid: u32) -> io::Result<(Owner, OwnedFd, Vec<String>)> {
use std::os::unix::fs::MetadataExt;
// SAFETY: geteuid has no pointer arguments or caller-side preconditions.
if std::fs::metadata(format!("/proc/{pid}"))?.uid() != unsafe { libc::geteuid() } {
return Err(io::Error::other("process belongs to another user"));
}
@@ -274,7 +293,10 @@ mod tests {
use super::*;
#[test]
fn captured_ancestry_stops_at_an_exited_process() {
let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap();
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.unwrap();
let (owner, fd, _) = inspect(child.id()).unwrap();
let mut verified = VerifiedOwner {
owner,
@@ -283,7 +305,11 @@ mod tests {
};
verified.capture_ancestors();
assert_eq!(verified.ancestor_pids().next(), Some(child.id()));
assert!(verified.ancestor_pids().any(|pid| pid == std::process::id()));
assert!(
verified
.ancestor_pids()
.any(|pid| pid == std::process::id())
);
child.kill().unwrap();
child.wait().unwrap();
assert!(verified.ancestor_pids().next().is_none());
@@ -334,7 +360,10 @@ mod tests {
fn owner_that_exits_in_the_dispatch_queue_is_rejected() {
let (sender, _receiver) = channel::sync_channel(16);
let monitor = Monitor::new(sender);
let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap();
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.unwrap();
let (owner, fd, _) = inspect(child.id()).unwrap();
child.kill().unwrap();
child.wait().unwrap();
@@ -369,8 +398,12 @@ mod tests {
let (peer, _) = monitor.verify(&left, false).unwrap();
assert_eq!(peer.owner.pid, std::process::id());
let (parent, _) = monitor.verify(&left, true).unwrap();
// SAFETY: getppid has no pointer arguments or caller-side preconditions.
assert_eq!(parent.owner.pid, unsafe { libc::getppid() } as u32);
let mut child = std::process::Command::new("sleep").arg("0.05").spawn().unwrap();
let mut child = std::process::Command::new("sleep")
.arg("0.05")
.spawn()
.unwrap();
let fd = pidfd(child.id()).unwrap();
assert!(!exited(&fd));
child.wait().unwrap();
+318
View File
@@ -0,0 +1,318 @@
use std::sync::Arc;
use ferese_core::{OutputId, WorkspaceId};
use serde_json::Value;
use smithay::utils::{Logical, Rectangle};
use crate::backends::direct::ConnectedOutputInfo;
/// Connector data and policy borrowed for an exact, allocation-free comparison.
pub(crate) struct State<'a> {
pub connected: &'a [ConnectedOutputInfo],
pub focused: Option<OutputId>,
pub error: Option<&'a str>,
pub confirmation_pending: bool,
pub low_power: bool,
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) struct Presentation {
pub id: Option<OutputId>,
pub geometry: Option<Rectangle<i32, Logical>>,
pub workspace: Option<WorkspaceId>,
pub scale: f64,
pub transform: &'static str,
}
struct Entry {
connected: Vec<ConnectedOutputInfo>,
focused: Option<OutputId>,
error: Option<String>,
confirmation_pending: bool,
low_power: bool,
presentations: Vec<Presentation>,
unlocked: Arc<Value>,
locked: Option<Arc<Value>>,
}
#[derive(Default)]
pub(crate) struct Cache {
entry: Option<Entry>,
}
impl Cache {
pub(crate) fn clear(&mut self) {
self.entry = None;
}
pub(crate) fn get_or_update(
&mut self,
state: State<'_>,
presentations: impl Iterator<Item = Presentation> + Clone,
locked: bool,
build: impl FnOnce() -> Value,
) -> Arc<Value> {
let unchanged = self.entry.as_ref().is_some_and(|entry| {
entry.connected == state.connected
&& entry.focused == state.focused
&& entry.error.as_deref() == state.error
&& entry.confirmation_pending == state.confirmation_pending
&& entry.low_power == state.low_power
&& presentations
.clone()
.eq(entry.presentations.iter().copied())
});
if !unchanged {
self.entry = Some(Entry {
connected: state.connected.to_vec(),
focused: state.focused,
error: state.error.map(str::to_owned),
confirmation_pending: state.confirmation_pending,
low_power: state.low_power,
presentations: presentations.collect(),
unlocked: Arc::new(build()),
locked: None,
});
}
let entry = self.entry.as_mut().expect("output snapshot was populated");
if locked {
entry
.locked
.get_or_insert_with(|| {
let mut value = entry.unlocked.as_ref().clone();
redact(&mut value);
Arc::new(value)
})
.clone()
} else {
entry.unlocked.clone()
}
}
}
pub(super) fn redact(value: &mut Value) {
for output in value.as_array_mut().into_iter().flatten() {
if let Some(fields) = output.as_object_mut() {
fields.remove("workspace");
fields.remove("focused");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backends::direct::ConnectedModeInfo;
use crate::config::OutputTransform;
use serde_json::json;
fn connector() -> ConnectedOutputInfo {
let mode = ConnectedModeInfo {
width: 3840,
height: 2160,
refresh_millihertz: 60_000,
preferred: true,
};
ConnectedOutputInfo {
connector: "HDMI-A-1".into(),
connected: true,
identity: "test-monitor".into(),
internal: false,
requested_enabled: true,
mirror_source: None,
enabled: true,
profile: Some("desktop".into()),
requested_profile: None,
auto_refresh: true,
physical_size: Some((600, 340)),
current_mode: Some(mode),
available_modes: vec![mode],
scale: 1.75,
transform: OutputTransform::Normal,
configured_position: Some([0, 0]),
}
}
fn state(connected: &[ConnectedOutputInfo]) -> State<'_> {
State {
connected,
focused: Some(OutputId(1)),
error: None,
confirmation_pending: false,
low_power: false,
}
}
fn presentation() -> Presentation {
Presentation {
id: Some(OutputId(1)),
geometry: Some(Rectangle::new((0, 0).into(), (2195, 1235).into())),
workspace: Some(WorkspaceId(3)),
scale: 1.75,
transform: "normal",
}
}
fn value() -> Value {
json!([{"id": 1, "workspace": 3, "focused": true, "scale": 1.75}])
}
#[test]
fn unchanged_outputs_reuse_storage_without_serializing_and_release_when_cleared() {
let connected = [connector()];
let mut cache = Cache::default();
let first = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
value,
);
for _ in 0..1000 {
let next = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
|| panic!("unchanged outputs must not rebuild JSON"),
);
assert!(Arc::ptr_eq(&first, &next));
}
cache.clear();
let next = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
value,
);
assert!(!Arc::ptr_eq(&first, &next));
assert_eq!(first, next);
}
#[test]
fn lock_redaction_is_cached_without_mutating_public_or_older_snapshots() {
let connected = [connector()];
let mut cache = Cache::default();
let public = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
value,
);
let locked = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
true,
|| panic!("lock redaction must reuse connector data"),
);
assert_eq!(*locked, json!([{"id": 1, "scale": 1.75}]));
assert_eq!(*public, value());
let again = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
true,
|| panic!("repeated locked snapshots must not rebuild"),
);
assert!(Arc::ptr_eq(&locked, &again));
let unlocked = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
|| panic!("unlock must reuse public data"),
);
assert!(Arc::ptr_eq(&public, &unlocked));
let changed = cache.get_or_update(state(&[]), [].into_iter(), true, || json!([]));
assert_eq!(*changed, json!([]));
assert_eq!(*locked, json!([{"id": 1, "scale": 1.75}]));
}
#[test]
fn connector_inventory_and_every_reported_property_invalidate_the_snapshot() {
let changes: &[fn(&mut ConnectedOutputInfo)] = &[
|info| info.connector.push('2'),
|info| info.connected = false,
|info| info.identity.push('2'),
|info| info.internal = true,
|info| info.requested_enabled = false,
|info| info.mirror_source = Some("DP-1".into()),
|info| info.enabled = false,
|info| info.profile = None,
|info| info.requested_profile = Some("mobile".into()),
|info| info.auto_refresh = false,
|info| info.physical_size = None,
|info| info.current_mode = None,
|info| info.available_modes[0].width = 1920,
|info| info.available_modes[0].height = 1080,
|info| info.available_modes[0].refresh_millihertz = 120_000,
|info| info.available_modes[0].preferred = false,
|info| info.available_modes.clear(),
|info| info.scale = 1.5,
|info| info.transform = OutputTransform::Rotate90,
|info| info.configured_position = None,
];
for (index, change) in changes.iter().enumerate() {
let mut cache = Cache::default();
let mut connected = [connector()];
let first = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
value,
);
change(&mut connected[0]);
let next = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
|| json!({"changed": index}),
);
assert_eq!(*next, json!({"changed": index}));
assert!(!Arc::ptr_eq(&first, &next));
}
let mut cache = Cache::default();
let first = cache.get_or_update(
state(&[connector()]),
[presentation()].into_iter(),
false,
value,
);
let next = cache.get_or_update(state(&[]), [].into_iter(), false, || json!([]));
assert_eq!(*next, json!([]));
assert_eq!(*first, value());
}
#[test]
fn focus_workspace_geometry_and_output_policy_changes_refresh_the_snapshot() {
let connected = [connector()];
for index in 0..10 {
let mut cache = Cache::default();
let first = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
value,
);
let mut state = state(&connected);
let mut presentation = presentation();
match index {
0 => state.focused = None,
1 => state.error = Some("output rejected"),
2 => state.confirmation_pending = true,
3 => state.low_power = true,
4 => presentation.id = None,
5 => presentation.geometry = None,
6 => presentation.geometry.as_mut().unwrap().loc.x += 100,
7 => presentation.workspace = Some(WorkspaceId(4)),
8 => presentation.scale = 1.25,
9 => presentation.transform = "rotate_90",
_ => unreachable!(),
}
let next = cache.get_or_update(
state,
[presentation].into_iter(),
false,
|| json!({"changed": index}),
);
assert_eq!(*next, json!({"changed": index}));
assert!(!Arc::ptr_eq(&first, &next));
}
}
}
+16 -5
View File
@@ -41,7 +41,10 @@ impl Write for DeadlineIo<'_> {
}
}
pub(super) fn read_request(stream: &UnixStream, idle: Option<Duration>) -> Result<Request, FrameError> {
pub(super) fn read_request(
stream: &UnixStream,
idle: Option<Duration>,
) -> Result<Request, FrameError> {
read_request_with_budget(stream, idle, FRAME_TIMEOUT)
}
@@ -60,7 +63,10 @@ fn read_request_with_budget(
ferese_ipc::read_frame(&mut first.as_slice().chain(reader))
}
pub(super) fn write_response(stream: &UnixStream, value: &impl serde::Serialize) -> Result<(), FrameError> {
pub(super) fn write_response(
stream: &UnixStream,
value: &impl serde::Serialize,
) -> Result<(), FrameError> {
// Tests may install a tighter socket timeout.
let previous = stream.write_timeout()?;
let budget = previous.unwrap_or(WRITE_TIMEOUT).min(WRITE_TIMEOUT);
@@ -88,7 +94,8 @@ mod tests {
assert!(read_request_with_budget(&server, Some(short), short).is_err());
let (mut client, server) = UnixStream::pair().unwrap();
let worker = std::thread::spawn(move || read_request_with_budget(&server, None, short).unwrap());
let worker =
std::thread::spawn(move || read_request_with_budget(&server, None, short).unwrap());
std::thread::sleep(short * 3);
let request = Request {
version: 1,
@@ -112,8 +119,12 @@ mod tests {
std::thread::sleep(Duration::from_millis(30));
}
});
let error =
read_request_with_budget(&server, Some(Duration::from_secs(1)), Duration::from_millis(70)).unwrap_err();
let error = read_request_with_budget(
&server,
Some(Duration::from_secs(1)),
Duration::from_millis(70),
)
.unwrap_err();
assert!(
matches!(error, FrameError::Io(error) if matches!(error.kind(), io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock))
);
+206 -21
View File
@@ -1,3 +1,4 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::mpsc::SyncSender;
@@ -18,14 +19,25 @@ pub(crate) struct Subscribers {
previous: Option<Snapshot>,
generation: u64,
pub(crate) config_revision: u64,
pub(crate) outputs: RefCell<crate::ipc::outputs::Cache>,
}
impl Subscribers {
pub(crate) fn subscribe(&mut self, owner: u64, sender: SyncSender<Arc<Event>>) -> Result<(), &'static str> {
pub(crate) fn subscribe(
&mut self,
owner: u64,
sender: SyncSender<Arc<Event>>,
) -> Result<(), &'static str> {
if self.subscribers.len() >= MAX_SUBSCRIBERS {
return Err("Event subscriber limit reached");
}
self.subscribers.insert(owner, Subscriber { sender, initial: true });
self.subscribers.insert(
owner,
Subscriber {
sender,
initial: true,
},
);
Ok(())
}
@@ -33,6 +45,7 @@ impl Subscribers {
self.subscribers.remove(&owner);
if self.subscribers.is_empty() {
self.previous = None;
self.outputs.get_mut().clear();
}
}
@@ -47,7 +60,15 @@ impl Subscribers {
.as_ref()
.map(|previous| snapshot.changes_since(previous))
.unwrap_or_default();
if changes.is_empty() && !self.subscribers.values().any(|subscriber| subscriber.initial) {
if changes.is_empty()
&& !self
.subscribers
.values()
.any(|subscriber| subscriber.initial)
{
// Keep the latest shared payload even when a theme publication
// produced equal logical values, so later comparisons stay cheap.
self.previous = self.active().then_some(snapshot);
return;
}
self.generation = self.generation.wrapping_add(1);
@@ -63,9 +84,15 @@ impl Subscribers {
})
.collect::<Vec<_>>();
if let Some(last) = events.last_mut() {
Arc::get_mut(last).unwrap().last = true;
Arc::get_mut(last)
.expect("new initial-event records have not been shared with subscribers")
.last = true;
}
let initial = self.subscribers.values().any(|subscriber| subscriber.initial).then(|| {
let initial = self
.subscribers
.values()
.any(|subscriber| subscriber.initial)
.then(|| {
Arc::new(Event {
version: VERSION,
generation: self.generation,
@@ -77,7 +104,15 @@ impl Subscribers {
});
self.subscribers.retain(|_, subscriber| {
let delivered = if subscriber.initial {
subscriber.sender.try_send(initial.as_ref().unwrap().clone()).is_ok()
subscriber
.sender
.try_send(
initial
.as_ref()
.expect("the initial event batch is built before subscription delivery")
.clone(),
)
.is_ok()
} else {
events
.iter()
@@ -89,6 +124,9 @@ impl Subscribers {
delivered
});
self.previous = self.active().then_some(snapshot);
if !self.active() {
self.outputs.get_mut().clear();
}
}
}
@@ -115,14 +153,111 @@ mod tests {
use serde_json::json;
use std::sync::mpsc::{TryRecvError, sync_channel};
#[test]
#[ignore = "manual release benchmark for IPC snapshot construction"]
fn profile_unchanged_event_snapshots() {
let name = "ipc_events::tests::profile_unchanged_event_snapshots";
if !crate::startup_tests::private_runtime(name) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let state = crate::startup_tests::state(&mut events);
for _ in 0..100 {
std::hint::black_box(state.ipc_event_snapshot());
}
for run in 1..=3 {
let started = std::time::Instant::now();
for _ in 0..2000 {
std::hint::black_box(state.ipc_event_snapshot());
}
eprintln!(
"event_snapshot_benchmark run={run} iterations=2000 elapsed_us={}",
started.elapsed().as_micros()
);
}
}
#[test]
#[ignore = "manual release benchmark for populated IPC publication"]
fn profile_populated_event_publication() {
use crate::state::DesktopOutput;
use smithay::output::{Mode, Output, PhysicalProperties, Scale, Subpixel};
use smithay::utils::Transform;
if !crate::startup_tests::private_runtime(
"ipc_events::tests::profile_populated_event_publication",
) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let output = DesktopOutput {
output: Output::new(
"benchmark".into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
),
identity: "benchmark".into(),
mode: Mode {
size: (1920, 1080).into(),
refresh: 60_000,
},
transform: Transform::Normal,
scale: Scale::Fractional(1.0),
position: (0, 0).into(),
};
state.begin_desktop_transition();
let changes = state.publish_desktop(vec![output]).unwrap();
state.finish_desktop_transition(changes);
let mut clients = Vec::new();
for window_count in [0, 1, 20, 100] {
while clients.len() < window_count {
let (window, client) =
crate::state::test_window(&mut state, &mut events, 0xff204060);
let id = state.windows.ids()[&window];
let record = state.windows.record_mut(id).unwrap();
record.app_id = "org.example.Editor".into();
record.title = format!("Document {} — publication benchmark", id.0);
clients.push(client);
}
let (sender, receive) = sync_channel(QUEUE_CAPACITY);
state.ipc_events.subscribe(1, sender).unwrap();
state.publish_ipc_events();
assert!(matches!(
receive.try_recv().unwrap().change,
Change::Snapshot { .. }
));
for _ in 0..100 {
state.publish_ipc_events();
}
for run in 1..=3 {
let started = std::time::Instant::now();
for _ in 0..2000 {
state.publish_ipc_events();
}
eprintln!(
"event_publication_benchmark windows={window_count} run={run} iterations=2000 elapsed_us={}",
started.elapsed().as_micros()
);
}
assert!(matches!(receive.try_recv(), Err(TryRecvError::Empty)));
state.ipc_events.remove(1);
}
}
fn snapshot() -> Snapshot {
Snapshot {
outputs: json!([]),
outputs: json!([]).into(),
workspaces: json!([]),
windows: json!([]),
windows: Vec::new(),
focus: json!(null),
config: ConfigState::default(),
theme: json!({}),
theme: json!({}).into(),
lock: LockState {
phase: "unlocked".into(),
sleeping: false,
@@ -137,19 +272,44 @@ mod tests {
hub.subscribe(1, sender).unwrap();
let mut state = snapshot();
hub.publish(state.clone());
assert!(matches!(receive.try_recv().unwrap().change, Change::Snapshot { .. }));
assert!(matches!(
receive.try_recv().unwrap().change,
Change::Snapshot { .. }
));
hub.publish(state.clone());
assert!(matches!(receive.try_recv(), Err(TryRecvError::Empty)));
state.outputs = json!([{"id": 2}]);
state.outputs = json!([{"id": 2}]).into();
state.workspaces = json!([{"output": 2}]);
state.focus = json!({"output": 2});
hub.publish(state);
let events = receive.try_iter().collect::<Vec<_>>();
assert_eq!(events.len(), 3);
assert!(events.iter().all(|event| event.generation == events[0].generation));
assert!(
events
.iter()
.all(|event| event.generation == events[0].generation)
);
assert!(!events[0].last && !events[1].last && events[2].last);
}
#[test]
fn equal_theme_publications_retain_shared_storage_without_emitting_events() {
let mut hub = Subscribers::default();
let (sender, receive) = sync_channel(QUEUE_CAPACITY);
hub.subscribe(1, sender).unwrap();
let first = snapshot();
hub.publish(first.clone());
receive.try_recv().unwrap();
let generation = hub.generation;
let mut next = first;
next.theme = Arc::new(next.theme.as_ref().clone());
let payload = next.theme.clone();
hub.publish(next);
assert!(matches!(receive.try_recv(), Err(TryRecvError::Empty)));
assert_eq!(hub.generation, generation);
assert!(Arc::ptr_eq(&hub.previous.as_ref().unwrap().theme, &payload));
}
#[test]
fn full_and_disconnected_subscribers_do_not_block_healthy_readers() {
let mut hub = Subscribers::default();
@@ -171,7 +331,10 @@ mod tests {
));
assert_eq!(hub.subscribers.len(), 1);
slow_receive.try_recv().unwrap();
assert!(matches!(slow_receive.try_recv(), Err(TryRecvError::Disconnected)));
assert!(matches!(
slow_receive.try_recv(),
Err(TryRecvError::Disconnected)
));
hub.remove(2);
assert!(!hub.active());
assert!(hub.previous.is_none());
@@ -199,17 +362,29 @@ mod tests {
let mut state = snapshot();
hub.publish(state.clone());
receive.try_recv().unwrap();
state.outputs = json!([{"id": 1}]);
state.outputs = json!([{"id": 1}]).into();
state.workspaces = json!([{"output": 1}]);
state.windows = json!([{"id": 7, "title": "Terminal"}]);
state.windows = vec![ferese_ipc::events::Window {
id: 7,
app_id: "org.example.Terminal".into(),
title: "Terminal".into(),
focused: false,
workspace: 1,
floating: false,
fullscreen: false,
}];
state.focus = json!({"window": 7});
state.config.revision = 1;
state.theme = json!({"mode": "light"});
state.theme = json!({"mode": "light"}).into();
state.lock.phase = "locked".into();
hub.publish(state);
let events = receive.try_iter().collect::<Vec<_>>();
assert_eq!(events.len(), 7);
assert!(events.iter().all(|event| event.generation == events[0].generation));
assert!(
events
.iter()
.all(|event| event.generation == events[0].generation)
);
assert!(events[..6].iter().all(|event| !event.last));
assert!(events[6].last);
}
@@ -218,7 +393,8 @@ mod tests {
fn topology_events_wait_for_publication_and_report_config_commits() {
use crate::state::DesktopOutput;
use smithay::output::{Mode, Output, PhysicalProperties, Scale, Subpixel};
use smithay::reexports::{calloop::EventLoop, wayland_server::Display};
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_server::Display;
use smithay::utils::Transform;
if !crate::startup_tests::private_runtime(
@@ -229,11 +405,13 @@ mod tests {
let mut event_loop = EventLoop::try_new().unwrap();
let mut config = crate::config::Config::default().runtime_config().unwrap();
config.wallpaper.path = None;
let mut state = crate::Ferese::new(&mut event_loop, Display::new().unwrap(), config).unwrap();
let mut state =
crate::Ferese::new(&mut event_loop, Display::new().unwrap(), config).unwrap();
let output = |name: &str, x| DesktopOutput {
output: Output::new(
name.into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
@@ -272,7 +450,11 @@ mod tests {
state.publish_ipc_events();
let events = receive.try_iter().collect::<Vec<_>>();
assert!(events.last().unwrap().last);
assert!(events.iter().all(|event| event.generation == events[0].generation));
assert!(
events
.iter()
.all(|event| event.generation == events[0].generation)
);
let final_state = state.ipc_event_snapshot();
assert_eq!(final_state.outputs.as_array().unwrap().len(), 2);
for workspace in final_state.workspaces.as_array().unwrap() {
@@ -335,7 +517,10 @@ mod tests {
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while completion.try_recv().is_err() {
assert!(std::time::Instant::now() < deadline, "subscription worker timed out");
assert!(
std::time::Instant::now() < deadline,
"subscription worker timed out"
);
event_loop
.dispatch(std::time::Duration::from_millis(10), &mut state)
.unwrap();
+40 -6
View File
@@ -5,11 +5,13 @@ mod config;
mod cursor;
mod daemon;
mod display_presentation;
mod dmabuf_feedback;
mod dmabuf_imports;
mod effects;
mod floating;
mod focus_effect;
mod frame_scheduler;
mod gamma;
mod gestures;
mod grabs;
mod handlers;
@@ -27,6 +29,7 @@ mod portal_session;
mod portal_shortcuts;
mod presentation;
mod presentation_policy;
mod priority;
mod private_client;
mod process;
mod reload;
@@ -49,6 +52,7 @@ mod winit;
mod xwayland;
pub(crate) use session_environment::{SessionEnvironment, SessionPolicy, X11Environment};
pub use state::{Ferese, RuntimeConfig};
use std::error::Error;
use std::io;
@@ -57,17 +61,32 @@ use calloop::signals::{Signal, Signals};
use process::{spawn_client, terminate_child, watch_client_exit};
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_server::Display;
pub use state::{Ferese, RuntimeConfig};
use tracing::{info, warn};
use crate::backends::LaunchConfig;
fn configuration_schema() -> serde_json::Value {
let mut schema =
serde_json::to_value(schemars::schema_for!(config::Config)).expect("serializable schema");
let mut defaults =
serde_json::to_value(config::Config::default()).expect("serializable defaults");
// None means the implicit default panel, not an empty desktop. Present its
// effective value so the complete editor can modify it directly.
defaults["panels"] = serde_json::json!([ferese_panel::Panel::default()]);
schema["properties"]["panels"]["default"] = defaults["panels"].clone();
serde_json::json!({"version":1,"schema":schema,"defaults":defaults})
}
fn main() -> Result<(), Box<dyn Error>> {
init_logging();
// Settings validates a candidate before atomically replacing the user's file.
// This mode never opens a display, input device, or compositor socket.
let args = std::env::args_os().skip(1).collect::<Vec<_>>();
if args == ["--config-schema"] || args == ["--config-schema", "--backend=schema-query"] {
println!("{}", configuration_schema());
return Ok(());
}
if args.first().is_some_and(|arg| arg == "--check-config") {
if args.len() != 2 {
return Err("usage: ferese --check-config PATH".into());
@@ -78,8 +97,11 @@ fn main() -> Result<(), Box<dyn Error>> {
return Ok(());
}
let launch =
LaunchConfig::from_environment().map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
let launch = LaunchConfig::from_environment()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
if launch.backend == backends::BackendKind::Drm {
priority::init();
}
let config_path = config::config_path();
let startup = config::startup(config_path.as_deref())?;
if let Some(error) = &startup.error {
@@ -118,7 +140,9 @@ fn main() -> Result<(), Box<dyn Error>> {
value
});
if let Some(error) = startup.error {
state.theme_engine.startup_failed(config_path.as_deref(), error);
state
.theme_engine
.startup_failed(config_path.as_deref(), error);
}
theme::init(&mut event_loop, &mut state, startup.theme)?;
let _resume_monitor = resume::init(&mut event_loop)?;
@@ -199,8 +223,15 @@ fn after_dispatch(state: &mut Ferese) {
});
}
fn after_dispatch_with_redraw(state: &mut Ferese, mut redraw: impl FnMut(&mut Ferese, RedrawRequest<'_>)) {
fn after_dispatch_with_redraw(
state: &mut Ferese,
mut redraw: impl FnMut(&mut Ferese, RedrawRequest<'_>),
) {
crate::backends::direct::gamma::sync_night_light(state);
state.poll_window_interaction();
state.process_pending_dmabuf_imports();
state.prune_screencopies();
state.refresh_capture_sessions();
state.update_named_cursor(std::time::Instant::now());
state.poll_theme();
if state.session_lock.active() || state.input_capture.active() {
@@ -210,7 +241,9 @@ fn after_dispatch_with_redraw(state: &mut Ferese, mut redraw: impl FnMut(&mut Fe
// when no output has a pending frame.
let wallpaper_changed = state.wallpaper.poll();
// All input/Wayland callbacks have returned, releasing seat locks.
if state.focus_cycle.is_some() && (state.session_lock.active() || state.input_capture.captures(1)) {
if state.focus_cycle.is_some()
&& (state.session_lock.active() || state.input_capture.captures(1))
{
state.cancel_focus_cycle();
}
@@ -241,6 +274,7 @@ fn after_dispatch_with_redraw(state: &mut Ferese, mut redraw: impl FnMut(&mut Fe
warn!(%error, "could not schedule wallpaper upload retry");
}
crate::backends::direct::feedback::refresh(state);
state.publish_ipc_events();
// Clients need registry/sync/configure replies even when no frame is drawn.
+18 -5
View File
@@ -108,8 +108,14 @@ impl RenderMetrics {
.unwrap_or(0);
self.frame_times.make_contiguous().sort_unstable();
let percentile = |percent: usize| {
let index = (self.frame_times.len() * percent).div_ceil(100).saturating_sub(1);
self.frame_times.get(index).copied().unwrap_or_default().as_micros()
let index = (self.frame_times.len() * percent)
.div_ceil(100)
.saturating_sub(1);
self.frame_times
.get(index)
.copied()
.unwrap_or_default()
.as_micros()
};
tracing::info!(
target: "ferese::render",
@@ -145,7 +151,8 @@ impl RenderMetrics {
}
fn rectangle_area(rectangle: &Rectangle<i32, Physical>) -> u64 {
u64::try_from(rectangle.size.w.max(0)).unwrap_or(0) * u64::try_from(rectangle.size.h.max(0)).unwrap_or(0)
u64::try_from(rectangle.size.w.max(0)).unwrap_or(0)
* u64::try_from(rectangle.size.h.max(0)).unwrap_or(0)
}
#[cfg(test)]
@@ -240,8 +247,14 @@ mod tests {
for micros in 0..600 {
metrics.record_frame(Duration::from_micros(micros), &[], 0, FrameEffectMetrics);
}
assert_eq!(metrics.frame_times.front(), Some(&Duration::from_micros(88)));
assert_eq!(metrics.frame_times.back(), Some(&Duration::from_micros(599)));
assert_eq!(
metrics.frame_times.front(),
Some(&Duration::from_micros(88))
);
assert_eq!(
metrics.frame_times.back(),
Some(&Duration::from_micros(599))
);
assert!(
metrics
.frame_times
Loaded 100 of 881 files, more files were not shown because too many files have changed in this diff. Show more