Compare commits
87
Commits
eb84104940
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
326f237262 | ||
|
|
a9f212237f | ||
|
|
1bacbb5d75 | ||
|
|
993bc0f88f | ||
|
|
a2be421974 | ||
|
|
78ce1ed590 | ||
|
|
81b99709bb | ||
|
|
6671960d5e | ||
|
|
326465952a | ||
|
|
9908dde9f8 | ||
|
|
d7e122711a | ||
|
|
1e34ec47ad | ||
|
|
cc3b6f903f | ||
|
|
6f34fd080f | ||
|
|
8e9a5838ab | ||
|
|
f21167e1a2 | ||
|
|
e03d491353 | ||
|
|
3d08d64c41 | ||
|
|
392e9cfeb0 | ||
|
|
979afbd213 | ||
|
|
b66b19e4d9 | ||
|
|
ad00e55fe4 | ||
|
|
ab11269246 | ||
|
|
e64ab52190 | ||
|
|
417f78aa94 | ||
|
|
f5fff6d930 | ||
|
|
ad1207b417 | ||
|
|
6509cb714a | ||
|
|
910f7cc188 | ||
|
|
e9f36efdb6 | ||
|
|
835aadc3eb | ||
|
|
533cd74c94 | ||
|
|
f49f62583d | ||
|
|
a8ef3967a1 | ||
|
|
df36d1c9b0 | ||
|
|
2248ee6846 | ||
|
|
d18f0fd8be | ||
|
|
8bc82a58a2 | ||
|
|
4f78cca71e | ||
|
|
1a484adead | ||
|
|
1b51ba83b4 | ||
|
|
34f2f07301 | ||
|
|
f2e728b503 | ||
|
|
a7fcd69897 | ||
|
|
23e280a6b4 | ||
|
|
1b4db0e681 | ||
|
|
610478f8c7 | ||
|
|
4661f595f8 | ||
|
|
97bbf1b866 | ||
|
|
90879145b2 | ||
|
|
17df20089b | ||
|
|
de6e66460d | ||
|
|
4fb383eaa3 | ||
|
|
afd46c0ea5 | ||
|
|
2c9bb85f2c | ||
|
|
53ba599918 | ||
|
|
90564e6564 | ||
|
|
63a3e41400 | ||
|
|
0be35b7f8a | ||
|
|
4bf556dedc | ||
|
|
8f1efd37f0 | ||
|
|
cc38436bce | ||
|
|
f228a08ff9 | ||
|
|
77fa25e5f7 | ||
|
|
a62f1367bd | ||
|
|
c8dfb3074d | ||
|
|
9251522a69 | ||
|
|
b5f7793870 | ||
|
|
f449c19099 | ||
|
|
d73d5f08f9 | ||
|
|
0589f5685b | ||
|
|
e980bb2e43 | ||
|
|
216289d66c | ||
|
|
a5e8ba65e2 | ||
|
|
07a8e50f47 | ||
|
|
bbc2f226d8 | ||
|
|
4d1d1af413 | ||
|
|
d8bdb68b2a | ||
|
|
2a400b26d0 | ||
|
|
9163374cdb | ||
|
|
f0620e317b | ||
|
|
305f49a62f | ||
|
|
f7e5efb245 | ||
|
|
5d9673aad3 | ||
|
|
8007f7a107 | ||
|
|
f57c68454a | ||
|
|
b552288dc1 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
/target/
|
||||
/.artifacts/
|
||||
/tools/
|
||||
*.log
|
||||
|
||||
|
||||
Generated
+1244
-366
File diff suppressed because it is too large.
Load diff
+25
-6
@@ -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" }
|
||||
@@ -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.
|
||||
|
||||

|
||||
**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
|
||||

|
||||
|
||||
- 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.
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Activity deadlines share CLOCK_BOOTTIME with the published countdowns. A
|
||||
//! monotonic calloop timer would otherwise keep waiting for time spent asleep.
|
||||
use smithay::reexports::calloop::generic::Generic;
|
||||
use smithay::reexports::calloop::{Interest, LoopHandle, Mode, PostAction, RegistrationToken};
|
||||
use std::io;
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||||
|
||||
pub(crate) struct Deadline {
|
||||
fd: OwnedFd,
|
||||
armed: std::cell::Cell<Option<u64>>,
|
||||
handle: LoopHandle<'static, crate::Ferese>,
|
||||
token: RegistrationToken,
|
||||
}
|
||||
impl Deadline {
|
||||
pub fn new(
|
||||
handle: LoopHandle<'static, crate::Ferese>,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
// SAFETY: timerfd_create returns a new owned descriptor or -1.
|
||||
let fd = unsafe {
|
||||
libc::timerfd_create(libc::CLOCK_BOOTTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error().into());
|
||||
}
|
||||
// SAFETY: timerfd_create succeeded above; this fresh descriptor transfers to one OwnedFd.
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
|
||||
let token = handle.insert_source(
|
||||
Generic::new(fd.try_clone()?, Interest::READ, Mode::Level),
|
||||
|_, fd, state| {
|
||||
let mut expirations = 0_u64;
|
||||
// SAFETY: expirations is a writable eight-byte timerfd counter.
|
||||
let result =
|
||||
unsafe { libc::read(fd.as_raw_fd(), (&raw mut expirations).cast(), 8) };
|
||||
if result < 0 {
|
||||
let error = io::Error::last_os_error();
|
||||
if error.kind() != io::ErrorKind::WouldBlock {
|
||||
return Err(error);
|
||||
}
|
||||
} else {
|
||||
state.activities.tick();
|
||||
}
|
||||
Ok(PostAction::Continue)
|
||||
},
|
||||
)?;
|
||||
Ok(Self {
|
||||
fd,
|
||||
armed: std::cell::Cell::new(None),
|
||||
handle,
|
||||
token,
|
||||
})
|
||||
}
|
||||
pub fn update(&self, deadline: Option<u64>) -> io::Result<()> {
|
||||
if self.armed.get() == deadline {
|
||||
return Ok(());
|
||||
}
|
||||
let ms = deadline.unwrap_or(0);
|
||||
let spec = libc::itimerspec {
|
||||
it_interval: libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
},
|
||||
it_value: libc::timespec {
|
||||
tv_sec: (ms / 1_000) as _,
|
||||
tv_nsec: ((ms % 1_000) * 1_000_000) as _,
|
||||
},
|
||||
};
|
||||
// SAFETY: spec is initialized; null old_value is permitted.
|
||||
if unsafe {
|
||||
libc::timerfd_settime(
|
||||
self.fd.as_raw_fd(),
|
||||
libc::TFD_TIMER_ABSTIME,
|
||||
&spec,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
} < 0
|
||||
{
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
self.armed.set(deadline);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Deadline {
|
||||
fn drop(&mut self) {
|
||||
self.handle.remove(self.token);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn deadline_uses_boottime_and_disarming_leaves_no_periodic_wakeups() {
|
||||
let events = smithay::reexports::calloop::EventLoop::<crate::Ferese>::try_new().unwrap();
|
||||
let deadline = Deadline::new(events.handle()).unwrap();
|
||||
let info =
|
||||
std::fs::read_to_string(format!("/proc/self/fdinfo/{}", deadline.fd.as_raw_fd()))
|
||||
.unwrap();
|
||||
assert!(
|
||||
info.lines()
|
||||
.any(|line| line.split_whitespace().collect::<Vec<_>>() == ["clockid:", "7"]),
|
||||
"{info}"
|
||||
);
|
||||
deadline
|
||||
.update(Some(ferese_ipc::activities::now_ms() + 20))
|
||||
.unwrap();
|
||||
let mut pollfd = libc::pollfd {
|
||||
fd: deadline.fd.as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
// SAFETY: pollfd is a valid array of one descriptor.
|
||||
assert_eq!(unsafe { libc::poll(&raw mut pollfd, 1, 500) }, 1);
|
||||
let mut counter = 0_u64;
|
||||
assert_eq!(
|
||||
// SAFETY: counter is writable for the requested eight bytes and deadline owns the
|
||||
// timer descriptor.
|
||||
unsafe { libc::read(deadline.fd.as_raw_fd(), (&raw mut counter).cast(), 8) },
|
||||
8
|
||||
);
|
||||
assert_eq!(counter, 1);
|
||||
deadline.update(None).unwrap();
|
||||
// SAFETY: pollfd is a live writable pollfd and the entry count is one.
|
||||
assert_eq!(unsafe { libc::poll(&raw mut pollfd, 1, 0) }, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
use super::{Data, Duration, Engine, Id, Source, State, Template, Value};
|
||||
#[cfg(test)]
|
||||
use ferese_activities::Owner;
|
||||
|
||||
use ferese_activities::pomodoro::{Session, Status};
|
||||
use ferese_ipc::activities::{PomodoroAction, PomodoroRequest};
|
||||
|
||||
pub(super) struct Timer {
|
||||
pub id: Id,
|
||||
pub session: Session,
|
||||
output: Option<String>,
|
||||
created_at: u64,
|
||||
}
|
||||
impl Timer {
|
||||
pub fn activity(&self) -> ferese_activities::Activity {
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
let value = if let Some(deadline) = self.session.deadline {
|
||||
Value::Countdown {
|
||||
remaining_ms: deadline.saturating_sub(now),
|
||||
sampled_at: now,
|
||||
}
|
||||
} else {
|
||||
Value::PausedCountdown {
|
||||
remaining_ms: self.session.remaining_ms,
|
||||
}
|
||||
};
|
||||
ferese_activities::Activity {
|
||||
id: self.id.clone(),
|
||||
data: Data {
|
||||
source: Source::Timer,
|
||||
output: self.output.clone(),
|
||||
priority: 100,
|
||||
icon: "timer".into(),
|
||||
title: format!(
|
||||
"Pomodoro · {}{}",
|
||||
self.session.phase.label(),
|
||||
match self.session.status {
|
||||
Status::Paused => " · Paused",
|
||||
Status::Ready => " · Ready",
|
||||
Status::Finished => " · Finished",
|
||||
_ => "",
|
||||
}
|
||||
),
|
||||
value,
|
||||
template: Template::Timer,
|
||||
},
|
||||
state: State::Running,
|
||||
created_at: self.created_at,
|
||||
eligible_at: self.created_at,
|
||||
refreshed_at: self.created_at,
|
||||
lease_deadline: None,
|
||||
result_deadline: None,
|
||||
result_message: None,
|
||||
flash_on_fail: false,
|
||||
result_flash_ms: 0,
|
||||
group: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Engine {
|
||||
pub fn pomodoro_control(
|
||||
&mut self,
|
||||
request: PomodoroRequest,
|
||||
output: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
if request.version != ferese_ipc::activities::VERSION {
|
||||
return Err("Unsupported activity schema".into());
|
||||
}
|
||||
if let Some(id) = &request.session
|
||||
&& self.pomodoro.as_ref().is_none_or(|timer| &timer.id != id)
|
||||
{
|
||||
return Err("Pomodoro session changed; refresh its controls".into());
|
||||
}
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
if !matches!(request.action, PomodoroAction::Stop | PomodoroAction::Reset)
|
||||
&& self.advance_pomodoro(now)
|
||||
{
|
||||
self.changed();
|
||||
}
|
||||
match request.action {
|
||||
PomodoroAction::Status => {}
|
||||
PomodoroAction::Start | PomodoroAction::Reset => {
|
||||
if request.action == PomodoroAction::Start
|
||||
&& self
|
||||
.pomodoro
|
||||
.as_ref()
|
||||
.is_some_and(|timer| timer.session.status != Status::Finished)
|
||||
{
|
||||
return Err("A Pomodoro session already exists; resume or reset it".into());
|
||||
}
|
||||
let session = Session::start(self.pomodoro_settings.clone(), now)?;
|
||||
// Verify the long-lived host, never the short-lived CLI, as owner.
|
||||
let (owner, _pidfd, _) = crate::ipc::activity_owner::inspect(std::process::id())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let id = Id {
|
||||
owner,
|
||||
local: "pomodoro".into(),
|
||||
generation: self.revision,
|
||||
session: self.store.session(),
|
||||
};
|
||||
self.pomodoro = Some(Timer {
|
||||
id,
|
||||
session,
|
||||
output,
|
||||
created_at: now,
|
||||
});
|
||||
}
|
||||
PomodoroAction::Stop => {
|
||||
self.pomodoro = None;
|
||||
}
|
||||
action => {
|
||||
let timer = self
|
||||
.pomodoro
|
||||
.as_mut()
|
||||
.ok_or("No Pomodoro session; start one first")?;
|
||||
match action {
|
||||
PomodoroAction::Pause => timer.session.pause(now)?,
|
||||
PomodoroAction::Resume => timer.session.resume(now)?,
|
||||
PomodoroAction::Next => timer.session.next(now)?,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
if request.action != PomodoroAction::Status {
|
||||
self.changed();
|
||||
}
|
||||
let snapshot = self.snapshot();
|
||||
serde_json::to_value(&snapshot.pomodoro).map_err(|error| error.to_string())
|
||||
}
|
||||
pub(super) fn advance_pomodoro(&mut self, now: u64) -> bool {
|
||||
let Some(timer) = &mut self.pomodoro else {
|
||||
return false;
|
||||
};
|
||||
let Some(completion) = timer.session.advance(now) else {
|
||||
return false;
|
||||
};
|
||||
if timer.session.settings.notifications {
|
||||
let title = format!("Pomodoro: {} complete", completion.phase.label());
|
||||
let body = completion.next.map_or_else(
|
||||
|| "Your Pomodoro session is finished.".into(),
|
||||
|phase| {
|
||||
format!(
|
||||
"{} {}.",
|
||||
phase.label(),
|
||||
if timer.session.status == Status::Running {
|
||||
"has started"
|
||||
} else {
|
||||
"is ready"
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
let alerts = self.alerts.get_or_insert_with(Alerts::new);
|
||||
if alerts.sender.try_send((title, body)).is_err() {
|
||||
tracing::warn!("Pomodoro alert queue unavailable");
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
pub fn pomodoro_remove_output(&mut self, output: &str, fallback: Option<&str>) {
|
||||
if let Some(timer) = &mut self.pomodoro
|
||||
&& timer.output.as_deref() == Some(output)
|
||||
{
|
||||
timer.output = fallback.map(str::to_owned);
|
||||
}
|
||||
}
|
||||
pub fn pomodoro_attach_output(&mut self, output: &str) -> bool {
|
||||
if let Some(timer) = &mut self.pomodoro
|
||||
&& timer.output.is_none()
|
||||
{
|
||||
timer.output = Some(output.into());
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Notification delivery runs off the compositor thread and is attempted once.
|
||||
/// Reconnection never replays an alert; the notification server applies DND and
|
||||
/// lock-screen privacy, just as it does for other applications.
|
||||
pub(super) struct Alerts {
|
||||
sender: std::sync::mpsc::SyncSender<(String, String)>,
|
||||
}
|
||||
impl Alerts {
|
||||
fn new() -> Self {
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel::<(String, String)>(16);
|
||||
std::thread::spawn(move || {
|
||||
while let Ok((title, body)) = receiver.recv() {
|
||||
let result = (|| -> zbus::Result<()> {
|
||||
let connection = zbus::blocking::connection::Builder::session()?
|
||||
.method_timeout(Duration::from_secs(2))
|
||||
.build()?;
|
||||
let mut hints =
|
||||
std::collections::HashMap::<&str, zbus::zvariant::Value<'_>>::new();
|
||||
hints.insert("suppress-sound", zbus::zvariant::Value::Bool(true));
|
||||
let reply = connection.call_method(
|
||||
Some("org.freedesktop.Notifications"),
|
||||
"/org/freedesktop/Notifications",
|
||||
Some("org.freedesktop.Notifications"),
|
||||
"Notify",
|
||||
&(
|
||||
"Ferese",
|
||||
0_u32,
|
||||
"",
|
||||
title,
|
||||
body,
|
||||
Vec::<String>::new(),
|
||||
hints,
|
||||
-1_i32,
|
||||
),
|
||||
)?;
|
||||
let _: u32 = reply.body().deserialize()?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(%error,"Pomodoro completion alert could not be delivered");
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { sender }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn control(engine: &mut Engine, action: PomodoroAction) -> Result<serde_json::Value, String> {
|
||||
engine.pomodoro_control(
|
||||
PomodoroRequest {
|
||||
version: ferese_ipc::activities::VERSION,
|
||||
action,
|
||||
session: None,
|
||||
},
|
||||
Some("test".into()),
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn completion_uses_the_standard_notification_service_without_sound() {
|
||||
const TEST: &str = "activities::pomodoro::tests::completion_uses_the_standard_notification_service_without_sound";
|
||||
if !crate::startup_tests::private_runtime(TEST) {
|
||||
return;
|
||||
}
|
||||
use std::io::BufRead;
|
||||
struct Bus(std::process::Child);
|
||||
impl Drop for Bus {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
let mut bus = Bus(std::process::Command::new("dbus-daemon")
|
||||
.args(["--session", "--nofork", "--print-address=1"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap());
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(bus.0.stdout.take().unwrap())
|
||||
.read_line(&mut address)
|
||||
.unwrap();
|
||||
// SAFETY: private_runtime runs only this test in a separate process;
|
||||
// set the bus before starting the notification service or alert worker.
|
||||
unsafe {
|
||||
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", address.trim());
|
||||
}
|
||||
struct Notifications(std::sync::mpsc::Sender<(String, String, bool)>);
|
||||
#[zbus::interface(name = "org.freedesktop.Notifications")]
|
||||
impl Notifications {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn notify(
|
||||
&self,
|
||||
_app_name: &str,
|
||||
_replaces_id: u32,
|
||||
_app_icon: &str,
|
||||
summary: &str,
|
||||
body: &str,
|
||||
_actions: Vec<String>,
|
||||
hints: std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
_expire_timeout: i32,
|
||||
) -> u32 {
|
||||
let silent = hints
|
||||
.get("suppress-sound")
|
||||
.and_then(|value| bool::try_from(value).ok())
|
||||
.unwrap_or(false);
|
||||
self.0.send((summary.into(), body.into(), silent)).unwrap();
|
||||
1
|
||||
}
|
||||
}
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let _service = zbus::blocking::connection::Builder::address(address.trim())
|
||||
.unwrap()
|
||||
.serve_at("/org/freedesktop/Notifications", Notifications(sender))
|
||||
.unwrap()
|
||||
.name("org.freedesktop.Notifications")
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
let mut engine = Engine::default();
|
||||
control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
let deadline = engine.pomodoro.as_ref().unwrap().session.deadline.unwrap();
|
||||
engine.advance_pomodoro(deadline);
|
||||
let (title, body, silent) = receiver.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
assert_eq!(title, "Pomodoro: Work complete");
|
||||
assert_eq!(body, "Short break is ready.");
|
||||
assert!(silent);
|
||||
engine.advance_pomodoro(deadline + 1);
|
||||
assert!(receiver.try_recv().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn host_owns_the_single_session_and_stale_popup_controls_are_rejected() {
|
||||
let mut engine = Engine::default();
|
||||
let first = control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
let first: ferese_ipc::activities::Pomodoro = serde_json::from_value(first).unwrap();
|
||||
assert_eq!(first.id.owner.pid, std::process::id());
|
||||
assert!(control(&mut engine, PomodoroAction::Start).is_err());
|
||||
engine.remove_connection(10);
|
||||
engine.owner_exit(Owner {
|
||||
pid: 999_999,
|
||||
birth: 0,
|
||||
});
|
||||
assert_eq!(engine.snapshot().pomodoro.as_ref().unwrap().id, first.id);
|
||||
assert_eq!(engine.snapshot().activities.len(), 1);
|
||||
control(&mut engine, PomodoroAction::Reset).unwrap();
|
||||
assert_ne!(engine.snapshot().pomodoro.as_ref().unwrap().id, first.id);
|
||||
assert!(
|
||||
engine
|
||||
.pomodoro_control(
|
||||
PomodoroRequest {
|
||||
version: ferese_ipc::activities::VERSION,
|
||||
action: PomodoroAction::Stop,
|
||||
session: Some(first.id)
|
||||
},
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
control(&mut engine, PomodoroAction::Stop).unwrap();
|
||||
assert!(engine.snapshot().pomodoro.is_none());
|
||||
assert!(engine.snapshot().activities.is_empty());
|
||||
assert!(Engine::default().snapshot().pomodoro.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn completions_are_delivered_once_and_cancellation_does_not_alert() {
|
||||
let mut engine = Engine::default();
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel(16);
|
||||
engine.alerts = Some(Alerts { sender });
|
||||
control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
let deadline = engine.pomodoro.as_ref().unwrap().session.deadline.unwrap();
|
||||
assert!(engine.advance_pomodoro(deadline));
|
||||
assert!(!engine.advance_pomodoro(deadline + 1));
|
||||
let (title, body) = receiver.try_recv().unwrap();
|
||||
assert_eq!(title, "Pomodoro: Work complete");
|
||||
assert_eq!(body, "Short break is ready.");
|
||||
assert!(receiver.try_recv().is_err());
|
||||
control(&mut engine, PomodoroAction::Reset).unwrap();
|
||||
engine.pomodoro.as_mut().unwrap().session.deadline = Some(1);
|
||||
control(&mut engine, PomodoroAction::Stop).unwrap();
|
||||
assert!(receiver.try_recv().is_err());
|
||||
control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
engine.pomodoro.as_mut().unwrap().session.deadline = Some(1);
|
||||
control(&mut engine, PomodoroAction::Reset).unwrap();
|
||||
assert!(receiver.try_recv().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn paused_snapshot_is_constant_and_output_loss_routes_the_timer() {
|
||||
let mut engine = Engine::default();
|
||||
control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
control(&mut engine, PomodoroAction::Pause).unwrap();
|
||||
let snapshot = engine.snapshot();
|
||||
let activity = &snapshot.activities[0];
|
||||
assert!(activity.data.title.contains("Paused"));
|
||||
assert_eq!(
|
||||
activity.data.value.remaining_ms(0),
|
||||
activity.data.value.remaining_ms(u64::MAX)
|
||||
);
|
||||
engine.pomodoro_remove_output("test", None);
|
||||
assert!(engine.pomodoro_attach_output("new"));
|
||||
engine.changed();
|
||||
assert_eq!(
|
||||
engine.snapshot().activities[0].data.output.as_deref(),
|
||||
Some("new")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn disabled_alerts_and_schema_errors_have_no_side_effects() {
|
||||
let mut engine = Engine::default();
|
||||
engine.pomodoro_settings.notifications = false;
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
engine.alerts = Some(Alerts { sender });
|
||||
control(&mut engine, PomodoroAction::Start).unwrap();
|
||||
assert!(
|
||||
engine
|
||||
.pomodoro_control(
|
||||
PomodoroRequest {
|
||||
version: 99,
|
||||
action: PomodoroAction::Stop,
|
||||
session: None
|
||||
},
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let deadline = engine.pomodoro.as_ref().unwrap().session.deadline.unwrap();
|
||||
engine.advance_pomodoro(deadline);
|
||||
assert!(receiver.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
+1005
-190
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
//! Conservative output feedback for the existing primary-plane policy.
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use smithay::backend::allocator::Format;
|
||||
use smithay::backend::allocator::format::FormatSet;
|
||||
use smithay::backend::renderer::element::{
|
||||
Element, Id, RenderElement, RenderElementPresentationState, RenderElementStates, RenderingReason,
|
||||
};
|
||||
use smithay::backend::renderer::gles::GlesRenderer;
|
||||
use smithay::backend::renderer::utils::with_renderer_surface_state;
|
||||
use smithay::output::Output;
|
||||
use smithay::reexports::wayland_protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_feedback_v1::TrancheFlags;
|
||||
use smithay::utils::{Physical, Rectangle, Scale};
|
||||
use smithay::wayland::dmabuf::{DmabufFeedback, DmabufFeedbackBuilder, DmabufGlobal};
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::render::AnimatedWindowRenderElement;
|
||||
|
||||
fn compatible(render: &FormatSet, primary: &FormatSet, observed: Format) -> bool {
|
||||
// Do not equate implicit and explicit modifiers, alpha and opaque FourCCs,
|
||||
// or formats from cursor/overlay planes. Exact intersection preserves import.
|
||||
render.contains(&observed) && primary.contains(&observed)
|
||||
}
|
||||
|
||||
pub(super) struct OutputFeedback {
|
||||
render_device: libc::dev_t,
|
||||
scanout_device: libc::dev_t,
|
||||
render_formats: FormatSet,
|
||||
primary_formats: FormatSet,
|
||||
observed: Option<Format>,
|
||||
preferred: Option<DmabufFeedback>,
|
||||
visible: HashSet<Id>,
|
||||
candidates: HashSet<Id>,
|
||||
}
|
||||
|
||||
impl OutputFeedback {
|
||||
pub fn new(
|
||||
render_device: libc::dev_t,
|
||||
scanout_device: libc::dev_t,
|
||||
render_formats: FormatSet,
|
||||
primary_formats: FormatSet,
|
||||
) -> Self {
|
||||
Self {
|
||||
render_device,
|
||||
scanout_device,
|
||||
render_formats,
|
||||
primary_formats,
|
||||
observed: None,
|
||||
preferred: None,
|
||||
visible: HashSet::new(),
|
||||
candidates: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalidate(&mut self) {
|
||||
self.observed = None;
|
||||
self.preferred = None;
|
||||
self.visible.clear();
|
||||
self.candidates.clear();
|
||||
}
|
||||
|
||||
fn observe_format(&mut self, format: Option<Format>) {
|
||||
let Some(format) = format else {
|
||||
return;
|
||||
};
|
||||
if self.observed == Some(format) {
|
||||
return;
|
||||
}
|
||||
self.observed = Some(format);
|
||||
self.preferred = None;
|
||||
if compatible(&self.render_formats, &self.primary_formats, format) {
|
||||
// Restrict the preference to the observed primary format/modifier:
|
||||
// without ALLOW_PRIMARY_PLANE_SCANOUT_ANY Smithay compares the full
|
||||
// format with the swapchain. Renderer-only formats remain in fallback.
|
||||
match DmabufFeedbackBuilder::new(
|
||||
self.render_device,
|
||||
self.render_formats.iter().copied(),
|
||||
)
|
||||
.add_preference_tranche(self.scanout_device, TrancheFlags::Scanout, [format], 4..=6)
|
||||
.build()
|
||||
{
|
||||
Ok(feedback) => self.preferred = Some(feedback),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "output DMA-BUF feedback unavailable; retaining render fallback")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn observed_frame(
|
||||
&mut self,
|
||||
format: Option<Format>,
|
||||
states: &RenderElementStates,
|
||||
candidates: HashSet<Id>,
|
||||
) {
|
||||
self.observe_format(format);
|
||||
self.visible = states
|
||||
.states
|
||||
.iter()
|
||||
.filter(|(_, state)| crate::display_presentation::is_visible(**state))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
self.candidates = candidates
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
states.states.get(id).is_some_and(|state| {
|
||||
crate::display_presentation::is_visible(*state)
|
||||
&& state.presentation_state
|
||||
!= RenderElementPresentationState::Rendering {
|
||||
reason: Some(RenderingReason::ScanoutFailed),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn candidates(
|
||||
renderer: &mut GlesRenderer,
|
||||
elements: &[AnimatedWindowRenderElement],
|
||||
output: &Output,
|
||||
allowed: bool,
|
||||
) -> HashSet<Id> {
|
||||
if !allowed {
|
||||
return HashSet::new();
|
||||
}
|
||||
let Some(mode) = output.current_mode() else {
|
||||
return HashSet::new();
|
||||
};
|
||||
let bounds = Rectangle::from_size(output.current_transform().transform_size(mode.size));
|
||||
let scale = output.current_scale().fractional_scale().into();
|
||||
elements
|
||||
.iter()
|
||||
.filter(|&element| {
|
||||
candidate_geometry(element, bounds, scale, || {
|
||||
element.underlying_storage(renderer).is_some()
|
||||
})
|
||||
})
|
||||
.map(|element| element.id().clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn candidate_geometry(
|
||||
element: &impl Element,
|
||||
bounds: Rectangle<i32, Physical>,
|
||||
scale: Scale<f64>,
|
||||
has_storage: impl FnOnce() -> bool,
|
||||
) -> bool {
|
||||
let geometry = element.geometry(scale);
|
||||
if element.alpha() != 1.0 || !geometry.contains_rect(bounds) {
|
||||
return false;
|
||||
}
|
||||
// A single opaque rectangle covering the output is deliberately more
|
||||
// conservative than reconstructing an arbitrary union of client regions.
|
||||
element
|
||||
.opaque_regions(scale)
|
||||
.iter()
|
||||
.any(|region| Rectangle::new(region.loc + geometry.loc, region.size).contains_rect(bounds))
|
||||
&& has_storage()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct OutputView<'a> {
|
||||
global: DmabufGlobal,
|
||||
feedback: &'a OutputFeedback,
|
||||
eligible: Option<Id>,
|
||||
}
|
||||
|
||||
fn select<'a>(
|
||||
global: DmabufGlobal,
|
||||
surface: &Id,
|
||||
outputs: impl IntoIterator<Item = OutputView<'a>>,
|
||||
) -> Option<&'a DmabufFeedback> {
|
||||
let mut visible = outputs
|
||||
.into_iter()
|
||||
.filter(|output| output.feedback.visible.contains(surface));
|
||||
let output = visible.next()?;
|
||||
// A surface visible on multiple outputs gets render fallback rather than
|
||||
// whichever output happened to render last. Cross-GPU import is not assumed.
|
||||
if visible.next().is_some()
|
||||
|| output.global != global
|
||||
|| output.eligible.as_ref() != Some(surface)
|
||||
|| !output.feedback.candidates.contains(surface)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
output.feedback.preferred.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn refresh(state: &mut Ferese) {
|
||||
if state.dmabuf_feedback.requested().next().is_none() {
|
||||
return;
|
||||
}
|
||||
let mut preferred = HashMap::new();
|
||||
if let Some(backend) = state.direct_backend.as_ref() {
|
||||
// Eligibility belongs to an output, so compute it once for all subscribers.
|
||||
let outputs = backend
|
||||
.devices
|
||||
.values()
|
||||
.flat_map(|device| device.outputs.values().map(move |output| (device, output)))
|
||||
.filter(|(_, output)| state.output_id(&output.output).is_some());
|
||||
let views: Vec<_> = outputs
|
||||
.map(|(device, output)| {
|
||||
let eligible = (backend.can_render()
|
||||
&& device.drm.is_active()
|
||||
&& output.power.can_render()
|
||||
&& output.mirror_source.is_none()
|
||||
&& !state.session_lock.active()
|
||||
&& !state.output_has_animations(&output.output)
|
||||
&& state.output_has_fullscreen(&output.output))
|
||||
.then(|| {
|
||||
state
|
||||
.output_id(&output.output)
|
||||
.and_then(|id| state.output_workspaces.active_workspace(id))
|
||||
.and_then(|workspace| state.workspaces.workspace(workspace))
|
||||
.and_then(|workspace| workspace.fullscreen)
|
||||
.filter(|id| {
|
||||
state
|
||||
.current_window_presentation(*id)
|
||||
.is_some_and(|presentation| {
|
||||
presentation.alpha() == 1.0 && presentation.dim == 0.0
|
||||
})
|
||||
})
|
||||
.and_then(|id| state.windows.window(id))
|
||||
.and_then(|window| window.toplevel())
|
||||
.filter(|toplevel| {
|
||||
with_renderer_surface_state(toplevel.wl_surface(), |state| {
|
||||
state.buffer().is_some()
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|toplevel| Id::from(toplevel.wl_surface()))
|
||||
})
|
||||
.flatten();
|
||||
OutputView {
|
||||
global: device.dmabuf_global,
|
||||
feedback: &output.dmabuf_feedback,
|
||||
eligible,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if views.iter().any(|view| view.eligible.is_some()) {
|
||||
for (surface, global) in state.dmabuf_feedback.requested() {
|
||||
let id = Id::from(surface);
|
||||
if let Some(feedback) = select(global, &id, views.iter().cloned()) {
|
||||
preferred.insert(surface.clone(), feedback.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
state.dmabuf_feedback.refresh(&preferred);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use smithay::backend::allocator::{Fourcc, Modifier};
|
||||
use smithay::backend::renderer::element::{RenderElementPresentationState, RenderElementState};
|
||||
use smithay::reexports::wayland_server::Display;
|
||||
use smithay::wayland::dmabuf::DmabufState;
|
||||
|
||||
fn format(code: Fourcc, modifier: Modifier) -> Format {
|
||||
Format { code, modifier }
|
||||
}
|
||||
fn formats(values: &[Format]) -> FormatSet {
|
||||
values.iter().copied().collect()
|
||||
}
|
||||
fn output(render: &[Format], primary: &[Format]) -> OutputFeedback {
|
||||
OutputFeedback::new(10, 20, formats(render), formats(primary))
|
||||
}
|
||||
fn create_globals() -> (Display<Ferese>, DmabufGlobal, DmabufGlobal) {
|
||||
let display = Display::new().unwrap();
|
||||
let mut state = DmabufState::new();
|
||||
let feedback = DmabufFeedbackBuilder::new(10, [format(Fourcc::Argb8888, Modifier::Linear)])
|
||||
.build()
|
||||
.unwrap();
|
||||
let global =
|
||||
state.create_global_with_default_feedback::<Ferese>(&display.handle(), &feedback);
|
||||
let other =
|
||||
state.create_global_with_default_feedback::<Ferese>(&display.handle(), &feedback);
|
||||
(display, global, other)
|
||||
}
|
||||
fn visible(output: &mut OutputFeedback, id: &Id, f: Format) {
|
||||
let mut states = RenderElementStates::default();
|
||||
states.states.insert(
|
||||
id.clone(),
|
||||
RenderElementState {
|
||||
needs_capture: false,
|
||||
visible_area: 100,
|
||||
presentation_state: RenderElementPresentationState::Rendering { reason: None },
|
||||
},
|
||||
);
|
||||
output.observed_frame(Some(f), &states, HashSet::from([id.clone()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_require_exact_renderer_and_primary_intersection() {
|
||||
let linear = format(Fourcc::Argb8888, Modifier::Linear);
|
||||
let implicit = format(Fourcc::Argb8888, Modifier::Invalid);
|
||||
let opaque = format(Fourcc::Xrgb8888, Modifier::Linear);
|
||||
assert!(compatible(
|
||||
&formats(&[linear, implicit]),
|
||||
&formats(&[linear]),
|
||||
linear
|
||||
));
|
||||
assert!(!compatible(
|
||||
&formats(&[linear]),
|
||||
&formats(&[implicit]),
|
||||
linear
|
||||
));
|
||||
assert!(!compatible(
|
||||
&formats(&[linear]),
|
||||
&formats(&[opaque]),
|
||||
linear
|
||||
));
|
||||
assert!(!compatible(
|
||||
&formats(&[linear]),
|
||||
&formats(&[implicit]),
|
||||
implicit
|
||||
));
|
||||
assert!(!compatible(
|
||||
&formats(&[opaque]),
|
||||
&formats(&[linear]),
|
||||
linear
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observed_format_limits_preference_and_retains_renderer_fallback() {
|
||||
let linear = format(Fourcc::Argb8888, Modifier::Linear);
|
||||
let other = format(Fourcc::Abgr8888, Modifier::Linear);
|
||||
let mut o = output(&[linear, other], &[linear, other]);
|
||||
assert!(o.preferred.is_none());
|
||||
o.observe_format(Some(linear));
|
||||
let expected = DmabufFeedbackBuilder::new(10, [linear, other])
|
||||
.add_preference_tranche(20, TrancheFlags::Scanout, [linear], 4..=6)
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(o.preferred.as_ref(), Some(&expected));
|
||||
o.observe_format(Some(other));
|
||||
assert_ne!(o.preferred.as_ref(), Some(&expected));
|
||||
o.observe_format(Some(format(Fourcc::Rgb565, Modifier::Linear)));
|
||||
assert!(o.preferred.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_multi_output_cross_gpu_and_policy_changes_fall_back() {
|
||||
let (_display, global, other_global) = create_globals();
|
||||
let id = Id::new();
|
||||
let f = format(Fourcc::Argb8888, Modifier::Linear);
|
||||
let mut a = output(&[f], &[f]);
|
||||
let mut b = output(&[f], &[f]);
|
||||
visible(&mut a, &id, f);
|
||||
visible(&mut b, &id, f);
|
||||
fn view(
|
||||
global: DmabufGlobal,
|
||||
feedback: &OutputFeedback,
|
||||
eligible: Option<Id>,
|
||||
) -> OutputView<'_> {
|
||||
OutputView {
|
||||
global,
|
||||
feedback,
|
||||
eligible,
|
||||
}
|
||||
}
|
||||
assert!(select(global, &id, [view(global, &a, Some(id.clone()))]).is_some());
|
||||
assert!(select(global, &id, [view(global, &a, None)]).is_none());
|
||||
assert!(
|
||||
select(
|
||||
global,
|
||||
&id,
|
||||
[
|
||||
view(global, &a, Some(id.clone())),
|
||||
view(global, &b, Some(id.clone()))
|
||||
]
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(select(other_global, &id, [view(global, &a, Some(id.clone()))]).is_none());
|
||||
a.invalidate();
|
||||
assert!(select(global, &id, [view(global, &a, Some(id.clone()))]).is_none());
|
||||
assert!(
|
||||
select(
|
||||
global,
|
||||
&id,
|
||||
[
|
||||
view(global, &a, Some(id.clone())),
|
||||
view(global, &b, Some(id.clone()))
|
||||
]
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
b.candidates.clear();
|
||||
assert!(select(global, &id, [view(global, &b, Some(id.clone()))]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconfiguration_invalidates_observations_until_a_new_frame() {
|
||||
let (_display, global, _) = create_globals();
|
||||
let id = Id::new();
|
||||
let f = format(Fourcc::Argb8888, Modifier::Linear);
|
||||
let mut o = output(&[f], &[f]);
|
||||
visible(&mut o, &id, f);
|
||||
o.invalidate();
|
||||
assert!(
|
||||
o.observed.is_none()
|
||||
&& o.preferred.is_none()
|
||||
&& o.visible.is_empty()
|
||||
&& o.candidates.is_empty()
|
||||
);
|
||||
o.observe_format(None);
|
||||
assert!(o.preferred.is_none());
|
||||
visible(&mut o, &id, f);
|
||||
assert!(
|
||||
select(
|
||||
global,
|
||||
&id,
|
||||
[OutputView {
|
||||
global,
|
||||
feedback: &o,
|
||||
eligible: Some(id.clone())
|
||||
}]
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_geometry_requires_covering_opaque_content_and_storage() {
|
||||
use smithay::backend::renderer::element::Kind;
|
||||
use smithay::backend::renderer::element::solid::SolidColorRenderElement;
|
||||
use smithay::backend::renderer::utils::CommitCounter;
|
||||
use smithay::utils::Transform;
|
||||
for transform in [Transform::Normal, Transform::_90] {
|
||||
let bounds = Rectangle::from_size(transform.transform_size((100, 60).into()));
|
||||
for scale in [1.0, 1.25, 2.0] {
|
||||
let solid = |geometry, alpha| {
|
||||
SolidColorRenderElement::new(
|
||||
Id::new(),
|
||||
geometry,
|
||||
CommitCounter::default(),
|
||||
[1., 1., 1., alpha],
|
||||
Kind::Unspecified,
|
||||
)
|
||||
};
|
||||
let opaque = solid(bounds, 1.0);
|
||||
assert!(candidate_geometry(&opaque, bounds, scale.into(), || true));
|
||||
assert!(
|
||||
!candidate_geometry(&opaque, bounds, scale.into(), || false),
|
||||
"clipped storage must remain hidden"
|
||||
);
|
||||
let faded = solid(bounds, 0.8);
|
||||
assert!(!candidate_geometry(
|
||||
&faded,
|
||||
bounds,
|
||||
scale.into(),
|
||||
|| panic!("faded elements must not probe storage")
|
||||
));
|
||||
let mut partial = bounds;
|
||||
partial.size.w -= 1;
|
||||
assert!(!candidate_geometry(
|
||||
&solid(partial, 1.0),
|
||||
bounds,
|
||||
scale.into(),
|
||||
|| panic!("partial content must not probe storage")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_scanout_and_occlusion_withdraw_preferences_but_format_hints_can_recover() {
|
||||
let (_display, global, _) = create_globals();
|
||||
let id = Id::new();
|
||||
let f = format(Fourcc::Argb8888, Modifier::Linear);
|
||||
let mut o = output(&[f], &[f]);
|
||||
for (state, expected) in [
|
||||
(
|
||||
RenderElementState {
|
||||
needs_capture: false,
|
||||
visible_area: 100,
|
||||
presentation_state: RenderElementPresentationState::Rendering {
|
||||
reason: Some(RenderingReason::FormatUnsupported),
|
||||
},
|
||||
},
|
||||
true,
|
||||
),
|
||||
(
|
||||
RenderElementState {
|
||||
needs_capture: false,
|
||||
visible_area: 100,
|
||||
presentation_state: RenderElementPresentationState::Rendering {
|
||||
reason: Some(RenderingReason::ScanoutFailed),
|
||||
},
|
||||
},
|
||||
false,
|
||||
),
|
||||
(
|
||||
RenderElementState {
|
||||
needs_capture: false,
|
||||
visible_area: 0,
|
||||
presentation_state: RenderElementPresentationState::Skipped,
|
||||
},
|
||||
false,
|
||||
),
|
||||
(
|
||||
RenderElementState {
|
||||
needs_capture: false,
|
||||
visible_area: 100,
|
||||
presentation_state: RenderElementPresentationState::ZeroCopy,
|
||||
},
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let states = RenderElementStates {
|
||||
states: HashMap::from([(id.clone(), state)]),
|
||||
};
|
||||
o.observed_frame(Some(f), &states, HashSet::from([id.clone()]));
|
||||
assert_eq!(
|
||||
select(
|
||||
global,
|
||||
&id,
|
||||
[OutputView {
|
||||
global,
|
||||
feedback: &o,
|
||||
eligible: Some(id.clone())
|
||||
}]
|
||||
)
|
||||
.is_some(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_failure_never_publishes_prepared_feedback_observations() {
|
||||
let f = format(Fourcc::Argb8888, Modifier::Linear);
|
||||
let id = Id::new();
|
||||
let mut o = output(&[f], &[f]);
|
||||
let failed = super::super::capture::after_queue(Err::<(), _>("queue failed"), || {
|
||||
visible(&mut o, &id, f)
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
assert!(o.visible.is_empty() && o.preferred.is_none());
|
||||
super::super::capture::after_queue(Ok::<(), ()>(()), || visible(&mut o, &id, f)).unwrap();
|
||||
assert!(o.visible.contains(&id) && o.preferred.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use smithay::backend::drm::{DrmDevice, DrmDeviceFd};
|
||||
use smithay::output::Output;
|
||||
use smithay::reexports::drm::control::{Device as _, crtc};
|
||||
use smithay::reexports::wayland_protocols_wlr::gamma_control::v1::server::zwlr_gamma_control_v1::ZwlrGammaControlV1;
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::gamma::Ramp;
|
||||
use ferese_config::night_light::Settings;
|
||||
use ferese_ipc::night_light::Status;
|
||||
|
||||
const TRANSITION_DURATION: Duration = Duration::from_secs(1);
|
||||
const TRANSITION_INTERVAL: Duration = Duration::from_millis(16);
|
||||
|
||||
struct Transition {
|
||||
from: Ramp,
|
||||
started: Instant,
|
||||
duration: Duration,
|
||||
}
|
||||
|
||||
// A single owner determines both the desired ramp and who loses control on failure.
|
||||
enum Source {
|
||||
Original,
|
||||
Client {
|
||||
control: ZwlrGammaControlV1,
|
||||
ramp: Ramp,
|
||||
},
|
||||
NightLight {
|
||||
temperature: u16,
|
||||
ramp: Ramp,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) enum State {
|
||||
#[default]
|
||||
Unprobed,
|
||||
Unsupported,
|
||||
Ready(Box<Gamma>),
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn probe(&mut self, drm: &DrmDevice, crtc: crtc::Handle) -> Option<&mut Gamma> {
|
||||
if matches!(self, Self::Unprobed) {
|
||||
*self = match Gamma::new(drm, crtc) {
|
||||
Ok(gamma) => Self::Ready(Box::new(gamma)),
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, ?crtc, "output gamma unavailable");
|
||||
Self::Unsupported
|
||||
}
|
||||
};
|
||||
}
|
||||
self.controller_mut()
|
||||
}
|
||||
|
||||
fn controller(&self) -> Option<&Gamma> {
|
||||
match self {
|
||||
Self::Ready(gamma) => Some(gamma),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn controller_mut(&mut self) -> Option<&mut Gamma> {
|
||||
match self {
|
||||
Self::Ready(gamma) => Some(gamma),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn suspend(&mut self) {
|
||||
if let Some(gamma) = self.controller_mut() {
|
||||
gamma.suspend();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn retry_probe(&mut self) {
|
||||
if matches!(self, Self::Unsupported) {
|
||||
*self = Self::Unprobed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Gamma {
|
||||
fd: DrmDeviceFd,
|
||||
crtc: crtc::Handle,
|
||||
original: Ramp,
|
||||
current: Ramp,
|
||||
transition: Option<Transition>,
|
||||
source: Source,
|
||||
suspended: bool,
|
||||
modified: bool,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl Gamma {
|
||||
fn new(drm: &DrmDevice, crtc: crtc::Handle) -> io::Result<Self> {
|
||||
let mut original = Ramp::zeroed(drm.get_crtc(crtc)?.gamma_length() as usize)?;
|
||||
let (red, green, blue) = original.channels_mut();
|
||||
drm.get_gamma(crtc, red, green, blue)?;
|
||||
Ok(Self {
|
||||
fd: drm.device_fd().clone(),
|
||||
crtc,
|
||||
current: original.clone(),
|
||||
transition: None,
|
||||
original,
|
||||
source: Source::Original,
|
||||
suspended: false,
|
||||
modified: false,
|
||||
failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn write(&self, ramp: &Ramp) -> io::Result<()> {
|
||||
if self.fd.get_crtc(self.crtc)?.gamma_length() as usize != ramp.size() {
|
||||
return Err(io::ErrorKind::InvalidData.into());
|
||||
}
|
||||
let (red, green, blue) = ramp.channels();
|
||||
self.fd.set_gamma(self.crtc, red, green, blue)
|
||||
}
|
||||
|
||||
fn restore(&mut self) {
|
||||
if self.suspended || !self.modified {
|
||||
return;
|
||||
}
|
||||
match self.write(&self.original) {
|
||||
Ok(()) => {
|
||||
self.modified = false;
|
||||
self.current = self.original.clone();
|
||||
}
|
||||
Err(error) => tracing::warn!(%error, ?self.crtc, "could not restore output gamma"),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_source(&mut self, source: Source) {
|
||||
if let Source::Client { control, .. } = std::mem::replace(&mut self.source, source) {
|
||||
control.failed();
|
||||
}
|
||||
}
|
||||
|
||||
fn configure(&mut self, settings: Settings, duration: Duration, now: Instant) {
|
||||
if settings.enabled {
|
||||
if matches!(self.source, Source::NightLight { temperature, .. } if temperature == settings.temperature)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.replace_source(Source::NightLight {
|
||||
temperature: settings.temperature,
|
||||
ramp: self.original.at_temperature(settings.temperature),
|
||||
});
|
||||
} else if matches!(self.source, Source::NightLight { .. }) {
|
||||
self.source = Source::Original;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
let target = match &self.source {
|
||||
Source::Original => &self.original,
|
||||
Source::NightLight { ramp, .. } | Source::Client { ramp, .. } => ramp,
|
||||
};
|
||||
self.transition =
|
||||
(!duration.is_zero() && !self.suspended && self.current != *target).then(|| {
|
||||
Transition {
|
||||
from: self.current.clone(),
|
||||
started: now,
|
||||
duration,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn owns(&self, owner: &ZwlrGammaControlV1) -> bool {
|
||||
matches!(&self.source, Source::Client { control, .. } if control == owner)
|
||||
}
|
||||
|
||||
pub(super) fn suspend(&mut self) {
|
||||
self.transition = None;
|
||||
self.restore();
|
||||
self.suspended = true;
|
||||
}
|
||||
|
||||
fn resume(&mut self, now: Instant) {
|
||||
self.suspended = false;
|
||||
let ramp = match &self.source {
|
||||
Source::Original if !self.modified && self.transition.is_none() => {
|
||||
self.failed = false;
|
||||
return;
|
||||
}
|
||||
Source::Original => &self.original,
|
||||
Source::Client { ramp, .. } | Source::NightLight { ramp, .. } => ramp,
|
||||
};
|
||||
let animated;
|
||||
let ramp = if let Some(transition) = &self.transition {
|
||||
let progress = now
|
||||
.saturating_duration_since(transition.started)
|
||||
.as_secs_f64()
|
||||
/ transition.duration.as_secs_f64();
|
||||
if progress >= 1. {
|
||||
self.transition = None;
|
||||
ramp
|
||||
} else {
|
||||
let eased = progress * progress * (3. - 2. * progress);
|
||||
animated = transition.from.interpolate(ramp, eased);
|
||||
&animated
|
||||
}
|
||||
} else {
|
||||
ramp
|
||||
};
|
||||
// Even a failed driver call may need the original table restored.
|
||||
self.modified |= ramp != &self.original;
|
||||
match self.write(ramp) {
|
||||
Ok(()) => {
|
||||
self.current = ramp.clone();
|
||||
self.modified = self.current != self.original;
|
||||
self.failed = false;
|
||||
}
|
||||
Err(error) => {
|
||||
self.transition = None;
|
||||
self.failed = true;
|
||||
tracing::warn!(%error, ?self.crtc, "could not apply output gamma");
|
||||
if matches!(self.source, Source::Client { .. }) {
|
||||
self.replace_source(Source::Original);
|
||||
}
|
||||
self.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Gamma {
|
||||
fn drop(&mut self) {
|
||||
self.replace_source(Source::Original);
|
||||
self.restore();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn acquire(
|
||||
state: &mut Ferese,
|
||||
target: &Output,
|
||||
owner: &ZwlrGammaControlV1,
|
||||
) -> Option<usize> {
|
||||
// Reconcile a just-reloaded configuration before assigning ownership.
|
||||
sync_night_light(state);
|
||||
if state.night_light.enabled {
|
||||
return None;
|
||||
}
|
||||
let duration = state.animation_duration(TRANSITION_DURATION);
|
||||
let backend = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.filter(|backend| backend.active)?;
|
||||
for device in backend
|
||||
.devices
|
||||
.values_mut()
|
||||
.filter(|device| device.drm.is_active())
|
||||
{
|
||||
for (crtc, output) in &mut device.outputs {
|
||||
if &output.output != target || output.global.is_none() {
|
||||
continue;
|
||||
}
|
||||
let gamma = output.gamma.probe(&device.drm, *crtc)?;
|
||||
gamma.configure(state.night_light, duration, Instant::now());
|
||||
if !matches!(gamma.source, Source::Original) {
|
||||
return None;
|
||||
}
|
||||
gamma.transition = None;
|
||||
gamma.source = Source::Client {
|
||||
control: owner.clone(),
|
||||
ramp: gamma.original.clone(),
|
||||
};
|
||||
return Some(gamma.original.size());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn sync_night_light(state: &mut Ferese) {
|
||||
if !state.gamma_dirty {
|
||||
return;
|
||||
}
|
||||
if !state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.is_some_and(|backend| backend.active)
|
||||
{
|
||||
return;
|
||||
}
|
||||
state.gamma_dirty = false;
|
||||
if advance_night_light(state, true) && !state.gamma_timer_pending {
|
||||
let result = state.loop_handle.insert_source(
|
||||
Timer::from_duration(TRANSITION_INTERVAL),
|
||||
|_, _, state| {
|
||||
if advance_night_light(state, false) {
|
||||
TimeoutAction::ToDuration(TRANSITION_INTERVAL)
|
||||
} else {
|
||||
state.gamma_timer_pending = false;
|
||||
TimeoutAction::Drop
|
||||
}
|
||||
},
|
||||
);
|
||||
match result {
|
||||
Ok(_) => state.gamma_timer_pending = true,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "could not schedule night-light transition");
|
||||
if let Some(backend) = &mut state.direct_backend {
|
||||
for device in backend
|
||||
.devices
|
||||
.values_mut()
|
||||
.filter(|device| device.drm.is_active())
|
||||
{
|
||||
for output in device
|
||||
.outputs
|
||||
.values_mut()
|
||||
.filter(|output| !output.power.needs_wake())
|
||||
{
|
||||
if let Some(gamma) = output.gamma.controller_mut() {
|
||||
gamma.transition = None;
|
||||
gamma.resume(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_night_light(state: &mut Ferese, force: bool) -> bool {
|
||||
let duration = state.animation_duration(TRANSITION_DURATION);
|
||||
let now = Instant::now();
|
||||
let Some(backend) = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.filter(|backend| backend.active)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut pending = false;
|
||||
for device in backend
|
||||
.devices
|
||||
.values_mut()
|
||||
.filter(|device| device.drm.is_active())
|
||||
{
|
||||
for (crtc, output) in &mut device.outputs {
|
||||
let ready = !output.power.needs_wake();
|
||||
let gamma = if ready {
|
||||
output.gamma.probe(&device.drm, *crtc)
|
||||
} else {
|
||||
output.gamma.controller_mut()
|
||||
};
|
||||
if let Some(gamma) = gamma {
|
||||
gamma.configure(state.night_light, duration, now);
|
||||
let update = force || gamma.transition.is_some();
|
||||
if duration.is_zero() {
|
||||
gamma.transition = None;
|
||||
}
|
||||
if ready && update {
|
||||
gamma.resume(now);
|
||||
pending |= gamma.transition.is_some();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pending
|
||||
}
|
||||
|
||||
pub(crate) fn night_light_status(state: &Ferese) -> Status {
|
||||
let mut status = Status {
|
||||
enabled: state.night_light.enabled,
|
||||
temperature: state.night_light.temperature,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(backend) = &state.direct_backend {
|
||||
for device in backend.devices.values() {
|
||||
for output in device.outputs.values() {
|
||||
if let Some(gamma) = output.gamma.controller() {
|
||||
status.supported_outputs += 1;
|
||||
status.failed_outputs += usize::from(gamma.failed);
|
||||
status.active_outputs += usize::from(
|
||||
backend.active
|
||||
&& !output.power.needs_wake()
|
||||
&& matches!(gamma.source, Source::NightLight { .. })
|
||||
&& !gamma.failed
|
||||
&& !gamma.suspended,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
fn owned<'a>(
|
||||
state: &'a mut Ferese,
|
||||
target: &Output,
|
||||
owner: &ZwlrGammaControlV1,
|
||||
) -> Option<(&'a mut Gamma, bool)> {
|
||||
let duration = state.animation_duration(TRANSITION_DURATION);
|
||||
let backend = state.direct_backend.as_mut()?;
|
||||
for device in backend.devices.values_mut() {
|
||||
for output in device.outputs.values_mut() {
|
||||
if &output.output == target {
|
||||
let ready = backend.active && device.drm.is_active() && !output.power.needs_wake();
|
||||
let gamma = output.gamma.controller_mut()?;
|
||||
gamma.configure(state.night_light, duration, Instant::now());
|
||||
return gamma.owns(owner).then_some((gamma, ready));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn size(
|
||||
state: &mut Ferese,
|
||||
target: &Output,
|
||||
owner: &ZwlrGammaControlV1,
|
||||
) -> Option<usize> {
|
||||
owned(state, target, owner).map(|(gamma, _)| gamma.original.size())
|
||||
}
|
||||
|
||||
pub(crate) fn set(state: &mut Ferese, target: &Output, owner: &ZwlrGammaControlV1, ramp: Ramp) {
|
||||
if let Some((gamma, ready)) = owned(state, target, owner) {
|
||||
gamma.transition = None;
|
||||
gamma.source = Source::Client {
|
||||
control: owner.clone(),
|
||||
ramp,
|
||||
};
|
||||
if ready {
|
||||
gamma.resume(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn release(state: &mut Ferese, target: &Output, owner: &ZwlrGammaControlV1) {
|
||||
if let Some((gamma, ready)) = owned(state, target, owner) {
|
||||
gamma.transition = None;
|
||||
gamma.source = Source::Original;
|
||||
if ready {
|
||||
gamma.resume(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
use super::ConfigError;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u32)]
|
||||
pub(crate) enum ScrollDirection {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PointerTrigger {
|
||||
Button(u32),
|
||||
Wheel(ScrollDirection),
|
||||
Touchpad(ScrollDirection),
|
||||
}
|
||||
|
||||
impl PointerTrigger {
|
||||
pub(super) fn parse(name: &str) -> Result<Option<Self>, ConfigError> {
|
||||
let name = name.to_ascii_lowercase();
|
||||
let button = match name.as_str() {
|
||||
"mouseleft" => Some(272),
|
||||
"mouseright" => Some(273),
|
||||
"mousemiddle" => Some(274),
|
||||
"mouseside" | "mouseback" => Some(275),
|
||||
"mouseextra" | "mouseforward" => Some(276),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(button) = button {
|
||||
return Ok(Some(Self::Button(button)));
|
||||
}
|
||||
if let Some(code) = name.strip_prefix("mouse:") {
|
||||
let code = code
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|code| (0x110..=0x11f).contains(code))
|
||||
.ok_or_else(|| {
|
||||
ConfigError::Binding(
|
||||
"mouse button must be a Linux mouse button code (272–287)".into(),
|
||||
)
|
||||
})?;
|
||||
return Ok(Some(Self::Button(code)));
|
||||
}
|
||||
for (prefix, touchpad) in [
|
||||
("wheelscroll", false),
|
||||
("wheel", false),
|
||||
("touchpadscroll", true),
|
||||
] {
|
||||
if let Some(direction) = name.strip_prefix(prefix) {
|
||||
let direction = match direction {
|
||||
"left" => ScrollDirection::Left,
|
||||
"right" => ScrollDirection::Right,
|
||||
"up" => ScrollDirection::Up,
|
||||
"down" => ScrollDirection::Down,
|
||||
_ => {
|
||||
return Err(ConfigError::Binding(format!(
|
||||
"invalid scroll direction {direction:?}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
return Ok(Some(if touchpad {
|
||||
Self::Touchpad(direction)
|
||||
} else {
|
||||
Self::Wheel(direction)
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(super) fn key(self) -> u32 {
|
||||
match self {
|
||||
Self::Button(code) => code,
|
||||
Self::Wheel(direction) => 1024 + direction as u32,
|
||||
Self::Touchpad(direction) => 1028 + direction as u32,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn from_key(key: u32) -> Self {
|
||||
let directions = [
|
||||
ScrollDirection::Left,
|
||||
ScrollDirection::Right,
|
||||
ScrollDirection::Up,
|
||||
ScrollDirection::Down,
|
||||
];
|
||||
match key {
|
||||
1024..=1027 => Self::Wheel(directions[(key - 1024) as usize]),
|
||||
1028..=1031 => Self::Touchpad(directions[(key - 1028) as usize]),
|
||||
code => Self::Button(code),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn label(self) -> String {
|
||||
match self {
|
||||
Self::Button(272) => "Mouse Left".into(),
|
||||
Self::Button(273) => "Mouse Right".into(),
|
||||
Self::Button(274) => "Mouse Middle".into(),
|
||||
Self::Button(275) => "Mouse Back".into(),
|
||||
Self::Button(276) => "Mouse Forward".into(),
|
||||
Self::Button(code) => format!("Mouse {code}"),
|
||||
Self::Wheel(direction) => format!("Wheel {direction:?}"),
|
||||
Self::Touchpad(direction) => format!("Touchpad scroll {direction:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{BindingAction, Config};
|
||||
|
||||
#[test]
|
||||
fn pointer_names_round_trip_without_colliding_with_keyboard_keys() {
|
||||
for name in [
|
||||
"MouseLeft",
|
||||
"MouseBack",
|
||||
"MouseForward",
|
||||
"mouse:287",
|
||||
"WheelUp",
|
||||
"WheelScrollLeft",
|
||||
"TouchpadScrollDown",
|
||||
] {
|
||||
let trigger = PointerTrigger::parse(name).unwrap().unwrap();
|
||||
assert_eq!(PointerTrigger::from_key(trigger.key()), trigger);
|
||||
}
|
||||
for name in ["mouse:12", "mouse:288", "mouse:foo", "WheelSideways"] {
|
||||
assert!(PointerTrigger::parse(name).is_err(), "{name}");
|
||||
}
|
||||
assert_eq!(PointerTrigger::parse("Return").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_defaults_can_be_overridden_and_disabled() {
|
||||
let config = Config::parse_source(
|
||||
r#"
|
||||
binding "Super+MouseLeft" action="close"
|
||||
binding "Super+MouseRight" disabled=#true
|
||||
binding "Alt+MouseMiddle" action="pointer-resize"
|
||||
binding "Super+WheelDown" action="workspace-next"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let bindings = config.bindings(&config.input_settings().unwrap()).unwrap();
|
||||
assert!(
|
||||
!bindings
|
||||
.iter()
|
||||
.any(|binding| binding.action == BindingAction::PointerMove)
|
||||
);
|
||||
assert_eq!(
|
||||
bindings
|
||||
.iter()
|
||||
.filter(|binding| binding.action == BindingAction::PointerResize)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let config =
|
||||
Config::parse_source(r#"binding "Super+WheelUp" action="pointer-move""#).unwrap();
|
||||
assert!(config.bindings(&config.input_settings().unwrap()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Config, ConfigError, Deserialize, SpringConfig, default_repeat_delay, default_repeat_rate,
|
||||
enabled_by_default,
|
||||
};
|
||||
|
||||
impl Config {
|
||||
pub fn input_settings(&self) -> Result<InputSettings, ConfigError> {
|
||||
@@ -27,7 +30,44 @@ impl Config {
|
||||
value: self.input.touchpad.swipe_threshold.to_string(),
|
||||
});
|
||||
}
|
||||
let mouse = self.input.mouse;
|
||||
for (field, value, valid) in [
|
||||
(
|
||||
"touchpad.accel-speed",
|
||||
self.input.touchpad.accel_speed,
|
||||
(-1.0..=1.0).contains(&self.input.touchpad.accel_speed),
|
||||
),
|
||||
(
|
||||
"mouse.accel-speed",
|
||||
mouse.accel_speed,
|
||||
(-1.0..=1.0).contains(&mouse.accel_speed),
|
||||
),
|
||||
(
|
||||
"mouse.scroll-factor",
|
||||
mouse.scroll_factor,
|
||||
(0.01..=100.0).contains(&mouse.scroll_factor),
|
||||
),
|
||||
(
|
||||
"mouse.drag-threshold",
|
||||
mouse.drag_threshold,
|
||||
(0.0..=100.0).contains(&mouse.drag_threshold),
|
||||
),
|
||||
] {
|
||||
if !value.is_finite() || !valid {
|
||||
return Err(ConfigError::InputValue {
|
||||
field,
|
||||
value: value.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if mouse.scroll_cooldown_ms > 5000 {
|
||||
return Err(ConfigError::InputValue {
|
||||
field: "mouse.scroll-cooldown-ms",
|
||||
value: mouse.scroll_cooldown_ms.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(InputSettings {
|
||||
mouse,
|
||||
focus_follows_mouse: self.input.focus_follows_mouse,
|
||||
xkb_layout: self.input.xkb_layout.clone(),
|
||||
xkb_variant: self.input.xkb_variant.clone(),
|
||||
@@ -36,6 +76,8 @@ impl Config {
|
||||
repeat_delay_ms: self.input.repeat_delay_ms,
|
||||
touchpad: TouchpadSettings {
|
||||
tap: self.input.touchpad.tap,
|
||||
accel_speed: self.input.touchpad.accel_speed,
|
||||
disable_while_mouse_connected: self.input.touchpad.disable_while_mouse_connected,
|
||||
natural_scroll: self.input.touchpad.natural_scroll,
|
||||
disable_while_typing: self.input.touchpad.disable_while_typing,
|
||||
swipe_threshold: self.input.touchpad.swipe_threshold,
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Allocation hints retain the import global's renderer as a composition fallback.
|
||||
use std::collections::HashMap;
|
||||
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
use smithay::wayland::compositor::with_states;
|
||||
use smithay::wayland::dmabuf::{DmabufFeedback, DmabufGlobal, SurfaceDmabufFeedbackState};
|
||||
|
||||
struct Surface {
|
||||
global: DmabufGlobal,
|
||||
fallback: DmabufFeedback,
|
||||
current: DmabufFeedback,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct FeedbackManager {
|
||||
globals: HashMap<DmabufGlobal, DmabufFeedback>,
|
||||
surfaces: HashMap<WlSurface, Surface>,
|
||||
}
|
||||
|
||||
impl FeedbackManager {
|
||||
pub fn register(&mut self, global: DmabufGlobal, render: DmabufFeedback) {
|
||||
self.globals.insert(global, render);
|
||||
}
|
||||
|
||||
pub fn new_surface(
|
||||
&mut self,
|
||||
surface: &WlSurface,
|
||||
global: DmabufGlobal,
|
||||
) -> Option<DmabufFeedback> {
|
||||
let fallback = self.globals.get(&global)?.clone();
|
||||
self.surfaces.insert(
|
||||
surface.clone(),
|
||||
Surface {
|
||||
global,
|
||||
fallback: fallback.clone(),
|
||||
current: fallback.clone(),
|
||||
},
|
||||
);
|
||||
Some(fallback)
|
||||
}
|
||||
|
||||
pub fn requested(&self) -> impl Iterator<Item = (&WlSurface, DmabufGlobal)> {
|
||||
self.surfaces
|
||||
.iter()
|
||||
.map(|(surface, record)| (surface, record.global))
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self, preferred: &HashMap<WlSurface, DmabufFeedback>) {
|
||||
for (surface, record) in &mut self.surfaces {
|
||||
let feedback = if self.globals.contains_key(&record.global) {
|
||||
preferred.get(surface).unwrap_or(&record.fallback)
|
||||
} else {
|
||||
&record.fallback
|
||||
};
|
||||
if &record.current == feedback {
|
||||
continue;
|
||||
}
|
||||
// Avoid locking surface state on every dispatch when its allocation
|
||||
// hints are unchanged. Smithay still owns subscriptions and messages.
|
||||
with_states(surface, |states| {
|
||||
if let Some(state) = SurfaceDmabufFeedbackState::from_states(states) {
|
||||
state.set_feedback(feedback);
|
||||
record.current = feedback.clone();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, global: DmabufGlobal) {
|
||||
self.globals.remove(&global);
|
||||
for (surface, record) in self
|
||||
.surfaces
|
||||
.iter_mut()
|
||||
.filter(|(_, record)| record.global == global)
|
||||
{
|
||||
// V4 requires a main-device tranche. Retain last-known render
|
||||
// capabilities, withdraw scanout hints and retire the global. Bound
|
||||
// resources cannot import through the retired global or another GPU.
|
||||
with_states(surface, |states| {
|
||||
if let Some(state) = SurfaceDmabufFeedbackState::from_states(states) {
|
||||
state.set_feedback(&record.fallback);
|
||||
record.current = record.fallback.clone();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn destroyed(&mut self, surface: &WlSurface) {
|
||||
self.surfaces.remove(surface);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Real v4 feedback subscriptions over a private Wayland socket; no DRM/EGL.
|
||||
use std::io::Read;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
use smithay::backend::allocator::{Format, Fourcc, Modifier};
|
||||
use smithay::output::{Mode, Output, PhysicalProperties, Subpixel};
|
||||
use smithay::reexports::calloop::EventLoop;
|
||||
use smithay::reexports::wayland_protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_feedback_v1::TrancheFlags;
|
||||
use smithay::reexports::wayland_server::Display;
|
||||
use smithay::wayland::dmabuf::DmabufFeedbackBuilder;
|
||||
|
||||
use super::*;
|
||||
use crate::Ferese;
|
||||
use crate::state::{dispatch_test_client as dispatch, test_client_request as request, test_window};
|
||||
|
||||
fn messages(wire: &mut UnixStream) -> Vec<(u32, u32, Vec<u32>)> {
|
||||
wire.set_nonblocking(true).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
let _ = wire.read_to_end(&mut bytes);
|
||||
wire.set_nonblocking(false).unwrap();
|
||||
let mut offset = 0;
|
||||
let mut result = Vec::new();
|
||||
while offset < bytes.len() {
|
||||
let object = u32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap());
|
||||
let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap());
|
||||
let size = (header >> 16) as usize;
|
||||
result.push((
|
||||
object,
|
||||
header & 0xffff,
|
||||
bytes[offset + 8..offset + size]
|
||||
.chunks_exact(4)
|
||||
.map(|word| u32::from_ne_bytes(word.try_into().unwrap()))
|
||||
.collect(),
|
||||
));
|
||||
offset += size;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn flags(events: &[(u32, u32, Vec<u32>)], object: u32) -> Vec<u32> {
|
||||
events
|
||||
.iter()
|
||||
.filter(|(id, opcode, _)| *id == object && *opcode == 6)
|
||||
.map(|(_, _, args)| args[0])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn done(events: &[(u32, u32, Vec<u32>)], object: u32) -> usize {
|
||||
events
|
||||
.iter()
|
||||
.filter(|(id, opcode, _)| *id == object && *opcode == 0)
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscriptions_update_deduplicate_and_retire_device_preferences() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"dmabuf_feedback::tests::subscriptions_update_deduplicate_and_retire_device_preferences",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let mut events = EventLoop::try_new().unwrap();
|
||||
let mut config = crate::config::Config::default().runtime_config().unwrap();
|
||||
config.wallpaper.path = None;
|
||||
let mut state = Ferese::new(&mut events, Display::new().unwrap(), config).unwrap();
|
||||
let output = Output::new(
|
||||
"feedback-test".into(),
|
||||
PhysicalProperties {
|
||||
serial_number: String::new(),
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
output.change_current_state(
|
||||
Some(Mode {
|
||||
size: (100, 100).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
state.space.map_output(&output, (0, 0));
|
||||
state.register_output(&output, "feedback-test".into());
|
||||
let f = Format {
|
||||
code: Fourcc::Argb8888,
|
||||
modifier: Modifier::Linear,
|
||||
};
|
||||
let fallback = DmabufFeedbackBuilder::new(10, [f]).build().unwrap();
|
||||
let preferred = DmabufFeedbackBuilder::new(10, [f])
|
||||
.add_preference_tranche(20, TrancheFlags::Scanout, [f], 4..=6)
|
||||
.build()
|
||||
.unwrap();
|
||||
let global = state
|
||||
.dmabuf_state
|
||||
.create_global_with_default_feedback::<Ferese>(&state.display_handle, &fallback);
|
||||
state.dmabuf_feedback.register(global, fallback.clone());
|
||||
let (window, mut wire) = test_window(&mut state, &mut events, 0xffabcdef);
|
||||
let surface = window.toplevel().unwrap().wl_surface().clone();
|
||||
request(&mut wire, 1, 1, &[7], None); // wl_display.get_registry
|
||||
dispatch(&mut events, &mut state);
|
||||
let registry = messages(&mut wire);
|
||||
let dmabuf_name = registry
|
||||
.iter()
|
||||
.find_map(|(object, opcode, args)| {
|
||||
if *object != 7 || *opcode != 0 {
|
||||
return None;
|
||||
}
|
||||
let bytes = args[2..]
|
||||
.iter()
|
||||
.flat_map(|word| word.to_ne_bytes())
|
||||
.collect::<Vec<_>>();
|
||||
bytes
|
||||
.starts_with(b"zwp_linux_dmabuf_v1\0")
|
||||
.then_some(args[0])
|
||||
})
|
||||
.expect("DMA-BUF v4 global");
|
||||
let name = b"zwp_linux_dmabuf_v1\0";
|
||||
let mut padded = name.to_vec();
|
||||
padded.resize(name.len().next_multiple_of(4), 0);
|
||||
let bind = [dmabuf_name, name.len() as u32]
|
||||
.into_iter()
|
||||
.chain(
|
||||
padded
|
||||
.chunks_exact(4)
|
||||
.map(|word| u32::from_ne_bytes(word.try_into().unwrap())),
|
||||
)
|
||||
.chain([4, 8])
|
||||
.collect::<Vec<_>>();
|
||||
request(&mut wire, 7, 0, &bind, None);
|
||||
request(&mut wire, 8, 3, &[9, 2], None); // get_surface_feedback
|
||||
dispatch(&mut events, &mut state);
|
||||
let initial = messages(&mut wire);
|
||||
assert_eq!(flags(&initial, 9), [0]);
|
||||
assert_eq!(done(&initial, 9), 1);
|
||||
assert_eq!(state.dmabuf_feedback.requested().count(), 1);
|
||||
let main = initial
|
||||
.iter()
|
||||
.find(|(id, opcode, _)| *id == 9 && *opcode == 2)
|
||||
.unwrap();
|
||||
assert_eq!(main.2[0] as usize, std::mem::size_of::<libc::dev_t>());
|
||||
assert_eq!(main.2[1], 10, "main device remains the renderer");
|
||||
|
||||
let selected = HashMap::from([(surface.clone(), preferred.clone())]);
|
||||
state.dmabuf_feedback.refresh(&selected);
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
let changed = messages(&mut wire);
|
||||
assert_eq!(flags(&changed, 9), [TrancheFlags::Scanout.bits(), 0]);
|
||||
assert_eq!(done(&changed, 9), 1);
|
||||
let target = changed
|
||||
.iter()
|
||||
.find(|(id, opcode, _)| *id == 9 && *opcode == 4)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
target.2[1], 20,
|
||||
"scanout tranche targets the primary DRM device"
|
||||
);
|
||||
state.dmabuf_feedback.refresh(&selected);
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
assert!(
|
||||
messages(&mut wire).is_empty(),
|
||||
"unchanged feedback must not be published again"
|
||||
);
|
||||
|
||||
request(&mut wire, 8, 3, &[10, 2], None);
|
||||
dispatch(&mut events, &mut state);
|
||||
let second = messages(&mut wire);
|
||||
assert_eq!(flags(&second, 10), [TrancheFlags::Scanout.bits(), 0]);
|
||||
state.dmabuf_feedback.refresh(&HashMap::new()); // migration/ineligibility fallback
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
let fallback_events = messages(&mut wire);
|
||||
assert_eq!(flags(&fallback_events, 9), [0]);
|
||||
assert_eq!(flags(&fallback_events, 10), [0]);
|
||||
assert_eq!(done(&fallback_events, 9), 1);
|
||||
assert_eq!(done(&fallback_events, 10), 1);
|
||||
|
||||
request(&mut wire, 9, 0, &[], None);
|
||||
request(&mut wire, 10, 0, &[], None);
|
||||
dispatch(&mut events, &mut state);
|
||||
messages(&mut wire);
|
||||
request(&mut wire, 8, 3, &[11, 2], None);
|
||||
request(&mut wire, 8, 2, &[12], None); // get_default_feedback
|
||||
dispatch(&mut events, &mut state);
|
||||
let renewed = messages(&mut wire);
|
||||
assert_eq!(
|
||||
flags(&renewed, 11),
|
||||
[0],
|
||||
"renewal starts with safe render feedback"
|
||||
);
|
||||
assert_eq!(flags(&renewed, 12), [0]);
|
||||
state.dmabuf_feedback.refresh(&selected);
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
let promoted = messages(&mut wire);
|
||||
assert_eq!(flags(&promoted, 11), [TrancheFlags::Scanout.bits(), 0]);
|
||||
state.dmabuf_feedback.remove(global);
|
||||
state
|
||||
.dmabuf_state
|
||||
.disable_global::<Ferese>(&state.display_handle, &global);
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
let retired = messages(&mut wire);
|
||||
assert_eq!(done(&retired, 11), 1);
|
||||
assert_eq!(
|
||||
flags(&retired, 11),
|
||||
[0],
|
||||
"retirement keeps a valid main-device render tranche"
|
||||
);
|
||||
assert_eq!(
|
||||
done(&retired, 12),
|
||||
0,
|
||||
"default renderer capabilities remain unchanged on existing bindings"
|
||||
);
|
||||
assert!(
|
||||
retired
|
||||
.iter()
|
||||
.any(|(id, opcode, args)| *id == 7 && *opcode == 1 && args[0] == dmabuf_name)
|
||||
);
|
||||
state.dmabuf_feedback.refresh(&selected); // A stale choice cannot resurrect a retired preference.
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
assert!(messages(&mut wire).is_empty());
|
||||
assert!(
|
||||
state
|
||||
.dmabuf_feedback
|
||||
.new_surface(&surface, global)
|
||||
.is_none()
|
||||
);
|
||||
drop(wire);
|
||||
dispatch(&mut events, &mut state);
|
||||
assert_eq!(
|
||||
state.dmabuf_feedback.requested().count(),
|
||||
0,
|
||||
"surface destruction must remove subscriptions without a refresh scan"
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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(®ions), decode_panel_shapes(&shapes, fusion))
|
||||
else {
|
||||
let (Some(regions), Some(panel)) = (
|
||||
decode_regions(®ions),
|
||||
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(®ions) {
|
||||
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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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.),
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
use crate::Ferese;
|
||||
use smithay::reexports::wayland_server::backend::ClientId;
|
||||
use smithay::reexports::wayland_server::{
|
||||
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, protocol as wl,
|
||||
};
|
||||
use smithay::reexports::{
|
||||
wayland_protocols as protocols, wayland_protocols_misc as misc, wayland_protocols_wlr as wlr,
|
||||
};
|
||||
use smithay::wayland::{Dispatch2, GlobalDispatch2};
|
||||
|
||||
// Forward stock protocol interfaces through Smithay's current dispatch API.
|
||||
// Keep xdg_toplevel and ext_session_lock_v1 explicit: Ferese validates their
|
||||
// requests before delegation, including ownership of the active session lock.
|
||||
macro_rules! delegate_protocols {
|
||||
($($interface:path),* $(,)?) => {
|
||||
$(
|
||||
impl<U: Dispatch2<$interface, Ferese>> Dispatch<$interface, U> for Ferese {
|
||||
fn request(
|
||||
state: &mut Self,
|
||||
client: &Client,
|
||||
resource: &$interface,
|
||||
request: <$interface as Resource>::Request,
|
||||
data: &U,
|
||||
display: &DisplayHandle,
|
||||
init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
data.request(state, client, resource, request, display, init);
|
||||
}
|
||||
|
||||
fn destroyed(state: &mut Self, client: ClientId, resource: &$interface, data: &U) {
|
||||
data.destroyed(state, client, resource);
|
||||
}
|
||||
}
|
||||
|
||||
impl<U: GlobalDispatch2<$interface, Ferese>> GlobalDispatch<$interface, U> for Ferese {
|
||||
fn bind(
|
||||
state: &mut Self,
|
||||
display: &DisplayHandle,
|
||||
client: &Client,
|
||||
resource: New<$interface>,
|
||||
data: &U,
|
||||
init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
data.bind(state, display, client, resource, init);
|
||||
}
|
||||
|
||||
fn can_view(client: Client, data: &U) -> bool {
|
||||
data.can_view(&client)
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
delegate_protocols!(
|
||||
wlr::virtual_pointer::v1::server::zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
|
||||
wlr::virtual_pointer::v1::server::zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
|
||||
protocols::ext::image_capture_source::v1::server::ext_image_capture_source_v1::ExtImageCaptureSourceV1,
|
||||
protocols::ext::image_capture_source::v1::server::ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1,
|
||||
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_manager_v1::ExtImageCopyCaptureManagerV1,
|
||||
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_session_v1::ExtImageCopyCaptureSessionV1,
|
||||
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_cursor_session_v1::ExtImageCopyCaptureCursorSessionV1,
|
||||
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_frame_v1::ExtImageCopyCaptureFrameV1,
|
||||
protocols::wp::idle_inhibit::zv1::server::zwp_idle_inhibit_manager_v1::ZwpIdleInhibitManagerV1,
|
||||
protocols::wp::idle_inhibit::zv1::server::zwp_idle_inhibitor_v1::ZwpIdleInhibitorV1,
|
||||
protocols::ext::data_control::v1::server::ext_data_control_device_v1::ExtDataControlDeviceV1,
|
||||
protocols::ext::data_control::v1::server::ext_data_control_manager_v1::ExtDataControlManagerV1,
|
||||
protocols::ext::data_control::v1::server::ext_data_control_source_v1::ExtDataControlSourceV1,
|
||||
protocols::ext::idle_notify::v1::server::ext_idle_notification_v1::ExtIdleNotificationV1,
|
||||
protocols::ext::idle_notify::v1::server::ext_idle_notifier_v1::ExtIdleNotifierV1,
|
||||
protocols::ext::session_lock::v1::server::ext_session_lock_manager_v1::ExtSessionLockManagerV1,
|
||||
protocols::ext::session_lock::v1::server::ext_session_lock_surface_v1::ExtSessionLockSurfaceV1,
|
||||
protocols::wp::alpha_modifier::v1::server::wp_alpha_modifier_surface_v1::WpAlphaModifierSurfaceV1,
|
||||
protocols::wp::alpha_modifier::v1::server::wp_alpha_modifier_v1::WpAlphaModifierV1,
|
||||
protocols::wp::cursor_shape::v1::server::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1,
|
||||
protocols::wp::cursor_shape::v1::server::wp_cursor_shape_manager_v1::WpCursorShapeManagerV1,
|
||||
protocols::wp::fractional_scale::v1::server::wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1,
|
||||
protocols::wp::fractional_scale::v1::server::wp_fractional_scale_v1::WpFractionalScaleV1,
|
||||
protocols::wp::keyboard_shortcuts_inhibit::zv1::server::zwp_keyboard_shortcuts_inhibit_manager_v1::ZwpKeyboardShortcutsInhibitManagerV1,
|
||||
protocols::wp::keyboard_shortcuts_inhibit::zv1::server::zwp_keyboard_shortcuts_inhibitor_v1::ZwpKeyboardShortcutsInhibitorV1,
|
||||
protocols::wp::linux_dmabuf::zv1::server::zwp_linux_buffer_params_v1::ZwpLinuxBufferParamsV1,
|
||||
protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_feedback_v1::ZwpLinuxDmabufFeedbackV1,
|
||||
protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1,
|
||||
protocols::wp::pointer_constraints::zv1::server::zwp_confined_pointer_v1::ZwpConfinedPointerV1,
|
||||
protocols::wp::pointer_constraints::zv1::server::zwp_locked_pointer_v1::ZwpLockedPointerV1,
|
||||
protocols::wp::pointer_constraints::zv1::server::zwp_pointer_constraints_v1::ZwpPointerConstraintsV1,
|
||||
protocols::wp::presentation_time::server::wp_presentation::WpPresentation,
|
||||
protocols::wp::presentation_time::server::wp_presentation_feedback::WpPresentationFeedback,
|
||||
protocols::wp::primary_selection::zv1::server::zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1,
|
||||
protocols::wp::primary_selection::zv1::server::zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1,
|
||||
protocols::wp::primary_selection::zv1::server::zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,
|
||||
protocols::wp::relative_pointer::zv1::server::zwp_relative_pointer_manager_v1::ZwpRelativePointerManagerV1,
|
||||
protocols::wp::relative_pointer::zv1::server::zwp_relative_pointer_v1::ZwpRelativePointerV1,
|
||||
protocols::wp::security_context::v1::server::wp_security_context_manager_v1::WpSecurityContextManagerV1,
|
||||
protocols::wp::security_context::v1::server::wp_security_context_v1::WpSecurityContextV1,
|
||||
protocols::wp::single_pixel_buffer::v1::server::wp_single_pixel_buffer_manager_v1::WpSinglePixelBufferManagerV1,
|
||||
protocols::wp::text_input::zv3::server::zwp_text_input_manager_v3::ZwpTextInputManagerV3,
|
||||
protocols::wp::text_input::zv3::server::zwp_text_input_v3::ZwpTextInputV3,
|
||||
protocols::wp::viewporter::server::wp_viewport::WpViewport,
|
||||
protocols::wp::viewporter::server::wp_viewporter::WpViewporter,
|
||||
protocols::xdg::activation::v1::server::xdg_activation_token_v1::XdgActivationTokenV1,
|
||||
protocols::xdg::activation::v1::server::xdg_activation_v1::XdgActivationV1,
|
||||
protocols::xdg::decoration::zv1::server::zxdg_decoration_manager_v1::ZxdgDecorationManagerV1,
|
||||
protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1,
|
||||
protocols::xdg::foreign::zv2::server::zxdg_exported_v2::ZxdgExportedV2,
|
||||
protocols::xdg::foreign::zv2::server::zxdg_exporter_v2::ZxdgExporterV2,
|
||||
protocols::xdg::foreign::zv2::server::zxdg_imported_v2::ZxdgImportedV2,
|
||||
protocols::xdg::foreign::zv2::server::zxdg_importer_v2::ZxdgImporterV2,
|
||||
protocols::xdg::shell::server::xdg_popup::XdgPopup,
|
||||
protocols::xdg::shell::server::xdg_positioner::XdgPositioner,
|
||||
protocols::xdg::shell::server::xdg_surface::XdgSurface,
|
||||
protocols::xdg::shell::server::xdg_wm_base::XdgWmBase,
|
||||
protocols::xdg::toplevel_icon::v1::server::xdg_toplevel_icon_manager_v1::XdgToplevelIconManagerV1,
|
||||
protocols::xdg::toplevel_icon::v1::server::xdg_toplevel_icon_v1::XdgToplevelIconV1,
|
||||
protocols::xdg::xdg_output::zv1::server::zxdg_output_manager_v1::ZxdgOutputManagerV1,
|
||||
protocols::xdg::xdg_output::zv1::server::zxdg_output_v1::ZxdgOutputV1,
|
||||
misc::zwp_input_method_v2::server::zwp_input_method_keyboard_grab_v2::ZwpInputMethodKeyboardGrabV2,
|
||||
misc::zwp_input_method_v2::server::zwp_input_method_manager_v2::ZwpInputMethodManagerV2,
|
||||
misc::zwp_input_method_v2::server::zwp_input_method_v2::ZwpInputMethodV2,
|
||||
misc::zwp_input_method_v2::server::zwp_input_popup_surface_v2::ZwpInputPopupSurfaceV2,
|
||||
misc::zwp_virtual_keyboard_v1::server::zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1,
|
||||
misc::zwp_virtual_keyboard_v1::server::zwp_virtual_keyboard_v1::ZwpVirtualKeyboardV1,
|
||||
wlr::data_control::v1::server::zwlr_data_control_device_v1::ZwlrDataControlDeviceV1,
|
||||
wlr::data_control::v1::server::zwlr_data_control_manager_v1::ZwlrDataControlManagerV1,
|
||||
wlr::data_control::v1::server::zwlr_data_control_source_v1::ZwlrDataControlSourceV1,
|
||||
wlr::layer_shell::v1::server::zwlr_layer_shell_v1::ZwlrLayerShellV1,
|
||||
wlr::layer_shell::v1::server::zwlr_layer_surface_v1::ZwlrLayerSurfaceV1,
|
||||
wl::wl_buffer::WlBuffer,
|
||||
wl::wl_callback::WlCallback,
|
||||
wl::wl_compositor::WlCompositor,
|
||||
wl::wl_data_device::WlDataDevice,
|
||||
wl::wl_data_device_manager::WlDataDeviceManager,
|
||||
wl::wl_data_source::WlDataSource,
|
||||
wl::wl_keyboard::WlKeyboard,
|
||||
wl::wl_output::WlOutput,
|
||||
wl::wl_pointer::WlPointer,
|
||||
wl::wl_region::WlRegion,
|
||||
wl::wl_seat::WlSeat,
|
||||
wl::wl_shm::WlShm,
|
||||
wl::wl_shm_pool::WlShmPool,
|
||||
wl::wl_subcompositor::WlSubcompositor,
|
||||
wl::wl_subsurface::WlSubsurface,
|
||||
wl::wl_surface::WlSurface,
|
||||
wl::wl_touch::WlTouch,
|
||||
);
|
||||
@@ -0,0 +1,101 @@
|
||||
use std::fs::File;
|
||||
|
||||
use smithay::output::Output;
|
||||
use smithay::reexports::wayland_protocols_wlr::gamma_control::v1::server::{
|
||||
zwlr_gamma_control_manager_v1::{self, ZwlrGammaControlManagerV1},
|
||||
zwlr_gamma_control_v1::{self, ZwlrGammaControlV1},
|
||||
};
|
||||
use smithay::reexports::wayland_server::{
|
||||
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
|
||||
};
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::backends::direct::gamma;
|
||||
use crate::gamma::Ramp;
|
||||
|
||||
pub(crate) fn init_global(display: &DisplayHandle) {
|
||||
display.create_global::<Ferese, ZwlrGammaControlManagerV1, ()>(1, ());
|
||||
}
|
||||
|
||||
impl GlobalDispatch<ZwlrGammaControlManagerV1, ()> for Ferese {
|
||||
fn bind(
|
||||
_state: &mut Self,
|
||||
_display: &DisplayHandle,
|
||||
_client: &Client,
|
||||
resource: New<ZwlrGammaControlManagerV1>,
|
||||
_data: &(),
|
||||
data_init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
data_init.init(resource, ());
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZwlrGammaControlManagerV1, ()> for Ferese {
|
||||
fn request(
|
||||
state: &mut Self,
|
||||
_client: &Client,
|
||||
_resource: &ZwlrGammaControlManagerV1,
|
||||
request: zwlr_gamma_control_manager_v1::Request,
|
||||
_data: &(),
|
||||
_display: &DisplayHandle,
|
||||
data_init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
match request {
|
||||
zwlr_gamma_control_manager_v1::Request::GetGammaControl { id, output } => {
|
||||
let output = Output::from_resource(&output);
|
||||
let control = data_init.init(id, output.clone());
|
||||
if let Some(size) = output
|
||||
.as_ref()
|
||||
.and_then(|output| gamma::acquire(state, output, &control))
|
||||
{
|
||||
control.gamma_size(size as u32);
|
||||
} else {
|
||||
control.failed();
|
||||
}
|
||||
}
|
||||
zwlr_gamma_control_manager_v1::Request::Destroy => {}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZwlrGammaControlV1, Option<Output>> for Ferese {
|
||||
fn request(
|
||||
state: &mut Self,
|
||||
_client: &Client,
|
||||
control: &ZwlrGammaControlV1,
|
||||
request: zwlr_gamma_control_v1::Request,
|
||||
output: &Option<Output>,
|
||||
_display: &DisplayHandle,
|
||||
_data_init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
match request {
|
||||
zwlr_gamma_control_v1::Request::SetGamma { fd } => {
|
||||
let Some(output) = output else { return };
|
||||
let Some(size) = gamma::size(state, output, control) else {
|
||||
return;
|
||||
};
|
||||
match Ramp::read(File::from(fd), size) {
|
||||
Ok(ramp) => gamma::set(state, output, control, ramp),
|
||||
Err(error) => control.post_error(
|
||||
zwlr_gamma_control_v1::Error::InvalidGamma,
|
||||
format!("Invalid gamma table: {error}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
zwlr_gamma_control_v1::Request::Destroy => {}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn destroyed(
|
||||
state: &mut Self,
|
||||
_client: smithay::reexports::wayland_server::backend::ClientId,
|
||||
control: &ZwlrGammaControlV1,
|
||||
output: &Option<Output>,
|
||||
) {
|
||||
if let Some(output) = output {
|
||||
gamma::release(state, output, control);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,952 @@
|
||||
//! Persistent, damage-driven capture for the native portal. One frame may be
|
||||
//! outstanding per session; client buffers remain owned until GPU completion.
|
||||
use smithay::reexports::calloop::RegistrationToken;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ferese_layout::WindowId;
|
||||
use smithay::backend::allocator::{Fourcc, Modifier};
|
||||
use smithay::backend::egl::EGLDevice;
|
||||
use smithay::backend::renderer::gles::{Capability, GlesRenderer, GlesTexture};
|
||||
use smithay::backend::renderer::Texture;
|
||||
use smithay::backend::renderer::utils::{CommitCounter, RendererSurfaceStateUserData, SurfaceView};
|
||||
use smithay::output::{Output, WeakOutput};
|
||||
use smithay::reexports::wayland_protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_frame_v1::FailureReason;
|
||||
use smithay::reexports::wayland_server::backend::ObjectId;
|
||||
use smithay::reexports::wayland_server::{Client, DisplayHandle, Resource};
|
||||
use smithay::reexports::wayland_server::protocol::wl_shm;
|
||||
use smithay::utils::{Buffer, Logical, Point, Rectangle, Size, Transform};
|
||||
use smithay::wayland::compositor::{TraversalAction, with_surface_tree_downward};
|
||||
use smithay::wayland::image_capture_source::{ImageCaptureSource, ImageCaptureSourceHandler, OutputCaptureSourceHandler, OutputCaptureSourceState};
|
||||
use smithay::wayland::image_copy_capture::{BufferConstraints, DmabufConstraints, Frame, FrameRef, ImageCopyCaptureHandler, ImageCopyCaptureState, Session, SessionRef};
|
||||
|
||||
use crate::Ferese;
|
||||
|
||||
const MAX_SESSIONS: usize = 16;
|
||||
// Reserve for the maximum eight portal pool buffers, a retained target and a
|
||||
// fallback transfer texture. Imports of the same allocation are not extra pixels.
|
||||
const MAX_STREAM_BYTES: u64 = 512 * 1024 * 1024;
|
||||
fn stream_bytes(size: Size<i32, Buffer>) -> u64 {
|
||||
// The portal rejects DMA strides above this allowance. Driver bookkeeping
|
||||
// and encoder memory are separate from this pixel-storage reservation.
|
||||
let stride = (size.w.max(0) as u64 * 4 + 4095) & !4095;
|
||||
stride
|
||||
.saturating_mul(size.h.max(0) as u64)
|
||||
.saturating_mul(10)
|
||||
}
|
||||
|
||||
pub(super) enum Source {
|
||||
Output(WeakOutput),
|
||||
Window(WindowId),
|
||||
Workspace(super::workspace_capture::WorkspaceSource),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct SourcePaused(pub std::sync::atomic::AtomicBool);
|
||||
|
||||
fn source_active(source: &ImageCaptureSource) -> bool {
|
||||
source
|
||||
.user_data()
|
||||
.get::<SourcePaused>()
|
||||
.is_none_or(|paused| !paused.0.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct SurfaceStamp {
|
||||
id: ObjectId,
|
||||
commit: CommitCounter,
|
||||
view: SurfaceView,
|
||||
scale: i32,
|
||||
transform: Transform,
|
||||
alpha: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct WindowStamp {
|
||||
surfaces: Vec<SurfaceStamp>,
|
||||
geometry: Rectangle<i32, Logical>,
|
||||
scale: f64,
|
||||
epoch: u64,
|
||||
cursor: Option<CursorStamp>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub(super) struct CursorStamp {
|
||||
location: Point<f64, Logical>,
|
||||
geometry: Rectangle<i32, Logical>,
|
||||
image: CursorImageStamp,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum CursorImageStamp {
|
||||
Named(Option<smithay::input::pointer::CursorIcon>, Instant, usize),
|
||||
Surface(Vec<SurfaceStamp>, Point<i32, Logical>),
|
||||
Hidden,
|
||||
}
|
||||
|
||||
fn surface_stamp(
|
||||
root: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
|
||||
crop: Option<Rectangle<i32, Logical>>,
|
||||
) -> Vec<SurfaceStamp> {
|
||||
let mut stamp = Vec::new();
|
||||
with_surface_tree_downward(
|
||||
root,
|
||||
Point::<f64, Logical>::default(),
|
||||
|surface, states, location| {
|
||||
let Some(data) = states.data_map.get::<RendererSurfaceStateUserData>() else {
|
||||
return TraversalAction::SkipChildren;
|
||||
};
|
||||
use smithay::wayland::alpha_modifier::AlphaModifierSurfaceCachedState;
|
||||
let alpha = states
|
||||
.cached_state
|
||||
.get::<AlphaModifierSurfaceCachedState>()
|
||||
.current()
|
||||
.multiplier_f32();
|
||||
let data = data.lock().expect("surface metadata mutex poisoned");
|
||||
let Some(view) = data.view() else {
|
||||
return TraversalAction::SkipChildren;
|
||||
};
|
||||
let location = *location + view.offset.to_f64();
|
||||
if crop.is_none_or(|crop| {
|
||||
Rectangle::new(location, view.dst.to_f64()).overlaps(crop.to_f64())
|
||||
}) {
|
||||
stamp.push(SurfaceStamp {
|
||||
id: surface.id(),
|
||||
commit: data.current_commit(),
|
||||
view,
|
||||
scale: data.buffer_scale(),
|
||||
transform: data.buffer_transform(),
|
||||
alpha,
|
||||
});
|
||||
}
|
||||
// An off-crop parent can have a child offset back into the captured area.
|
||||
TraversalAction::DoChildren(location)
|
||||
},
|
||||
|_, _, _| {},
|
||||
|_, _, _| true,
|
||||
);
|
||||
stamp
|
||||
}
|
||||
|
||||
fn pending_frame_callbacks(
|
||||
root: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
|
||||
) -> bool {
|
||||
let mut pending = false;
|
||||
with_surface_tree_downward(
|
||||
root,
|
||||
(),
|
||||
|_, states, _| {
|
||||
if states
|
||||
.data_map
|
||||
.get::<RendererSurfaceStateUserData>()
|
||||
.is_some_and(|data| {
|
||||
data.lock()
|
||||
.expect("surface metadata mutex poisoned")
|
||||
.view()
|
||||
.is_none()
|
||||
})
|
||||
{
|
||||
return TraversalAction::SkipChildren;
|
||||
}
|
||||
pending |= !states
|
||||
.cached_state
|
||||
.get::<smithay::wayland::compositor::SurfaceAttributes>()
|
||||
.current()
|
||||
.frame_callbacks
|
||||
.is_empty();
|
||||
TraversalAction::DoChildren(())
|
||||
},
|
||||
|_, _, _| {},
|
||||
|_, _, _| true,
|
||||
);
|
||||
pending
|
||||
}
|
||||
|
||||
pub(super) struct Active {
|
||||
pub(super) session: Session,
|
||||
pub(super) pending: Option<Frame>,
|
||||
pub(super) revision: Option<u64>,
|
||||
window_stamp: Option<WindowStamp>,
|
||||
size: Size<i32, Buffer>,
|
||||
dma_node: Option<smithay::backend::drm::DrmNode>,
|
||||
}
|
||||
|
||||
struct InFlight {
|
||||
frame: Frame,
|
||||
session: SessionRef,
|
||||
epoch: u64,
|
||||
fence: RegistrationToken,
|
||||
timeout: RegistrationToken,
|
||||
}
|
||||
|
||||
pub(crate) struct CaptureSessions {
|
||||
copy: ImageCopyCaptureState,
|
||||
outputs: OutputCaptureSourceState,
|
||||
active: Vec<Active>,
|
||||
in_flight: HashMap<u64, InFlight>,
|
||||
next_fence: u64,
|
||||
callback_timer: Option<RegistrationToken>,
|
||||
callback_deadline: Option<Instant>,
|
||||
workspace_callbacks: HashMap<ferese_core::WorkspaceId, Instant>,
|
||||
workspaces:
|
||||
HashMap<(ferese_core::WorkspaceId, bool), super::workspace_capture::WorkspaceCapture>,
|
||||
}
|
||||
|
||||
fn authorized(client: &Client) -> bool {
|
||||
super::screencopy::capture_allowed()
|
||||
&& client
|
||||
.get_data::<crate::state::ClientState>()
|
||||
.is_some_and(|state| {
|
||||
state
|
||||
.capabilities
|
||||
.contains(crate::private_client::ClientCapabilities::WINDOW_CAPTURE)
|
||||
})
|
||||
}
|
||||
|
||||
impl CaptureSessions {
|
||||
pub(super) fn workspace_callback_deadline(
|
||||
&self,
|
||||
id: ferese_core::WorkspaceId,
|
||||
now: Instant,
|
||||
) -> Instant {
|
||||
self.workspace_callbacks.get(&id).copied().unwrap_or(now)
|
||||
}
|
||||
pub(super) fn set_workspace_callback_deadline(
|
||||
&mut self,
|
||||
id: ferese_core::WorkspaceId,
|
||||
deadline: Instant,
|
||||
) {
|
||||
self.workspace_callbacks.insert(id, deadline);
|
||||
}
|
||||
pub(crate) fn invalidate_workspaces(&mut self) {
|
||||
for view in self.workspaces.values_mut() {
|
||||
view.invalidate();
|
||||
}
|
||||
}
|
||||
pub(crate) fn workspace_changed(&mut self, id: ferese_core::WorkspaceId) {
|
||||
for ((workspace, _), view) in &mut self.workspaces {
|
||||
if *workspace == id {
|
||||
view.content_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn workspace_geometry_changed(&mut self, id: ferese_core::WorkspaceId) {
|
||||
for ((workspace, _), view) in &mut self.workspaces {
|
||||
if *workspace == id {
|
||||
view.invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn captured_workspaces(&self) -> Vec<ferese_core::WorkspaceId> {
|
||||
self.workspaces.keys().map(|(id, _)| *id).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn new(display: &DisplayHandle) -> Self {
|
||||
Self {
|
||||
copy: ImageCopyCaptureState::new_with_filter::<Ferese, _>(display, authorized),
|
||||
outputs: OutputCaptureSourceState::new_with_filter::<Ferese, _>(display, authorized),
|
||||
active: Vec::new(),
|
||||
in_flight: HashMap::new(),
|
||||
next_fence: 0,
|
||||
callback_timer: None,
|
||||
callback_deadline: None,
|
||||
workspace_callbacks: HashMap::new(),
|
||||
workspaces: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_sessions(&self) -> bool {
|
||||
self.active.iter().any(|active| {
|
||||
active.pending.is_some()
|
||||
&& matches!(
|
||||
active.session.source().user_data().get::<Source>(),
|
||||
Some(Source::Output(_))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn pending_output(&self, output: &Output, cursor: bool) -> bool {
|
||||
self.active.iter().any(|active| {
|
||||
active.pending.is_some()
|
||||
&& active.session.draw_cursor() == cursor
|
||||
&& matches!(active.session.source().user_data().get::<Source>(),
|
||||
Some(Source::Output(weak)) if weak.upgrade().as_ref() == Some(output))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dma_constraints(renderer: &GlesRenderer) -> Option<Arc<DmabufConstraints>> {
|
||||
struct Cached(Option<Arc<DmabufConstraints>>);
|
||||
renderer
|
||||
.egl_context()
|
||||
.user_data()
|
||||
.get_or_insert_threadsafe(|| Cached(query_dma_constraints(renderer).map(Arc::new)))
|
||||
.0
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn query_dma_constraints(renderer: &GlesRenderer) -> Option<DmabufConstraints> {
|
||||
if !renderer.capabilities().contains(&Capability::ExportFence) {
|
||||
return None;
|
||||
}
|
||||
let display = renderer.egl_context().display();
|
||||
if !display
|
||||
.extensions()
|
||||
.iter()
|
||||
.any(|extension| extension == "EGL_ANDROID_native_fence_sync")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let node = EGLDevice::device_for_display(display)
|
||||
.ok()?
|
||||
.try_get_render_node()
|
||||
.ok()??;
|
||||
let format = smithay::backend::allocator::Format {
|
||||
code: Fourcc::Xrgb8888,
|
||||
modifier: Modifier::Linear,
|
||||
};
|
||||
display
|
||||
.dmabuf_render_formats()
|
||||
.contains(&format)
|
||||
.then_some(DmabufConstraints {
|
||||
node,
|
||||
formats: vec![(Fourcc::Xrgb8888, vec![Modifier::Linear])],
|
||||
})
|
||||
}
|
||||
|
||||
impl ImageCaptureSourceHandler for Ferese {}
|
||||
impl OutputCaptureSourceHandler for Ferese {
|
||||
fn output_capture_source_state(&mut self) -> &mut OutputCaptureSourceState {
|
||||
&mut self.image_capture.outputs
|
||||
}
|
||||
fn output_source_created(&mut self, source: ImageCaptureSource, output: &Output) {
|
||||
source
|
||||
.user_data()
|
||||
.insert_if_missing(|| Source::Output(output.downgrade()));
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageCopyCaptureHandler for Ferese {
|
||||
fn image_copy_capture_state(&mut self) -> &mut ImageCopyCaptureState {
|
||||
&mut self.image_capture.copy
|
||||
}
|
||||
fn capture_constraints(&mut self, source: &ImageCaptureSource) -> Option<BufferConstraints> {
|
||||
let (size, output) = self.capture_source_geometry(source)?;
|
||||
Some(BufferConstraints {
|
||||
size,
|
||||
shm: vec![wl_shm::Format::Xrgb8888],
|
||||
dma: self.capture_dma_constraints(&output).as_deref().cloned(),
|
||||
})
|
||||
}
|
||||
fn new_session(&mut self, session: Session) {
|
||||
if self.image_capture.active.len() >= MAX_SESSIONS {
|
||||
return;
|
||||
}
|
||||
let Some(constraints) = session.current_constraints() else {
|
||||
return;
|
||||
};
|
||||
let reserved: u64 = self
|
||||
.image_capture
|
||||
.active
|
||||
.iter()
|
||||
.map(|active| stream_bytes(active.size))
|
||||
.sum();
|
||||
if stream_bytes(constraints.size) > MAX_STREAM_BYTES.saturating_sub(reserved) {
|
||||
return;
|
||||
}
|
||||
self.image_capture.active.push(Active {
|
||||
session,
|
||||
pending: None,
|
||||
revision: None,
|
||||
window_stamp: None,
|
||||
size: constraints.size,
|
||||
dma_node: constraints.dma.as_ref().map(|dma| dma.node),
|
||||
});
|
||||
}
|
||||
fn frame(&mut self, session: &SessionRef, frame: Frame) {
|
||||
let Some(active) = self
|
||||
.image_capture
|
||||
.active
|
||||
.iter_mut()
|
||||
.find(|active| active.session.as_ref() == *session)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if active.pending.is_some() {
|
||||
frame.fail(FailureReason::Unknown);
|
||||
return;
|
||||
}
|
||||
active.pending = Some(frame);
|
||||
if let Some(Source::Output(weak)) = session.source().user_data().get::<Source>()
|
||||
&& let Some(output) = weak.upgrade()
|
||||
{
|
||||
crate::backends::direct::schedule_capture(self, &[output]);
|
||||
}
|
||||
}
|
||||
|
||||
fn session_destroyed(&mut self, session: SessionRef) {
|
||||
self.image_capture
|
||||
.active
|
||||
.retain(|active| active.session.as_ref() != session);
|
||||
let fences: Vec<_> = self
|
||||
.image_capture
|
||||
.in_flight
|
||||
.iter()
|
||||
.filter_map(|(id, pending)| (pending.session == session).then_some(*id))
|
||||
.collect();
|
||||
for id in fences {
|
||||
self.cancel_capture_fence(id);
|
||||
}
|
||||
}
|
||||
fn frame_aborted(&mut self, frame: FrameRef) {
|
||||
let fences: Vec<_> = self
|
||||
.image_capture
|
||||
.in_flight
|
||||
.iter()
|
||||
.filter_map(|(id, pending)| (pending.frame == frame).then_some(*id))
|
||||
.collect();
|
||||
for id in fences {
|
||||
self.cancel_capture_fence(id);
|
||||
}
|
||||
for active in &mut self.image_capture.active {
|
||||
if active
|
||||
.pending
|
||||
.as_ref()
|
||||
.is_some_and(|pending| *pending == frame)
|
||||
{
|
||||
active.pending.take();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
pub(super) fn capture_cursor_stamp(
|
||||
&self,
|
||||
geometry: Rectangle<i32, Logical>,
|
||||
) -> Option<CursorStamp> {
|
||||
use smithay::input::pointer::CursorImageStatus;
|
||||
let pointer = self.seat.get_pointer()?;
|
||||
let image = match &self.cursor_status {
|
||||
CursorImageStatus::Named(_) => {
|
||||
let (icon, epoch, frame) = self.cursor_animation.capture_revision();
|
||||
CursorImageStamp::Named(icon, epoch, frame)
|
||||
}
|
||||
CursorImageStatus::Surface(surface) => {
|
||||
let hotspot = smithay::wayland::compositor::with_states(surface, |states| {
|
||||
states
|
||||
.data_map
|
||||
.get::<smithay::input::pointer::CursorImageSurfaceData>()
|
||||
.map(|data| data.lock().expect("cursor metadata mutex poisoned").hotspot)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
CursorImageStamp::Surface(surface_stamp(surface, None), hotspot)
|
||||
}
|
||||
CursorImageStatus::Hidden => CursorImageStamp::Hidden,
|
||||
};
|
||||
Some(CursorStamp {
|
||||
location: pointer.current_location(),
|
||||
geometry,
|
||||
image,
|
||||
})
|
||||
}
|
||||
|
||||
fn capture_source_geometry(
|
||||
&self,
|
||||
source: &ImageCaptureSource,
|
||||
) -> Option<(Size<i32, Buffer>, Output)> {
|
||||
if self.session_lock.active() || !source.alive() || !super::screencopy::capture_allowed() {
|
||||
return None;
|
||||
}
|
||||
let (size, output) = match source.user_data().get::<Source>()? {
|
||||
Source::Workspace(view) => {
|
||||
if !view.valid(self) {
|
||||
return None;
|
||||
}
|
||||
(view.size, view.output.clone())
|
||||
}
|
||||
Source::Window(id) => {
|
||||
let info = self.window_capture_info(*id)?;
|
||||
let size = info.geometry.size.to_physical_precise_round(info.scale);
|
||||
(Size::from((size.w, size.h)), info.output)
|
||||
}
|
||||
Source::Output(weak) => {
|
||||
let output = weak.upgrade()?;
|
||||
if !self.output_ids.contains_key(&output) {
|
||||
return None;
|
||||
}
|
||||
let size = output
|
||||
.current_transform()
|
||||
.transform_size(output.current_mode()?.size);
|
||||
(Size::from((size.w, size.h)), output)
|
||||
}
|
||||
};
|
||||
let bytes = u64::try_from(size.w)
|
||||
.ok()?
|
||||
.checked_mul(u64::try_from(size.h).ok()?)?
|
||||
.checked_mul(4)?;
|
||||
if bytes == 0 || bytes > 128 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
Some((size, output))
|
||||
}
|
||||
|
||||
fn capture_dma_constraints(&mut self, output: &Output) -> Option<Arc<DmabufConstraints>> {
|
||||
if let Some(backend) = self.nested_backend.clone() {
|
||||
backend
|
||||
.try_borrow_mut()
|
||||
.ok()
|
||||
.and_then(|mut backend| dma_constraints(backend.renderer()))
|
||||
} else {
|
||||
self.direct_backend
|
||||
.as_mut()
|
||||
.and_then(|backend| backend.capture_renderer(output))
|
||||
.and_then(|renderer| dma_constraints(renderer))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn refresh_capture_source(&mut self, source: &ImageCaptureSource) {
|
||||
let mut outputs = Vec::new();
|
||||
for active in &mut self.image_capture.active {
|
||||
if active.session.source() != *source {
|
||||
continue;
|
||||
}
|
||||
active.revision = None;
|
||||
active.window_stamp = None;
|
||||
if active.pending.is_some()
|
||||
&& let Some(Source::Output(weak)) = source.user_data().get::<Source>()
|
||||
&& let Some(output) = weak.upgrade()
|
||||
&& !outputs.contains(&output)
|
||||
{
|
||||
outputs.push(output);
|
||||
}
|
||||
}
|
||||
if !outputs.is_empty() {
|
||||
crate::backends::direct::schedule_capture(self, &outputs);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_capture_sessions(&mut self) {
|
||||
self.image_capture.copy.cleanup();
|
||||
let mut callback_delay: Option<Duration> = None;
|
||||
// Yield between distinct workspace compositions so input and display
|
||||
// deadlines can run even when several consumers request changed views.
|
||||
let mut workspace_compositions = 1usize;
|
||||
let mut sessions = std::mem::take(&mut self.image_capture.active);
|
||||
let mut workspaces = std::mem::take(&mut self.image_capture.workspaces);
|
||||
let mut reserved: u64 = sessions
|
||||
.iter()
|
||||
.map(|active| stream_bytes(active.size))
|
||||
.sum();
|
||||
sessions.retain_mut(|active| {
|
||||
let Some((size, output)) = self.capture_source_geometry(&active.session.source())
|
||||
else {
|
||||
self.cancel_session_fences(active.session.as_ref());
|
||||
reserved = reserved.saturating_sub(stream_bytes(active.size));
|
||||
return false;
|
||||
};
|
||||
let dma = self.capture_dma_constraints(&output);
|
||||
let dma_node = dma.as_ref().map(|dma| dma.node);
|
||||
if size != active.size || dma_node != active.dma_node {
|
||||
let next_reserved = reserved
|
||||
.saturating_sub(stream_bytes(active.size))
|
||||
.saturating_add(stream_bytes(size));
|
||||
if next_reserved > MAX_STREAM_BYTES {
|
||||
self.cancel_session_fences(active.session.as_ref());
|
||||
reserved = reserved.saturating_sub(stream_bytes(active.size));
|
||||
return false;
|
||||
}
|
||||
reserved = next_reserved;
|
||||
if let Some(Source::Workspace(source)) =
|
||||
active.session.source().user_data().get::<Source>()
|
||||
{
|
||||
// GPU resources belong to the renderer that created them.
|
||||
workspaces.remove(&(source.id, active.session.draw_cursor()));
|
||||
}
|
||||
active.size = size;
|
||||
active.dma_node = dma_node;
|
||||
active.revision = None;
|
||||
active.window_stamp = None;
|
||||
active.session.update_constraints(BufferConstraints {
|
||||
size,
|
||||
shm: vec![wl_shm::Format::Xrgb8888],
|
||||
dma: dma.as_deref().cloned(),
|
||||
});
|
||||
if let Some(frame) = active.pending.take() {
|
||||
frame.fail(FailureReason::BufferConstraints);
|
||||
}
|
||||
}
|
||||
if let Some(Source::Workspace(source)) =
|
||||
active.session.source().user_data().get::<Source>()
|
||||
&& source_active(&active.session.source())
|
||||
{
|
||||
let source = source.clone();
|
||||
let key = (source.id, active.session.draw_cursor());
|
||||
if let Some(frame) = active.pending.take() {
|
||||
let view = workspaces.entry(key).or_insert_with(|| {
|
||||
super::workspace_capture::WorkspaceCapture::new(
|
||||
source.clone(),
|
||||
self.capture_epoch,
|
||||
)
|
||||
});
|
||||
if !view.source.valid(self) {
|
||||
*view = super::workspace_capture::WorkspaceCapture::new(
|
||||
source.clone(),
|
||||
self.capture_epoch,
|
||||
);
|
||||
}
|
||||
if let Some(delay) = super::workspace_capture::process(
|
||||
view,
|
||||
self,
|
||||
active,
|
||||
frame,
|
||||
&mut workspace_compositions,
|
||||
) {
|
||||
callback_delay =
|
||||
Some(callback_delay.map_or(delay, |existing| existing.min(delay)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(Source::Window(id)) = active.session.source().user_data().get::<Source>() {
|
||||
let id = *id;
|
||||
if let Some(frame) = active.pending.take() {
|
||||
let Some(info) = self.window_capture_info(id) else {
|
||||
return false;
|
||||
};
|
||||
let callback_interval = Duration::from_nanos(
|
||||
1_000_000_000_000
|
||||
/ info
|
||||
.output
|
||||
.current_mode()
|
||||
.map(|mode| mode.refresh.max(1000) as u64)
|
||||
.unwrap_or(60_000),
|
||||
);
|
||||
if let Some(toplevel) = info.window.toplevel()
|
||||
&& pending_frame_callbacks(toplevel.wl_surface())
|
||||
{
|
||||
smithay::desktop::utils::send_frames_surface_tree(
|
||||
toplevel.wl_surface(),
|
||||
&info.output,
|
||||
self.start_time.elapsed(),
|
||||
Some(callback_interval),
|
||||
|_, _| None,
|
||||
);
|
||||
}
|
||||
let mut stamp = WindowStamp {
|
||||
surfaces: Vec::new(),
|
||||
geometry: info.geometry,
|
||||
scale: info.scale,
|
||||
epoch: self.capture_epoch,
|
||||
cursor: None,
|
||||
};
|
||||
if let Some(toplevel) = info.window.toplevel() {
|
||||
stamp.surfaces = surface_stamp(toplevel.wl_surface(), Some(info.geometry));
|
||||
}
|
||||
let cursor = active
|
||||
.session
|
||||
.draw_cursor()
|
||||
.then(|| self.window_capture_cursor(id, &info.window, info.geometry))
|
||||
.flatten();
|
||||
if let Some(geometry) = cursor
|
||||
&& let Some(pointer) = self.seat.get_pointer()
|
||||
{
|
||||
use smithay::input::pointer::CursorImageStatus;
|
||||
let image = match &self.cursor_status {
|
||||
CursorImageStatus::Named(_) => {
|
||||
let (icon, epoch, frame) = self.cursor_animation.capture_revision();
|
||||
CursorImageStamp::Named(icon, epoch, frame)
|
||||
}
|
||||
CursorImageStatus::Surface(surface) => {
|
||||
let hotspot =
|
||||
smithay::wayland::compositor::with_states(surface, |states| {
|
||||
states
|
||||
.data_map
|
||||
.get::<smithay::input::pointer::CursorImageSurfaceData>(
|
||||
)
|
||||
.map(|data| {
|
||||
data.lock()
|
||||
.expect("cursor metadata mutex poisoned")
|
||||
.hotspot
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
CursorImageStamp::Surface(surface_stamp(surface, None), hotspot)
|
||||
}
|
||||
CursorImageStatus::Hidden => CursorImageStamp::Hidden,
|
||||
};
|
||||
stamp.cursor = Some(CursorStamp {
|
||||
location: pointer.current_location(),
|
||||
geometry,
|
||||
image,
|
||||
});
|
||||
}
|
||||
if active.window_stamp.as_ref() == Some(&stamp) {
|
||||
active.pending = Some(frame);
|
||||
if info
|
||||
.window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| pending_frame_callbacks(toplevel.wl_surface()))
|
||||
{
|
||||
callback_delay =
|
||||
Some(callback_delay.map_or(callback_interval, |delay| {
|
||||
delay.min(callback_interval)
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
crate::backends::direct::capture::touch_cache(self);
|
||||
let result = if let Some(backend) = self.nested_backend.clone() {
|
||||
backend
|
||||
.try_borrow_mut()
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|mut backend| {
|
||||
crate::render::capture_stream_window(
|
||||
backend.renderer(),
|
||||
self,
|
||||
&info,
|
||||
cursor,
|
||||
&frame.buffer(),
|
||||
)
|
||||
})
|
||||
} else if let Some(mut backend) = self.direct_backend.take() {
|
||||
let result = backend
|
||||
.capture_renderer(&info.output)
|
||||
.ok_or_else(|| "Capture output is unavailable".to_string())
|
||||
.and_then(|renderer| {
|
||||
crate::render::capture_stream_window(
|
||||
renderer,
|
||||
self,
|
||||
&info,
|
||||
cursor,
|
||||
&frame.buffer(),
|
||||
)
|
||||
});
|
||||
self.direct_backend = Some(backend);
|
||||
result
|
||||
} else {
|
||||
Err("Capture renderer is unavailable".into())
|
||||
};
|
||||
active.window_stamp = Some(stamp);
|
||||
self.finish_stream_frame(frame, active.session.as_ref(), result);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
workspaces.retain(|(id, cursor), view| {
|
||||
let matches = |active: &Active| {
|
||||
active.session.draw_cursor() == *cursor
|
||||
&& matches!(active.session.source().user_data().get::<Source>(), Some(Source::Workspace(source)) if source.id == *id)
|
||||
};
|
||||
if !sessions.iter().any(matches) { return false; }
|
||||
let pending = sessions.iter().any(|active| matches(active)
|
||||
&& active.pending.is_some() && source_active(&active.session.source()));
|
||||
if !pending && view.target.is_some() {
|
||||
let idle = view.last_used.elapsed();
|
||||
if idle >= Duration::from_secs(2) {
|
||||
view.release_idle_target();
|
||||
} else {
|
||||
let delay = Duration::from_secs(2) - idle;
|
||||
callback_delay = Some(callback_delay.map_or(delay, |existing| existing.min(delay)));
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
self.image_capture
|
||||
.workspace_callbacks
|
||||
.retain(|id, _| workspaces.keys().any(|(workspace, _)| id == workspace));
|
||||
self.image_capture.workspaces = workspaces;
|
||||
self.image_capture.active = sessions;
|
||||
if let Some(delay) = callback_delay {
|
||||
let deadline = Instant::now() + delay;
|
||||
if self
|
||||
.image_capture
|
||||
.callback_deadline
|
||||
.is_none_or(|previous| deadline < previous)
|
||||
{
|
||||
if let Some(timer) = self.image_capture.callback_timer.take() {
|
||||
self.loop_handle.remove(timer);
|
||||
}
|
||||
self.image_capture.callback_deadline = Some(deadline);
|
||||
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
|
||||
match self
|
||||
.loop_handle
|
||||
.insert_source(Timer::from_duration(delay), |_, _, state| {
|
||||
state.image_capture.callback_timer = None;
|
||||
state.image_capture.callback_deadline = None;
|
||||
TimeoutAction::Drop
|
||||
}) {
|
||||
Ok(token) => self.image_capture.callback_timer = Some(token),
|
||||
Err(error) => {
|
||||
self.image_capture.callback_deadline = None;
|
||||
tracing::warn!(%error, "could not schedule capture callbacks")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(timer) = self.image_capture.callback_timer.take() {
|
||||
self.loop_handle.remove(timer);
|
||||
self.image_capture.callback_deadline = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn process_output_streams(
|
||||
&mut self,
|
||||
renderer: &mut GlesRenderer,
|
||||
texture: &GlesTexture,
|
||||
output: &Output,
|
||||
cursor: bool,
|
||||
revision: u64,
|
||||
) {
|
||||
let mut sessions = std::mem::take(&mut self.image_capture.active);
|
||||
for active in &mut sessions {
|
||||
let matches = matches!(active.session.source().user_data().get::<Source>(), Some(Source::Output(weak)) if weak.upgrade().as_ref() == Some(output));
|
||||
if matches {
|
||||
let size = output.current_transform().transform_size(texture.size());
|
||||
if active.size != Size::from((size.w, size.h)) {
|
||||
if let Some(frame) = active.pending.take() {
|
||||
frame.fail(FailureReason::BufferConstraints);
|
||||
}
|
||||
active.revision = None;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if matches
|
||||
&& active.session.draw_cursor() == cursor
|
||||
&& active.revision != Some(revision)
|
||||
&& let Some(frame) = active.pending.take()
|
||||
{
|
||||
let result = crate::render::capture_stream_texture(
|
||||
renderer,
|
||||
texture,
|
||||
&frame.buffer(),
|
||||
output.current_transform(),
|
||||
false,
|
||||
);
|
||||
active.revision = Some(revision);
|
||||
self.finish_stream_frame(frame, active.session.as_ref(), result);
|
||||
}
|
||||
}
|
||||
self.image_capture.active = sessions;
|
||||
}
|
||||
|
||||
fn cancel_session_fences(&mut self, session: SessionRef) {
|
||||
let fences: Vec<_> = self
|
||||
.image_capture
|
||||
.in_flight
|
||||
.iter()
|
||||
.filter_map(|(id, pending)| (pending.session == session).then_some(*id))
|
||||
.collect();
|
||||
for id in fences {
|
||||
self.cancel_capture_fence(id);
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_capture_fence(&mut self, id: u64) {
|
||||
if let Some(pending) = self.image_capture.in_flight.remove(&id) {
|
||||
self.loop_handle.remove(pending.fence);
|
||||
self.loop_handle.remove(pending.timeout);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish_stream_frame(
|
||||
&mut self,
|
||||
frame: Frame,
|
||||
session: SessionRef,
|
||||
result: Result<smithay::backend::renderer::sync::SyncPoint, String>,
|
||||
) {
|
||||
match result {
|
||||
Ok(sync) if sync.is_reached() => frame.success(
|
||||
Transform::Normal,
|
||||
None,
|
||||
smithay::utils::Clock::<smithay::utils::Monotonic>::new().now(),
|
||||
),
|
||||
Ok(sync) => {
|
||||
let Some(fd) = sync.export() else {
|
||||
frame.fail(FailureReason::Unknown);
|
||||
return;
|
||||
};
|
||||
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
|
||||
use smithay::reexports::calloop::{Interest, Mode, PostAction, generic::Generic};
|
||||
self.image_capture.next_fence = self.image_capture.next_fence.wrapping_add(1);
|
||||
let id = self.image_capture.next_fence;
|
||||
let source = Generic::new(fd, Interest::READ, Mode::OneShot);
|
||||
let fence = match self.loop_handle.insert_source(source, move |_, _, state| {
|
||||
if let Some(pending) = state.image_capture.in_flight.remove(&id) {
|
||||
state.loop_handle.remove(pending.timeout);
|
||||
if pending.epoch == state.capture_epoch
|
||||
&& state
|
||||
.capture_source_geometry(&pending.session.source())
|
||||
.is_some()
|
||||
&& !pending.frame.has_failed()
|
||||
{
|
||||
pending.frame.success(
|
||||
Transform::Normal,
|
||||
None,
|
||||
smithay::utils::Clock::<smithay::utils::Monotonic>::new().now(),
|
||||
);
|
||||
} else {
|
||||
pending.frame.fail(
|
||||
if state
|
||||
.capture_source_geometry(&pending.session.source())
|
||||
.is_some()
|
||||
{
|
||||
FailureReason::BufferConstraints
|
||||
} else {
|
||||
FailureReason::Stopped
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(PostAction::Remove)
|
||||
}) {
|
||||
Ok(token) => token,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "could not register capture fence");
|
||||
frame.fail(FailureReason::Unknown);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let timeout = match self.loop_handle.insert_source(
|
||||
Timer::from_duration(Duration::from_secs(5)),
|
||||
move |_, _, state| {
|
||||
if let Some(pending) = state.image_capture.in_flight.remove(&id) {
|
||||
state.loop_handle.remove(pending.fence);
|
||||
pending.frame.fail(FailureReason::Stopped);
|
||||
state
|
||||
.image_capture
|
||||
.active
|
||||
.retain(|active| active.session.as_ref() != pending.session);
|
||||
}
|
||||
TimeoutAction::Drop
|
||||
},
|
||||
) {
|
||||
Ok(token) => token,
|
||||
Err(error) => {
|
||||
self.loop_handle.remove(fence);
|
||||
tracing::warn!(%error, "could not register capture deadline");
|
||||
frame.fail(FailureReason::Unknown);
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.image_capture.in_flight.insert(
|
||||
id,
|
||||
InFlight {
|
||||
frame,
|
||||
session,
|
||||
epoch: self.capture_epoch,
|
||||
fence,
|
||||
timeout,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "stream capture failed");
|
||||
frame.fail(FailureReason::Unknown);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "workspace_capture_tests.rs"]
|
||||
mod workspace_tests;
|
||||
@@ -1,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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¤t);
|
||||
*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(¤t);
|
||||
let requested_focus = policy.keyboard_interactivity == KeyboardInteractivity::OnDemand
|
||||
&& previous
|
||||
.as_ref()
|
||||
.is_some_and(|(_, keyboard)| *keyboard == KeyboardInteractivity::None);
|
||||
*previous = Some((current, policy.keyboard_interactivity));
|
||||
(changed, requested_focus)
|
||||
};
|
||||
|
||||
if changed {
|
||||
@@ -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
@@ -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;
|
||||
@@ -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]
|
||||
|
||||
@@ -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(®ion(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(®ion(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(®ion(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(®ion(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(®ion(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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
Reference in new issue
Block a user