mirror of
https://github.com/niri-wm/niri.git
synced 2026-06-21 02:01:55 +07:00
Compare commits
209 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce76877b04 | |||
| 62b8b11909 | |||
| fefc0bc0a7 | |||
| 0b1a6c76ec | |||
| 485e667fec | |||
| 8f442dee06 | |||
| 9c09bc730f | |||
| 7b065f8618 | |||
| 60fbcd2329 | |||
| 5ac440a760 | |||
| 0e3d078a85 | |||
| 36efd6e3f9 | |||
| 30a9c6c31b | |||
| bc0a06226a | |||
| ed799f5afc | |||
| 007d35541d | |||
| e46a27351d | |||
| 56901eed5d | |||
| 48fe08caf4 | |||
| df00f0328e | |||
| d85eaf9799 | |||
| 25cbb739ae | |||
| 88339633b1 | |||
| 22e43193e0 | |||
| 7a2379ad35 | |||
| fe2c2eec29 | |||
| 746a7e81b7 | |||
| 51b6a495c5 | |||
| bb40a35ccf | |||
| 37c6412e80 | |||
| 19c8fca836 | |||
| 186e0b608a | |||
| ce501bca9e | |||
| 45e9bb769d | |||
| dfb3683187 | |||
| ce9ba00d54 | |||
| 37458d94b2 | |||
| 044f14f8f9 | |||
| 4c02f3bba4 | |||
| b55a80c641 | |||
| e0b0b04b44 | |||
| ed14e8da84 | |||
| e53f8527b0 | |||
| da3dc913a6 | |||
| f3f6e79eec | |||
| 83ec369536 | |||
| 97dfd2b1a0 | |||
| 730eab09fb | |||
| a23ce10311 | |||
| 2f18d8e328 | |||
| 7aec37f5c9 | |||
| 07080a0431 | |||
| aa47223c19 | |||
| 10a6d6ae45 | |||
| 7db864d203 | |||
| 8d7b22d1a8 | |||
| 0407ac5e4c | |||
| a18d24fc24 | |||
| 2bacc80c93 | |||
| c91638c12e | |||
| f8a0c9df2c | |||
| 6bab912383 | |||
| 3edb8fd906 | |||
| c9b1514d63 | |||
| 2066737024 | |||
| f918eabe6a | |||
| 0698f167e5 | |||
| 242ebf2945 | |||
| 9858599ac1 | |||
| abac28a65c | |||
| a7186a0441 | |||
| 1911cf3f55 | |||
| 09da884cd8 | |||
| 8ba57fcf25 | |||
| 126ca37d96 | |||
| e6bd60fbb1 | |||
| a605a3f016 | |||
| ef44adea69 | |||
| 7fdb918cd0 | |||
| 8347cc20dc | |||
| 51a176ec4a | |||
| d618daf6b9 | |||
| 357f9157cc | |||
| c4a759e620 | |||
| f369a0f810 | |||
| 71251a7003 | |||
| 2415346caa | |||
| 3f2b7e63ba | |||
| ae89cb6017 | |||
| b6fc4d0455 | |||
| d8265ad34e | |||
| 3b864dc104 | |||
| 15093221ed | |||
| ac7b3fbf19 | |||
| bb8eb377c7 | |||
| 6169c0312a | |||
| 2ae99224ab | |||
| 4f63e13385 | |||
| 46a8f81160 | |||
| 0d6843ea67 | |||
| 6d083ea497 | |||
| 7a42140d6c | |||
| eeb411bef5 | |||
| defd4c5c4d | |||
| 7227e64149 | |||
| c98537a2b0 | |||
| 9c103f1f1d | |||
| 2aff1ec71a | |||
| 3466fc0a66 | |||
| f917932b3e | |||
| 89b7423ee5 | |||
| a2efaf2816 | |||
| 5816691460 | |||
| 4b5e9e6cb0 | |||
| a8259b4cea | |||
| 9d3d7cb0e9 | |||
| 398bc78ea0 | |||
| caa6189448 | |||
| 86f57c2ec7 | |||
| 3cc67897af | |||
| a99489c6c0 | |||
| 0763c7e196 | |||
| fb5c5204e8 | |||
| d207cd385b | |||
| 99bf2df2b4 | |||
| 09be90f4e6 | |||
| dfc42b9d82 | |||
| e2b9838d89 | |||
| 816a0d479c | |||
| 84323d10a4 | |||
| b956f2775c | |||
| 9ff2f83db0 | |||
| 7a10f71ee5 | |||
| ea7add3563 | |||
| e9c6f08906 | |||
| 17343a6740 | |||
| 140d726cd3 | |||
| c37d3b3442 | |||
| 497f186422 | |||
| 3e31c134a6 | |||
| fe682938db | |||
| 6142922ca4 | |||
| 4b44fba14c | |||
| 57639ca84c | |||
| ec88aae77d | |||
| 6c9705dd4b | |||
| eb590c5346 | |||
| 02baad91ac | |||
| 68589cd5a1 | |||
| f2c690802b | |||
| 9d6037b94c | |||
| 7b4cf094ef | |||
| 446bc155ce | |||
| 3289324ce4 | |||
| 9fb02b9571 | |||
| 0e9496b01e | |||
| 82dabc21f3 | |||
| 39b3d62873 | |||
| af080a03cd | |||
| 5f117c61dc | |||
| cb857e32e4 | |||
| 199be26947 | |||
| d5c0c74d2c | |||
| 9bb292ec82 | |||
| a1ba6bcaa0 | |||
| fd389af6d8 | |||
| db09727b18 | |||
| c9d6478c3c | |||
| 758cca5432 | |||
| 78e3daf5f8 | |||
| a99a0b2492 | |||
| bfd42c74f4 | |||
| 501ea47128 | |||
| d2a1cf53b4 | |||
| 62d47d77d5 | |||
| 85cd64e830 | |||
| 55c14eebf2 | |||
| 3fe67549b4 | |||
| 1835b532d9 | |||
| e6d82d3ee3 | |||
| fae3a27641 | |||
| 31e76cf451 | |||
| b8a9be542f | |||
| 59de6918b3 | |||
| bd3d554389 | |||
| af1fca35bb | |||
| 9571d149b2 | |||
| 99358e36b3 | |||
| 8b878f355f | |||
| 395b6d9a4f | |||
| 25f24f668c | |||
| 929eaf0d69 | |||
| ce3103949f | |||
| ef60dd81d7 | |||
| 7671a5d833 | |||
| 3f09352067 | |||
| 5059cce886 | |||
| b20dd226c0 | |||
| acb69c3b4d | |||
| dbe0a9e293 | |||
| d3a79faeec | |||
| 21630ddb5e | |||
| 9e5e0c85bb | |||
| 5cd8040d1a | |||
| 86351938f2 | |||
| ee4c5e23ab | |||
| ffd6acc0aa | |||
| cee11dc329 | |||
| 59a42249a4 |
@@ -1 +1,12 @@
|
||||
# LFS configuration for images from the wiki
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Exclude LFS-tracked files from the tarball
|
||||
/wiki/img/ export-ignore
|
||||
|
||||
# exclude .gitattributes itself from the tarball
|
||||
.gitattributes export-ignore
|
||||
|
||||
# tip: can be tested using
|
||||
# git archive --format=tar.gz --output=source.tar.gz HEAD && \
|
||||
# tar tfvz source.tar.gz | grep -e '.png' -e '.gitattributes'
|
||||
|
||||
@@ -3,7 +3,7 @@ updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
groups:
|
||||
smithay:
|
||||
patterns:
|
||||
@@ -17,6 +17,6 @@ updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
ignore:
|
||||
- dependency-name: "Andrew-Chen-Wang/github-wiki-action"
|
||||
|
||||
@@ -11,6 +11,7 @@ env:
|
||||
RUN_SLOW_TESTS: 1
|
||||
DEPS_APT: curl gcc clang libudev-dev libgbm-dev libxkbcommon-dev libegl1-mesa-dev libwayland-dev libinput-dev libdbus-1-dev libsystemd-dev libseat-dev libpipewire-0.3-dev libpango1.0-dev libdisplay-info-dev
|
||||
DEPS_DNF: cargo gcc clang libudev-devel libgbm-devel libxkbcommon-devel wayland-devel libinput-devel dbus-devel systemd-devel libseat-devel pipewire-devel pango-devel cairo-gobject-devel libdisplay-info-devel
|
||||
DEPS_APK: cargo clang-libclang eudev-dev glib-dev libdisplay-info-dev libinput-dev libseat-dev libxkbcommon-dev mesa-dev pango-dev pipewire-dev tar
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -70,6 +71,26 @@ jobs:
|
||||
- name: Test
|
||||
run: cargo test --all --exclude niri-visual-tests ${{ matrix.release-flag }} -- --nocapture
|
||||
|
||||
build-musl:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
name: alpine musl
|
||||
runs-on: ubuntu-24.04
|
||||
container: alpine:3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
show-progress: false
|
||||
|
||||
- name: Install Deps
|
||||
run: apk add --no-cache ${{ env.DEPS_APK }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build
|
||||
run: cargo build --no-default-features --features dbus,xdp-gnome-screencast
|
||||
|
||||
# Job that runs randomized tests for a longer period of time.
|
||||
randomized-tests:
|
||||
strategy:
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Contributing to niri
|
||||
|
||||
Thanks for your interest in niri!
|
||||
The project has grown quite a bit, and we could use all help that we can.
|
||||
|
||||
Make sure to join our Matrix chat if you have any questions or want to discuss anything: https://matrix.to/#/#niri:matrix.org
|
||||
|
||||
## Issues and discussions
|
||||
|
||||
This is a good way to help many new and existing users without programming knowledge.
|
||||
|
||||
- Answer and help people in GitHub issues and discussions.
|
||||
- Check and point out duplicate issues.
|
||||
- Check for issues that are likely application bugs (and not niri bugs).
|
||||
- Ask or try to reproduce on another non-Smithay-based compositor (sway, KDE/KWin, GNOME/Mutter). If the issue reproduces, it's likely an application bug.
|
||||
- Ask or try to reproduce on another *Smithay-based* compositor ([cosmic-comp], [anvil]). If the issue reproduces only on Smithay compositors, it may be a Smithay bug.
|
||||
- Make sure you're testing the Wayland version of the app on all compositors. Apps may silently use X11 when an X11 `$DISPLAY` is available.
|
||||
- Problems with X11 apps should be reported to [xwayland-satellite]. When testing xwayland-satellite on different compositors, make sure you use xwayland-satellite's `$DISPLAY` (rather than another compositor's built-in Xwayland `$DISPLAY`).
|
||||
- After testing, mention where you could and couldn't reproduce, as well as the exact steps to reproduce if the issue is missing them.
|
||||
- Try to reproduce the issue on your own system and write if you could or couldn't reproduce it.
|
||||
- Upvote issues with a thumbs up reaction as you like.
|
||||
- Ideas and feature requests from new users should go to Discussions.
|
||||
|
||||
If your issue is a duplicate, or not a niri issue (application bug, hardware problem, configuration problem), then please close it.
|
||||
|
||||
## Reviewing and testing pull requests
|
||||
|
||||
With the growing popularity, the volume of pull requests is honestly more than I can manage myself in my free time.
|
||||
I would really appreciate help with testing and reviewing them.
|
||||
|
||||
### Testing
|
||||
|
||||
Pick a pull request you like, then build it and give it a go.
|
||||
The [Developing niri wiki page](https://github.com/YaLTeR/niri/wiki/Developing-niri) has guidance on running niri test builds.
|
||||
|
||||
Be really thorough with your testing.
|
||||
We're striving for polished features in niri, so point out any issues and bugs, even small ones like animation jank.
|
||||
|
||||
- Think of weird edge cases or unexpected interactions and try them to see that they work reasonably.
|
||||
- Try to break the feature and check that it behaves well.
|
||||
- Where applicable, try different input devices: keyboard, mouse, trackpad, tablet, touchscreen.
|
||||
- Watch out for any new performance drops.
|
||||
|
||||
For bug fixes, first make sure you can reproduce the bug, then do the same steps in the PR test build, and verify that the bug is fixed.
|
||||
Be similarly thorough: test any similar or related edge cases to verify that the fix doesn't introduce any new problems.
|
||||
|
||||
Write your findings in the pull request: any issues you found, or if everything worked well.
|
||||
Re-test after the author updates the code to see that your issues were fixed.
|
||||
|
||||
Don't hesitate to test even if someone else already did; very frequently different people will stumble upon different problems.
|
||||
|
||||
### Reviewing
|
||||
|
||||
Reviewing pull requests is something I need the most help with since there are a lot of them, and it's quite time-consuming.
|
||||
Anyone with code accepted into niri is welcome, but this is not a requirement; even if you aren't familiar with Rust you may find some logic problems.
|
||||
|
||||
Pick a pull request, then review its code.
|
||||
|
||||
- Check that everything looks good, check various conditions for edge cases.
|
||||
- See if there are any scenarios the author forgot to handle.
|
||||
- Check that the code fits well into the rest of niri, follows its design and code style.
|
||||
- I understand this is vague. The idea is: look at the surrounding code and at similar modules (e.g. when implementing a new protocol, check other protocol implementations), and try to follow the style and structure.
|
||||
- Check for unrelated changes that may be better split into their own pull request.
|
||||
- Check that the wiki had been updated if necessary (for example, new config options were documented with examples, and have a correct Since annotation).
|
||||
|
||||
Point out everything you find as review comments (don't forget to submit the review).
|
||||
Be constructive and respectful; some people may be new to programming and Rust.
|
||||
As the author addresses the comments and issues, check the code again to see that the problems were fixed.
|
||||
If everything looks good, say that, so I know someone has reviewed the PR.
|
||||
|
||||
As with testing, don't hesitate to look through and comment even if someone else already had.
|
||||
Extra pairs of eyes catch more problems.
|
||||
|
||||
## Writing pull requests
|
||||
|
||||
When creating pull requests, please keep the following in mind.
|
||||
|
||||
- Make sure new features align with niri's design directions. Ideally, there should be an existing issue or discussion where we settled on that solution.
|
||||
- Keep pull requests focused on a single feature or bug fix with no unrelated changes.
|
||||
- Try to split your changes into small, self-contained commits. Every commit should build and pass tests. This makes it much easier to review your PR, and bisect for regressions in the future.
|
||||
- When addressing PR comments, try to squash the changes straight into the relevant commits.
|
||||
- In some cases when the requested changes are big/unclear, you can leave them as separate commits on top, but please squash and otherwise clean up the history when the changes are finalized.
|
||||
- To update the main branch, please rebase instead of merging. Try to force-push the main update rebase separately from other changes, this way it's easy to skip during review since it's usually not interesting.
|
||||
- When working on bigger features, I usually start with a big messy commit, then gradually split out smaller self-contained changes from it as the code gets into shape.
|
||||
- [git-rebase.io](https://git-rebase.io/) is a helpful guide for splitting commits and cleaning up history in git.
|
||||
- When you address a review comment, mark it as resolved.
|
||||
- Remember to [run tests](https://github.com/YaLTeR/niri/wiki/Developing-niri#tests) and format the code with `cargo +nightly fmt --all`.
|
||||
- For new layout actions, remember to add them to the randomized tests. For weird Wayland handling, adding client-server tests in `src/tests/` could be very useful.
|
||||
- Test your changes by hand thoroughly, including for edge cases and weird interactions. See the Testing section above for some tips.
|
||||
- Remember to document new config options on the wiki.
|
||||
- When opening a pull request, ensure "Allow edits from maintainers" is enabled, so I can make final tweaks before merging.
|
||||
|
||||
|
||||
[cosmic-comp]: https://github.com/pop-os/cosmic-comp
|
||||
[anvil]: https://github.com/Smithay/smithay/tree/master/anvil
|
||||
[xwayland-satellite]: https://github.com/Supreeeme/xwayland-satellite
|
||||
Generated
+677
-483
File diff suppressed because it is too large
Load Diff
+28
-27
@@ -6,7 +6,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "25.2.0"
|
||||
version = "25.5.1"
|
||||
description = "A scrollable-tiling Wayland compositor"
|
||||
authors = ["Ivan Molodetskikh <yalterz@gmail.com>"]
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -15,15 +15,15 @@ repository = "https://github.com/YaLTeR/niri"
|
||||
rust-version = "1.80.1"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.97"
|
||||
bitflags = "2.9.0"
|
||||
clap = { version = "4.5.34", features = ["derive"] }
|
||||
insta = "1.42.2"
|
||||
anyhow = "1.0.98"
|
||||
bitflags = "2.9.1"
|
||||
clap = { version = "4.5.41", features = ["derive"] }
|
||||
insta = "1.43.1"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
tracing = { version = "0.1.41", features = ["max_level_trace", "release_max_level_debug"] }
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
tracy-client = { version = "0.18.0", default-features = false }
|
||||
tracy-client = { version = "0.18.2", default-features = false }
|
||||
|
||||
[workspace.dependencies.smithay]
|
||||
# version = "0.4.1"
|
||||
@@ -52,34 +52,35 @@ keywords = ["wayland", "compositor", "tiling", "smithay", "wm"]
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
arrayvec = "0.7.6"
|
||||
async-channel = "2.3.1"
|
||||
async-io = { version = "2.4.0", optional = true }
|
||||
atomic = "0.6.0"
|
||||
async-channel = "2.5.0"
|
||||
async-io = { version = "2.4.1", optional = true }
|
||||
atomic = "0.6.1"
|
||||
bitflags.workspace = true
|
||||
bytemuck = { version = "1.22.0", features = ["derive"] }
|
||||
calloop = { version = "0.14.2", features = ["executor", "futures-io"] }
|
||||
bytemuck = { version = "1.23.1", features = ["derive"] }
|
||||
calloop = { version = "0.14.2", features = ["executor", "futures-io", "signals"] }
|
||||
clap = { workspace = true, features = ["string"] }
|
||||
clap_complete = "4.5.47"
|
||||
clap_complete = "4.5.55"
|
||||
clap_complete_nushell = "4.5.8"
|
||||
directories = "6.0.0"
|
||||
drm-ffi = "0.9.0"
|
||||
fastrand = "2.3.0"
|
||||
futures-util = { version = "0.3.31", default-features = false, features = ["std", "io"] }
|
||||
git-version = "0.3.9"
|
||||
glam = "0.30.1"
|
||||
glam = "0.30.4"
|
||||
input = { version = "0.9.1", features = ["libinput_1_21"] }
|
||||
keyframe = { version = "1.1.1", default-features = false }
|
||||
libc = "0.2.171"
|
||||
libc = "0.2.174"
|
||||
libdisplay-info = "0.2.2"
|
||||
log = { version = "0.4.27", features = ["max_level_trace", "release_max_level_debug"] }
|
||||
niri-config = { version = "25.2.0", path = "niri-config" }
|
||||
niri-ipc = { version = "25.2.0", path = "niri-ipc", features = ["clap"] }
|
||||
niri-config = { version = "25.5.1", path = "niri-config" }
|
||||
niri-ipc = { version = "25.5.1", path = "niri-ipc", features = ["clap"] }
|
||||
ordered-float = "5.0.0"
|
||||
pango = { version = "0.20.9", features = ["v1_44"] }
|
||||
pangocairo = "0.20.7"
|
||||
pango = { version = "0.20.12", features = ["v1_44"] }
|
||||
pangocairo = "0.20.10"
|
||||
pipewire = { git = "https://gitlab.freedesktop.org/pipewire/pipewire-rs.git", optional = true, features = ["v0_3_33"] }
|
||||
png = "0.17.16"
|
||||
portable-atomic = { version = "1.11.0", default-features = false, features = ["float"] }
|
||||
profiling = "1.0.16"
|
||||
portable-atomic = { version = "1.11.1", default-features = false, features = ["float"] }
|
||||
profiling = "1.0.17"
|
||||
sd-notify = "0.4.5"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -88,10 +89,10 @@ tracing-subscriber.workspace = true
|
||||
tracing.workspace = true
|
||||
tracy-client.workspace = true
|
||||
url = { version = "2.5.4", optional = true }
|
||||
wayland-backend = "0.3.8"
|
||||
wayland-backend = "0.3.10"
|
||||
wayland-scanner = "0.31.6"
|
||||
xcursor = "0.3.8"
|
||||
zbus = { version = "5.5.0", optional = true }
|
||||
xcursor = "0.3.10"
|
||||
zbus = { version = "5.8.0", optional = true }
|
||||
|
||||
[dependencies.smithay]
|
||||
workspace = true
|
||||
@@ -115,10 +116,10 @@ features = [
|
||||
approx = "0.5.1"
|
||||
calloop-wayland-source = "0.4.0"
|
||||
insta.workspace = true
|
||||
proptest = "1.6.0"
|
||||
proptest-derive = { version = "0.5.1", features = ["boxed_union"] }
|
||||
proptest = "1.7.0"
|
||||
proptest-derive = { version = "0.6.0", features = ["boxed_union"] }
|
||||
rayon = "1.10.0"
|
||||
wayland-client = "0.31.8"
|
||||
wayland-client = "0.31.10"
|
||||
xshell = "0.2.7"
|
||||
|
||||
[features]
|
||||
@@ -152,7 +153,7 @@ insta.opt-level = 3
|
||||
similar.opt-level = 3
|
||||
|
||||
[package.metadata.generate-rpm]
|
||||
version = "25.02"
|
||||
version = "25.05.1"
|
||||
assets = [
|
||||
{ source = "target/release/niri", dest = "/usr/bin/", mode = "755" },
|
||||
{ source = "resources/niri-session", dest = "/usr/bin/", mode = "755" },
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/YaLTeR/niri/wiki/Getting-Started">Getting Started</a> | <a href="https://github.com/YaLTeR/niri/wiki/Configuration:-Overview">Configuration</a> | <a href="https://github.com/YaLTeR/niri/discussions/325">Setup Showcase</a>
|
||||
<a href="https://github.com/YaLTeR/niri/wiki/Getting-Started">Getting Started</a> | <a href="https://github.com/YaLTeR/niri/wiki/Configuration:-Introduction">Configuration</a> | <a href="https://github.com/YaLTeR/niri/discussions/325">Setup Showcase</a>
|
||||
</p>
|
||||
|
||||

|
||||

|
||||
|
||||
## About
|
||||
|
||||
@@ -30,9 +30,11 @@ When a monitor disconnects, its workspaces will move to another monitor, but upo
|
||||
|
||||
- Built from the ground up for scrollable tiling
|
||||
- [Dynamic workspaces](https://github.com/YaLTeR/niri/wiki/Workspaces) like in GNOME
|
||||
- An [Overview](https://github.com/user-attachments/assets/379a5d1f-acdb-4c11-b36c-e85fd91f0995) that zooms out workspaces and windows
|
||||
- Built-in screenshot UI
|
||||
- Monitor and window screencasting through xdg-desktop-portal-gnome
|
||||
- You can [block out](https://github.com/YaLTeR/niri/wiki/Configuration:-Window-Rules#block-out-from) sensitive windows from screencasts
|
||||
- [Dynamic cast target](https://github.com/YaLTeR/niri/wiki/Screencasting#dynamic-screencast-target) that can change what it shows on the go
|
||||
- [Touchpad](https://github.com/YaLTeR/niri/assets/1794388/946a910e-9bec-4cd1-a923-4a9421707515) and [mouse](https://github.com/YaLTeR/niri/assets/1794388/8464e65d-4bf2-44fa-8c8e-5883355bd000) gestures
|
||||
- Group windows into [tabs](https://github.com/YaLTeR/niri/wiki/Tabs)
|
||||
- Configurable layout: gaps, borders, struts, window sizes
|
||||
@@ -44,6 +46,8 @@ When a monitor disconnects, its workspaces will move to another monitor, but upo
|
||||
|
||||
https://github.com/YaLTeR/niri/assets/1794388/bce834b0-f205-434e-a027-b373495f9729
|
||||
|
||||
Also check out this video from Brodie Robertson that showcases a lot of the niri functionality: [Niri Is My New Favorite Wayland Compositor](https://youtu.be/DeYx2exm04M)
|
||||
|
||||
## Status
|
||||
|
||||
Niri is stable for day-to-day use and does most things expected of a Wayland compositor.
|
||||
@@ -72,9 +76,30 @@ I've seen someone use it fine on an Eee PC 900 from 2008, of all things.
|
||||
- Discord and other Electron apps: work well through xwayland-satellite.
|
||||
- Chromium and VSCode: work perfectly natively on Wayland with the right flags.
|
||||
- X11 apps that want to position windows or bars at specific screen coordinates: won't work well; you can run them in a nested compositor like [labwc](https://github.com/YaLTeR/niri/wiki/Xwayland#using-the-labwc-wayland-compositor) or [rootful Xwayland](https://github.com/YaLTeR/niri/wiki/Xwayland#directly-running-xwayland-in-rootful-mode).
|
||||
- Display scaling (integer or fractional) will make X11 apps look blurry; this needs to be supported in xwayland-satellite.
|
||||
- Display scaling (integer or fractional) keeps X11 apps crisp, but you need the latest xwayland-satellite.
|
||||
For games, you can run them in [gamescope] at native resolution, even with display scaling.
|
||||
|
||||
## Media
|
||||
|
||||
[niri: Making a Wayland compositor in Rust](https://youtu.be/Kmz8ODolnDg?list=PLRdS-n5seLRqrmWDQY4KDqtRMfIwU0U3T) · *December 2024*
|
||||
|
||||
My talk from the 2024 Moscow RustCon about niri, and how I do randomized property testing and profiling, and measure input latency.
|
||||
The talk is in Russian, but I prepared full English subtitles that you can find in YouTube's subtitle language selector.
|
||||
|
||||
[An interview with Ivan, the developer behind Niri](https://www.trommelspeicher.de/podcast/special_the_developer_behind_niri) · *June 2025*
|
||||
|
||||
An interview by a German tech podcast Das Triumvirat (in English).
|
||||
We talk about niri development and history, and my experience building and maintaining niri.
|
||||
|
||||
[A tour of the niri scrolling-tiling Wayland compositor](https://lwn.net/Articles/1025866/) · *July 2025*
|
||||
|
||||
An LWN article with a nice overview and introduction to niri.
|
||||
|
||||
## Contributing
|
||||
|
||||
If you'd like to help with niri, there are plenty of both coding- and non-coding-related ways to do so.
|
||||
See [CONTRIBUTING.md](https://github.com/YaLTeR/niri/blob/main/CONTRIBUTING.md) for an overview.
|
||||
|
||||
## Inspiration
|
||||
|
||||
Niri is heavily inspired by [PaperWM] which implements scrollable tiling on top of GNOME Shell.
|
||||
@@ -88,17 +113,10 @@ Here are some other projects which implement a similar workflow:
|
||||
|
||||
- [PaperWM]: scrollable tiling on top of GNOME Shell.
|
||||
- [karousel]: scrollable tiling on top of KDE.
|
||||
- [papersway]: scrollable tiling on top of sway/i3.
|
||||
- [hyprscroller] and [hyprslidr]: scrollable tiling on top of Hyprland.
|
||||
- [scroll](https://github.com/dawsers/scroll) and [papersway]: scrollable tiling on top of sway/i3.
|
||||
- [hyprscrolling] and [hyprslidr]: scrollable tiling on top of Hyprland.
|
||||
- [PaperWM.spoon]: scrollable tiling on top of macOS.
|
||||
|
||||
## Media
|
||||
|
||||
[niri: Making a Wayland compositor in Rust](https://youtu.be/Kmz8ODolnDg?list=PLRdS-n5seLRqrmWDQY4KDqtRMfIwU0U3T)
|
||||
|
||||
My talk from the 2024 Moscow RustCon about niri, and how I do randomized property testing and profiling, and measure input latency.
|
||||
The talk is in Russian, but I prepared full English subtitles that you can find in YouTube's subtitle language selector.
|
||||
|
||||
## Contact
|
||||
|
||||
We have a Matrix chat, feel free to join and ask a question: https://matrix.to/#/#niri:matrix.org
|
||||
@@ -108,7 +126,7 @@ We have a Matrix chat, feel free to join and ask a question: https://matrix.to/#
|
||||
[fuzzel]: https://codeberg.org/dnkl/fuzzel
|
||||
[karousel]: https://github.com/peterfajdiga/karousel
|
||||
[papersway]: https://spwhitton.name/tech/code/papersway/
|
||||
[hyprscroller]: https://github.com/dawsers/hyprscroller
|
||||
[hyprscrolling]: https://github.com/hyprwm/hyprland-plugins/tree/main/hyprscrolling
|
||||
[hyprslidr]: https://gitlab.com/magus/hyprslidr
|
||||
[PaperWM.spoon]: https://github.com/mogenson/PaperWM.spoon
|
||||
[Matrix channel]: https://matrix.to/#/#niri:matrix.org
|
||||
|
||||
Generated
+6
-22
@@ -1,27 +1,12 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nix-filter": {
|
||||
"locked": {
|
||||
"lastModified": 1731533336,
|
||||
"narHash": "sha256-oRam5PS1vcrr5UPgALW0eo1m/5/pls27Z/pabHNy2Ms=",
|
||||
"owner": "numtide",
|
||||
"repo": "nix-filter",
|
||||
"rev": "f7653272fd234696ae94229839a99b73c9ab7de0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "nix-filter",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1742707865,
|
||||
"narHash": "sha256-RVQQZy38O3Zb8yoRJhuFgWo/iDIDj0hEdRTVfhOtzRk=",
|
||||
"lastModified": 1752077645,
|
||||
"narHash": "sha256-HM791ZQtXV93xtCY+ZxG1REzhQenSQO020cu6rHtAPk=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "dd613136ee91f67e5dba3f3f41ac99ae89c5406b",
|
||||
"rev": "be9e214982e20b8310878ac2baa063a961c1bdf6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -33,7 +18,6 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nix-filter": "nix-filter",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
@@ -45,11 +29,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1742697269,
|
||||
"narHash": "sha256-Lpp0XyAtIl1oGJzNmTiTGLhTkcUjwSkEb0gOiNzYFGM=",
|
||||
"lastModified": 1752374969,
|
||||
"narHash": "sha256-Ky3ynEkJXih7mvWyt9DWoiSiZGqPeHLU1tlBU4b0mcc=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "01973c84732f9275c50c5f075dd1f54cc04b3316",
|
||||
"rev": "75fb000638e6d0f57cb1e8b7a4550cbdd8c76f1d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
nix-filter.url = "github:numtide/nix-filter";
|
||||
|
||||
# NOTE: This is not necessary for end users
|
||||
# You can omit it with `inputs.rust-overlay.follows = ""`
|
||||
@@ -18,7 +17,6 @@
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
nix-filter,
|
||||
rust-overlay,
|
||||
}:
|
||||
let
|
||||
@@ -50,16 +48,16 @@
|
||||
pname = "niri";
|
||||
version = self.shortRev or self.dirtyShortRev or "unknown";
|
||||
|
||||
src = nix-filter.lib.filter {
|
||||
root = self;
|
||||
include = [
|
||||
"niri-config"
|
||||
"niri-ipc"
|
||||
"niri-visual-tests"
|
||||
"resources"
|
||||
"src"
|
||||
./Cargo.lock
|
||||
src = lib.fileset.toSource {
|
||||
root = ./.;
|
||||
fileset = lib.fileset.unions [
|
||||
./niri-config
|
||||
./niri-ipc
|
||||
./niri-visual-tests
|
||||
./resources
|
||||
./src
|
||||
./Cargo.toml
|
||||
./Cargo.lock
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bitflags.workspace = true
|
||||
csscolorparser = "0.7.0"
|
||||
csscolorparser = "0.7.2"
|
||||
knuffel = "3.2.0"
|
||||
miette = { version = "5.10.0", features = ["fancy-no-backtrace"] }
|
||||
niri-ipc = { version = "25.2.0", path = "../niri-ipc" }
|
||||
niri-ipc = { version = "25.5.1", path = "../niri-ipc" }
|
||||
regex = "1.11.1"
|
||||
smithay = { workspace = true, features = ["backend_libinput"] }
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -15,6 +15,10 @@ pub struct LayerRule {
|
||||
pub shadow: ShadowRule,
|
||||
#[knuffel(child)]
|
||||
pub geometry_corner_radius: Option<CornerRadius>,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
pub place_within_backdrop: Option<bool>,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
pub baba_is_float: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Debug, Default, Clone, PartialEq)]
|
||||
|
||||
+409
-31
@@ -23,7 +23,8 @@ use smithay::input::keyboard::xkb::{keysym_from_name, KEYSYM_CASE_INSENSITIVE};
|
||||
use smithay::input::keyboard::{Keysym, XkbConfig};
|
||||
use smithay::reexports::input;
|
||||
|
||||
pub const DEFAULT_BACKGROUND_COLOR: Color = Color::from_array_unpremul([0.2, 0.2, 0.2, 1.]);
|
||||
pub const DEFAULT_BACKGROUND_COLOR: Color = Color::from_array_unpremul([0.25, 0.25, 0.25, 1.]);
|
||||
pub const DEFAULT_BACKDROP_COLOR: Color = Color::from_array_unpremul([0.15, 0.15, 0.15, 1.]);
|
||||
|
||||
pub mod layer_rule;
|
||||
|
||||
@@ -61,7 +62,11 @@ pub struct Config {
|
||||
#[knuffel(child, default)]
|
||||
pub gestures: Gestures,
|
||||
#[knuffel(child, default)]
|
||||
pub overview: Overview,
|
||||
#[knuffel(child, default)]
|
||||
pub environment: Environment,
|
||||
#[knuffel(child, default)]
|
||||
pub xwayland_satellite: XwaylandSatellite,
|
||||
#[knuffel(children(name = "window-rule"))]
|
||||
pub window_rules: Vec<WindowRule>,
|
||||
#[knuffel(children(name = "layer-rule"))]
|
||||
@@ -117,6 +122,8 @@ pub struct Keyboard {
|
||||
pub repeat_rate: u8,
|
||||
#[knuffel(child, unwrap(argument), default)]
|
||||
pub track_layout: TrackLayout,
|
||||
#[knuffel(child)]
|
||||
pub numlock: bool,
|
||||
}
|
||||
|
||||
impl Default for Keyboard {
|
||||
@@ -126,6 +133,7 @@ impl Default for Keyboard {
|
||||
repeat_delay: 600,
|
||||
repeat_rate: 25,
|
||||
track_layout: Default::default(),
|
||||
numlock: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,13 +206,15 @@ pub struct Touchpad {
|
||||
#[knuffel(child, unwrap(argument, str))]
|
||||
pub click_method: Option<ClickMethod>,
|
||||
#[knuffel(child, unwrap(argument), default)]
|
||||
pub accel_speed: f64,
|
||||
pub accel_speed: FloatOrInt<-1, 1>,
|
||||
#[knuffel(child, unwrap(argument, str))]
|
||||
pub accel_profile: Option<AccelProfile>,
|
||||
#[knuffel(child, unwrap(argument, str))]
|
||||
pub scroll_method: Option<ScrollMethod>,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
pub scroll_button: Option<u32>,
|
||||
#[knuffel(child)]
|
||||
pub scroll_button_lock: bool,
|
||||
#[knuffel(child, unwrap(argument, str))]
|
||||
pub tap_button_map: Option<TapButtonMap>,
|
||||
#[knuffel(child)]
|
||||
@@ -224,7 +234,7 @@ pub struct Mouse {
|
||||
#[knuffel(child)]
|
||||
pub natural_scroll: bool,
|
||||
#[knuffel(child, unwrap(argument), default)]
|
||||
pub accel_speed: f64,
|
||||
pub accel_speed: FloatOrInt<-1, 1>,
|
||||
#[knuffel(child, unwrap(argument, str))]
|
||||
pub accel_profile: Option<AccelProfile>,
|
||||
#[knuffel(child, unwrap(argument, str))]
|
||||
@@ -232,6 +242,8 @@ pub struct Mouse {
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
pub scroll_button: Option<u32>,
|
||||
#[knuffel(child)]
|
||||
pub scroll_button_lock: bool,
|
||||
#[knuffel(child)]
|
||||
pub left_handed: bool,
|
||||
#[knuffel(child)]
|
||||
pub middle_emulation: bool,
|
||||
@@ -246,7 +258,7 @@ pub struct Trackpoint {
|
||||
#[knuffel(child)]
|
||||
pub natural_scroll: bool,
|
||||
#[knuffel(child, unwrap(argument), default)]
|
||||
pub accel_speed: f64,
|
||||
pub accel_speed: FloatOrInt<-1, 1>,
|
||||
#[knuffel(child, unwrap(argument, str))]
|
||||
pub accel_profile: Option<AccelProfile>,
|
||||
#[knuffel(child, unwrap(argument, str))]
|
||||
@@ -254,6 +266,8 @@ pub struct Trackpoint {
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
pub scroll_button: Option<u32>,
|
||||
#[knuffel(child)]
|
||||
pub scroll_button_lock: bool,
|
||||
#[knuffel(child)]
|
||||
pub left_handed: bool,
|
||||
#[knuffel(child)]
|
||||
pub middle_emulation: bool,
|
||||
@@ -266,7 +280,7 @@ pub struct Trackball {
|
||||
#[knuffel(child)]
|
||||
pub natural_scroll: bool,
|
||||
#[knuffel(child, unwrap(argument), default)]
|
||||
pub accel_speed: f64,
|
||||
pub accel_speed: FloatOrInt<-1, 1>,
|
||||
#[knuffel(child, unwrap(argument, str))]
|
||||
pub accel_profile: Option<AccelProfile>,
|
||||
#[knuffel(child, unwrap(argument, str))]
|
||||
@@ -274,6 +288,8 @@ pub struct Trackball {
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
pub scroll_button: Option<u32>,
|
||||
#[knuffel(child)]
|
||||
pub scroll_button_lock: bool,
|
||||
#[knuffel(child)]
|
||||
pub left_handed: bool,
|
||||
#[knuffel(child)]
|
||||
pub middle_emulation: bool,
|
||||
@@ -442,8 +458,10 @@ pub struct Output {
|
||||
pub variable_refresh_rate: Option<Vrr>,
|
||||
#[knuffel(child)]
|
||||
pub focus_at_startup: bool,
|
||||
#[knuffel(child, default = DEFAULT_BACKGROUND_COLOR)]
|
||||
pub background_color: Color,
|
||||
#[knuffel(child)]
|
||||
pub background_color: Option<Color>,
|
||||
#[knuffel(child)]
|
||||
pub backdrop_color: Option<Color>,
|
||||
}
|
||||
|
||||
impl Output {
|
||||
@@ -471,7 +489,8 @@ impl Default for Output {
|
||||
position: None,
|
||||
mode: None,
|
||||
variable_refresh_rate: None,
|
||||
background_color: DEFAULT_BACKGROUND_COLOR,
|
||||
background_color: None,
|
||||
backdrop_color: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -532,6 +551,8 @@ pub struct Layout {
|
||||
pub gaps: FloatOrInt<0, 65535>,
|
||||
#[knuffel(child, default)]
|
||||
pub struts: Struts,
|
||||
#[knuffel(child, default = DEFAULT_BACKGROUND_COLOR)]
|
||||
pub background_color: Color,
|
||||
}
|
||||
|
||||
impl Default for Layout {
|
||||
@@ -551,6 +572,7 @@ impl Default for Layout {
|
||||
gaps: FloatOrInt(16.),
|
||||
struts: Default::default(),
|
||||
preset_window_heights: Default::default(),
|
||||
background_color: DEFAULT_BACKGROUND_COLOR,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -571,10 +593,14 @@ pub struct FocusRing {
|
||||
pub active_color: Color,
|
||||
#[knuffel(child, default = Self::default().inactive_color)]
|
||||
pub inactive_color: Color,
|
||||
#[knuffel(child, default = Self::default().urgent_color)]
|
||||
pub urgent_color: Color,
|
||||
#[knuffel(child)]
|
||||
pub active_gradient: Option<Gradient>,
|
||||
#[knuffel(child)]
|
||||
pub inactive_gradient: Option<Gradient>,
|
||||
#[knuffel(child)]
|
||||
pub urgent_gradient: Option<Gradient>,
|
||||
}
|
||||
|
||||
impl Default for FocusRing {
|
||||
@@ -584,8 +610,10 @@ impl Default for FocusRing {
|
||||
width: FloatOrInt(4.),
|
||||
active_color: Color::from_rgba8_unpremul(127, 200, 255, 255),
|
||||
inactive_color: Color::from_rgba8_unpremul(80, 80, 80, 255),
|
||||
urgent_color: Color::from_rgba8_unpremul(155, 0, 0, 255),
|
||||
active_gradient: None,
|
||||
inactive_gradient: None,
|
||||
urgent_gradient: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -657,10 +685,14 @@ pub struct Border {
|
||||
pub active_color: Color,
|
||||
#[knuffel(child, default = Self::default().inactive_color)]
|
||||
pub inactive_color: Color,
|
||||
#[knuffel(child, default = Self::default().urgent_color)]
|
||||
pub urgent_color: Color,
|
||||
#[knuffel(child)]
|
||||
pub active_gradient: Option<Gradient>,
|
||||
#[knuffel(child)]
|
||||
pub inactive_gradient: Option<Gradient>,
|
||||
#[knuffel(child)]
|
||||
pub urgent_gradient: Option<Gradient>,
|
||||
}
|
||||
|
||||
impl Default for Border {
|
||||
@@ -670,8 +702,10 @@ impl Default for Border {
|
||||
width: FloatOrInt(4.),
|
||||
active_color: Color::from_rgba8_unpremul(255, 200, 127, 255),
|
||||
inactive_color: Color::from_rgba8_unpremul(80, 80, 80, 255),
|
||||
urgent_color: Color::from_rgba8_unpremul(155, 0, 0, 255),
|
||||
active_gradient: None,
|
||||
inactive_gradient: None,
|
||||
urgent_gradient: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -683,8 +717,10 @@ impl From<Border> for FocusRing {
|
||||
width: value.width,
|
||||
active_color: value.active_color,
|
||||
inactive_color: value.inactive_color,
|
||||
urgent_color: value.urgent_color,
|
||||
active_gradient: value.active_gradient,
|
||||
inactive_gradient: value.inactive_gradient,
|
||||
urgent_gradient: value.urgent_gradient,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,8 +732,10 @@ impl From<FocusRing> for Border {
|
||||
width: value.width,
|
||||
active_color: value.active_color,
|
||||
inactive_color: value.inactive_color,
|
||||
urgent_color: value.urgent_color,
|
||||
active_gradient: value.active_gradient,
|
||||
inactive_gradient: value.inactive_gradient,
|
||||
urgent_gradient: value.urgent_gradient,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -745,6 +783,49 @@ pub struct ShadowOffset {
|
||||
pub y: FloatOrInt<-65535, 65535>,
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)]
|
||||
pub struct WorkspaceShadow {
|
||||
#[knuffel(child)]
|
||||
pub off: bool,
|
||||
#[knuffel(child, default = Self::default().offset)]
|
||||
pub offset: ShadowOffset,
|
||||
#[knuffel(child, unwrap(argument), default = Self::default().softness)]
|
||||
pub softness: FloatOrInt<0, 1024>,
|
||||
#[knuffel(child, unwrap(argument), default = Self::default().spread)]
|
||||
pub spread: FloatOrInt<-1024, 1024>,
|
||||
#[knuffel(child, default = Self::default().color)]
|
||||
pub color: Color,
|
||||
}
|
||||
|
||||
impl Default for WorkspaceShadow {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
off: false,
|
||||
offset: ShadowOffset {
|
||||
x: FloatOrInt(0.),
|
||||
y: FloatOrInt(10.),
|
||||
},
|
||||
softness: FloatOrInt(40.),
|
||||
spread: FloatOrInt(10.),
|
||||
color: Color::from_rgba8_unpremul(0, 0, 0, 0x50),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkspaceShadow> for Shadow {
|
||||
fn from(value: WorkspaceShadow) -> Self {
|
||||
Self {
|
||||
on: !value.off,
|
||||
offset: value.offset,
|
||||
softness: value.softness,
|
||||
spread: value.spread,
|
||||
draw_behind_window: false,
|
||||
color: value.color,
|
||||
inactive_color: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TabIndicator {
|
||||
#[knuffel(child)]
|
||||
@@ -770,9 +851,13 @@ pub struct TabIndicator {
|
||||
#[knuffel(child)]
|
||||
pub inactive_color: Option<Color>,
|
||||
#[knuffel(child)]
|
||||
pub urgent_color: Option<Color>,
|
||||
#[knuffel(child)]
|
||||
pub active_gradient: Option<Gradient>,
|
||||
#[knuffel(child)]
|
||||
pub inactive_gradient: Option<Gradient>,
|
||||
#[knuffel(child)]
|
||||
pub urgent_gradient: Option<Gradient>,
|
||||
}
|
||||
|
||||
impl Default for TabIndicator {
|
||||
@@ -791,8 +876,10 @@ impl Default for TabIndicator {
|
||||
corner_radius: FloatOrInt(0.),
|
||||
active_color: None,
|
||||
inactive_color: None,
|
||||
urgent_color: None,
|
||||
active_gradient: None,
|
||||
inactive_gradient: None,
|
||||
urgent_gradient: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -954,6 +1041,8 @@ pub struct Struts {
|
||||
pub struct HotkeyOverlay {
|
||||
#[knuffel(child)]
|
||||
pub skip_at_startup: bool,
|
||||
#[knuffel(child)]
|
||||
pub hide_not_bound: bool,
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -966,8 +1055,8 @@ pub struct Clipboard {
|
||||
pub struct Animations {
|
||||
#[knuffel(child)]
|
||||
pub off: bool,
|
||||
#[knuffel(child, unwrap(argument), default = 1.)]
|
||||
pub slowdown: f64,
|
||||
#[knuffel(child, unwrap(argument), default = FloatOrInt(1.))]
|
||||
pub slowdown: FloatOrInt<0, { i32::MAX }>,
|
||||
#[knuffel(child, default)]
|
||||
pub workspace_switch: WorkspaceSwitchAnim,
|
||||
#[knuffel(child, default)]
|
||||
@@ -984,13 +1073,15 @@ pub struct Animations {
|
||||
pub config_notification_open_close: ConfigNotificationOpenCloseAnim,
|
||||
#[knuffel(child, default)]
|
||||
pub screenshot_ui_open: ScreenshotUiOpenAnim,
|
||||
#[knuffel(child, default)]
|
||||
pub overview_open_close: OverviewOpenCloseAnim,
|
||||
}
|
||||
|
||||
impl Default for Animations {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
off: false,
|
||||
slowdown: 1.,
|
||||
slowdown: FloatOrInt(1.),
|
||||
workspace_switch: Default::default(),
|
||||
horizontal_view_movement: Default::default(),
|
||||
window_movement: Default::default(),
|
||||
@@ -999,6 +1090,7 @@ impl Default for Animations {
|
||||
window_resize: Default::default(),
|
||||
config_notification_open_close: Default::default(),
|
||||
screenshot_ui_open: Default::default(),
|
||||
overview_open_close: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1146,6 +1238,22 @@ impl Default for ScreenshotUiOpenAnim {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct OverviewOpenCloseAnim(pub Animation);
|
||||
|
||||
impl Default for OverviewOpenCloseAnim {
|
||||
fn default() -> Self {
|
||||
Self(Animation {
|
||||
off: false,
|
||||
kind: AnimationKind::Spring(SpringParams {
|
||||
damping_ratio: 1.,
|
||||
stiffness: 800,
|
||||
epsilon: 0.0001,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Animation {
|
||||
pub off: bool,
|
||||
@@ -1183,6 +1291,10 @@ pub struct SpringParams {
|
||||
pub struct Gestures {
|
||||
#[knuffel(child, default)]
|
||||
pub dnd_edge_view_scroll: DndEdgeViewScroll,
|
||||
#[knuffel(child, default)]
|
||||
pub dnd_edge_workspace_switch: DndEdgeWorkspaceSwitch,
|
||||
#[knuffel(child, default)]
|
||||
pub hot_corners: HotCorners,
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)]
|
||||
@@ -1205,6 +1317,52 @@ impl Default for DndEdgeViewScroll {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)]
|
||||
pub struct DndEdgeWorkspaceSwitch {
|
||||
#[knuffel(child, unwrap(argument), default = Self::default().trigger_height)]
|
||||
pub trigger_height: FloatOrInt<0, 65535>,
|
||||
#[knuffel(child, unwrap(argument), default = Self::default().delay_ms)]
|
||||
pub delay_ms: u16,
|
||||
#[knuffel(child, unwrap(argument), default = Self::default().max_speed)]
|
||||
pub max_speed: FloatOrInt<0, 1_000_000>,
|
||||
}
|
||||
|
||||
impl Default for DndEdgeWorkspaceSwitch {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
trigger_height: FloatOrInt(50.),
|
||||
delay_ms: 100,
|
||||
max_speed: FloatOrInt(1500.),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Debug, Default, Clone, Copy, PartialEq)]
|
||||
pub struct HotCorners {
|
||||
#[knuffel(child)]
|
||||
pub off: bool,
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Overview {
|
||||
#[knuffel(child, unwrap(argument), default = Self::default().zoom)]
|
||||
pub zoom: FloatOrInt<0, 1>,
|
||||
#[knuffel(child, default = Self::default().backdrop_color)]
|
||||
pub backdrop_color: Color,
|
||||
#[knuffel(child, default)]
|
||||
pub workspace_shadow: WorkspaceShadow,
|
||||
}
|
||||
|
||||
impl Default for Overview {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
zoom: FloatOrInt(0.5),
|
||||
backdrop_color: DEFAULT_BACKDROP_COLOR,
|
||||
workspace_shadow: WorkspaceShadow::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Environment(#[knuffel(children)] pub Vec<EnvironmentVariable>);
|
||||
|
||||
@@ -1216,6 +1374,23 @@ pub struct EnvironmentVariable {
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct XwaylandSatellite {
|
||||
#[knuffel(child)]
|
||||
pub off: bool,
|
||||
#[knuffel(child, unwrap(argument), default = Self::default().path)]
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl Default for XwaylandSatellite {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
off: false,
|
||||
path: String::from("xwayland-satellite"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Workspace {
|
||||
#[knuffel(argument)]
|
||||
@@ -1311,6 +1486,8 @@ pub struct Match {
|
||||
#[knuffel(property)]
|
||||
pub is_window_cast_target: Option<bool>,
|
||||
#[knuffel(property)]
|
||||
pub is_urgent: Option<bool>,
|
||||
#[knuffel(property)]
|
||||
pub at_startup: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -1363,9 +1540,13 @@ pub struct BorderRule {
|
||||
#[knuffel(child)]
|
||||
pub inactive_color: Option<Color>,
|
||||
#[knuffel(child)]
|
||||
pub urgent_color: Option<Color>,
|
||||
#[knuffel(child)]
|
||||
pub active_gradient: Option<Gradient>,
|
||||
#[knuffel(child)]
|
||||
pub inactive_gradient: Option<Gradient>,
|
||||
#[knuffel(child)]
|
||||
pub urgent_gradient: Option<Gradient>,
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Debug, Default, Clone, Copy, PartialEq)]
|
||||
@@ -1395,9 +1576,13 @@ pub struct TabIndicatorRule {
|
||||
#[knuffel(child)]
|
||||
pub inactive_color: Option<Color>,
|
||||
#[knuffel(child)]
|
||||
pub urgent_color: Option<Color>,
|
||||
#[knuffel(child)]
|
||||
pub active_gradient: Option<Gradient>,
|
||||
#[knuffel(child)]
|
||||
pub inactive_gradient: Option<Gradient>,
|
||||
#[knuffel(child)]
|
||||
pub urgent_gradient: Option<Gradient>,
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)]
|
||||
@@ -1540,7 +1725,11 @@ pub enum Action {
|
||||
FocusWindowInColumn(#[knuffel(argument)] u8),
|
||||
FocusWindowPrevious,
|
||||
FocusColumnLeft,
|
||||
#[knuffel(skip)]
|
||||
FocusColumnLeftUnderMouse,
|
||||
FocusColumnRight,
|
||||
#[knuffel(skip)]
|
||||
FocusColumnRightUnderMouse,
|
||||
FocusColumnFirst,
|
||||
FocusColumnLast,
|
||||
FocusColumnRightOrFirst,
|
||||
@@ -1589,8 +1778,13 @@ pub enum Action {
|
||||
CenterWindow,
|
||||
#[knuffel(skip)]
|
||||
CenterWindowById(u64),
|
||||
CenterVisibleColumns,
|
||||
FocusWorkspaceDown,
|
||||
#[knuffel(skip)]
|
||||
FocusWorkspaceDownUnderMouse,
|
||||
FocusWorkspaceUp,
|
||||
#[knuffel(skip)]
|
||||
FocusWorkspaceUpUnderMouse,
|
||||
FocusWorkspace(#[knuffel(argument)] WorkspaceReference),
|
||||
FocusWorkspacePrevious,
|
||||
MoveWindowToWorkspaceDown,
|
||||
@@ -1605,9 +1799,12 @@ pub enum Action {
|
||||
reference: WorkspaceReference,
|
||||
focus: bool,
|
||||
},
|
||||
MoveColumnToWorkspaceDown,
|
||||
MoveColumnToWorkspaceUp,
|
||||
MoveColumnToWorkspace(#[knuffel(argument)] WorkspaceReference),
|
||||
MoveColumnToWorkspaceDown(#[knuffel(property(name = "focus"), default = true)] bool),
|
||||
MoveColumnToWorkspaceUp(#[knuffel(property(name = "focus"), default = true)] bool),
|
||||
MoveColumnToWorkspace(
|
||||
#[knuffel(argument)] WorkspaceReference,
|
||||
#[knuffel(property(name = "focus"), default = true)] bool,
|
||||
),
|
||||
MoveWorkspaceDown,
|
||||
MoveWorkspaceUp,
|
||||
MoveWorkspaceToIndex(#[knuffel(argument)] usize),
|
||||
@@ -1716,6 +1913,15 @@ pub enum Action {
|
||||
SetDynamicCastWindowById(u64),
|
||||
SetDynamicCastMonitor(#[knuffel(argument)] Option<String>),
|
||||
ClearDynamicCastTarget,
|
||||
ToggleOverview,
|
||||
OpenOverview,
|
||||
CloseOverview,
|
||||
#[knuffel(skip)]
|
||||
ToggleWindowUrgent(u64),
|
||||
#[knuffel(skip)]
|
||||
SetWindowUrgent(u64),
|
||||
#[knuffel(skip)]
|
||||
UnsetWindowUrgent(u64),
|
||||
}
|
||||
|
||||
impl From<niri_ipc::Action> for Action {
|
||||
@@ -1816,6 +2022,7 @@ impl From<niri_ipc::Action> for Action {
|
||||
niri_ipc::Action::CenterColumn {} => Self::CenterColumn,
|
||||
niri_ipc::Action::CenterWindow { id: None } => Self::CenterWindow,
|
||||
niri_ipc::Action::CenterWindow { id: Some(id) } => Self::CenterWindowById(id),
|
||||
niri_ipc::Action::CenterVisibleColumns {} => Self::CenterVisibleColumns,
|
||||
niri_ipc::Action::FocusWorkspaceDown {} => Self::FocusWorkspaceDown,
|
||||
niri_ipc::Action::FocusWorkspaceUp {} => Self::FocusWorkspaceUp,
|
||||
niri_ipc::Action::FocusWorkspace { reference } => {
|
||||
@@ -1838,10 +2045,14 @@ impl From<niri_ipc::Action> for Action {
|
||||
reference: WorkspaceReference::from(reference),
|
||||
focus,
|
||||
},
|
||||
niri_ipc::Action::MoveColumnToWorkspaceDown {} => Self::MoveColumnToWorkspaceDown,
|
||||
niri_ipc::Action::MoveColumnToWorkspaceUp {} => Self::MoveColumnToWorkspaceUp,
|
||||
niri_ipc::Action::MoveColumnToWorkspace { reference } => {
|
||||
Self::MoveColumnToWorkspace(WorkspaceReference::from(reference))
|
||||
niri_ipc::Action::MoveColumnToWorkspaceDown { focus } => {
|
||||
Self::MoveColumnToWorkspaceDown(focus)
|
||||
}
|
||||
niri_ipc::Action::MoveColumnToWorkspaceUp { focus } => {
|
||||
Self::MoveColumnToWorkspaceUp(focus)
|
||||
}
|
||||
niri_ipc::Action::MoveColumnToWorkspace { reference, focus } => {
|
||||
Self::MoveColumnToWorkspace(WorkspaceReference::from(reference), focus)
|
||||
}
|
||||
niri_ipc::Action::MoveWorkspaceDown {} => Self::MoveWorkspaceDown,
|
||||
niri_ipc::Action::MoveWorkspaceUp {} => Self::MoveWorkspaceUp,
|
||||
@@ -1980,6 +2191,12 @@ impl From<niri_ipc::Action> for Action {
|
||||
Self::SetDynamicCastMonitor(output)
|
||||
}
|
||||
niri_ipc::Action::ClearDynamicCastTarget {} => Self::ClearDynamicCastTarget,
|
||||
niri_ipc::Action::ToggleOverview {} => Self::ToggleOverview,
|
||||
niri_ipc::Action::OpenOverview {} => Self::OpenOverview,
|
||||
niri_ipc::Action::CloseOverview {} => Self::CloseOverview,
|
||||
niri_ipc::Action::ToggleWindowUrgent { id } => Self::ToggleWindowUrgent(id),
|
||||
niri_ipc::Action::SetWindowUrgent { id } => Self::SetWindowUrgent(id),
|
||||
niri_ipc::Action::UnsetWindowUrgent { id } => Self::UnsetWindowUrgent(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2141,6 +2358,10 @@ pub struct DebugConfig {
|
||||
pub strict_new_window_focus_policy: bool,
|
||||
#[knuffel(child)]
|
||||
pub honor_xdg_activation_with_invalid_serial: bool,
|
||||
#[knuffel(child)]
|
||||
pub deactivate_unfocused_windows: bool,
|
||||
#[knuffel(child)]
|
||||
pub skip_cursor_only_updates_during_vrr: bool,
|
||||
}
|
||||
|
||||
#[derive(knuffel::DecodeScalar, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -2208,12 +2429,18 @@ impl BorderRule {
|
||||
if let Some(x) = other.inactive_color {
|
||||
self.inactive_color = Some(x);
|
||||
}
|
||||
if let Some(x) = other.urgent_color {
|
||||
self.urgent_color = Some(x);
|
||||
}
|
||||
if let Some(x) = other.active_gradient {
|
||||
self.active_gradient = Some(x);
|
||||
}
|
||||
if let Some(x) = other.inactive_gradient {
|
||||
self.inactive_gradient = Some(x);
|
||||
}
|
||||
if let Some(x) = other.urgent_gradient {
|
||||
self.urgent_gradient = Some(x);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_against(&self, mut config: Border) -> Border {
|
||||
@@ -2233,12 +2460,19 @@ impl BorderRule {
|
||||
config.inactive_color = x;
|
||||
config.inactive_gradient = None;
|
||||
}
|
||||
if let Some(x) = self.urgent_color {
|
||||
config.urgent_color = x;
|
||||
config.urgent_gradient = None;
|
||||
}
|
||||
if let Some(x) = self.active_gradient {
|
||||
config.active_gradient = Some(x);
|
||||
}
|
||||
if let Some(x) = self.inactive_gradient {
|
||||
config.inactive_gradient = Some(x);
|
||||
}
|
||||
if let Some(x) = self.urgent_gradient {
|
||||
config.urgent_gradient = Some(x);
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
@@ -2313,12 +2547,18 @@ impl TabIndicatorRule {
|
||||
if let Some(x) = other.inactive_color {
|
||||
self.inactive_color = Some(x);
|
||||
}
|
||||
if let Some(x) = other.urgent_color {
|
||||
self.urgent_color = Some(x);
|
||||
}
|
||||
if let Some(x) = other.active_gradient {
|
||||
self.active_gradient = Some(x);
|
||||
}
|
||||
if let Some(x) = other.inactive_gradient {
|
||||
self.inactive_gradient = Some(x);
|
||||
}
|
||||
if let Some(x) = other.urgent_gradient {
|
||||
self.urgent_gradient = Some(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2445,7 +2685,10 @@ impl FromStr for Color {
|
||||
type Err = miette::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let color = csscolorparser::parse(s).into_diagnostic()?.to_array();
|
||||
let color = csscolorparser::parse(s)
|
||||
.into_diagnostic()?
|
||||
.clamp()
|
||||
.to_array();
|
||||
Ok(Self::from_array_unpremul(color))
|
||||
}
|
||||
}
|
||||
@@ -2836,7 +3079,7 @@ impl<S: knuffel::traits::ErrorSpan> knuffel::DecodeScalar<S> for WorkspaceName {
|
||||
ctx.emit_error(DecodeError::unexpected(
|
||||
val,
|
||||
"named workspace",
|
||||
format!("duplicate named workspace: {}", s),
|
||||
format!("duplicate named workspace: {s}"),
|
||||
));
|
||||
return Ok(Self(String::new()));
|
||||
}
|
||||
@@ -2964,6 +3207,21 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> knuffel::Decode<S> for OverviewOpenCloseAnim
|
||||
where
|
||||
S: knuffel::traits::ErrorSpan,
|
||||
{
|
||||
fn decode_node(
|
||||
node: &knuffel::ast::SpannedNode<S>,
|
||||
ctx: &mut knuffel::decode::Context<S>,
|
||||
) -> Result<Self, DecodeError<S>> {
|
||||
let default = Self::default().0;
|
||||
Ok(Self(Animation::decode_node(node, ctx, default, |_, _| {
|
||||
Ok(false)
|
||||
})?))
|
||||
}
|
||||
}
|
||||
|
||||
impl Animation {
|
||||
pub fn new_off() -> Self {
|
||||
Self {
|
||||
@@ -3704,6 +3962,7 @@ mod tests {
|
||||
accel-profile "flat"
|
||||
scroll-method "two-finger"
|
||||
scroll-button 272
|
||||
scroll-button-lock
|
||||
tap-button-map "left-middle-right"
|
||||
disabled-on-external-mouse
|
||||
scroll-factor 0.9
|
||||
@@ -3735,6 +3994,7 @@ mod tests {
|
||||
accel-profile "flat"
|
||||
scroll-method "edge"
|
||||
scroll-button 275
|
||||
scroll-button-lock
|
||||
left-handed
|
||||
middle-emulation
|
||||
}
|
||||
@@ -3958,6 +4218,7 @@ mod tests {
|
||||
repeat_delay: 600,
|
||||
repeat_rate: 25,
|
||||
track_layout: Window,
|
||||
numlock: false,
|
||||
},
|
||||
touchpad: Touchpad {
|
||||
off: false,
|
||||
@@ -3972,7 +4233,9 @@ mod tests {
|
||||
click_method: Some(
|
||||
Clickfinger,
|
||||
),
|
||||
accel_speed: 0.2,
|
||||
accel_speed: FloatOrInt(
|
||||
0.2,
|
||||
),
|
||||
accel_profile: Some(
|
||||
Flat,
|
||||
),
|
||||
@@ -3982,6 +4245,7 @@ mod tests {
|
||||
scroll_button: Some(
|
||||
272,
|
||||
),
|
||||
scroll_button_lock: true,
|
||||
tap_button_map: Some(
|
||||
LeftMiddleRight,
|
||||
),
|
||||
@@ -3997,7 +4261,9 @@ mod tests {
|
||||
mouse: Mouse {
|
||||
off: false,
|
||||
natural_scroll: true,
|
||||
accel_speed: 0.4,
|
||||
accel_speed: FloatOrInt(
|
||||
0.4,
|
||||
),
|
||||
accel_profile: Some(
|
||||
Flat,
|
||||
),
|
||||
@@ -4007,6 +4273,7 @@ mod tests {
|
||||
scroll_button: Some(
|
||||
273,
|
||||
),
|
||||
scroll_button_lock: false,
|
||||
left_handed: false,
|
||||
middle_emulation: true,
|
||||
scroll_factor: Some(
|
||||
@@ -4018,7 +4285,9 @@ mod tests {
|
||||
trackpoint: Trackpoint {
|
||||
off: true,
|
||||
natural_scroll: true,
|
||||
accel_speed: 0.0,
|
||||
accel_speed: FloatOrInt(
|
||||
0.0,
|
||||
),
|
||||
accel_profile: Some(
|
||||
Flat,
|
||||
),
|
||||
@@ -4028,13 +4297,16 @@ mod tests {
|
||||
scroll_button: Some(
|
||||
274,
|
||||
),
|
||||
scroll_button_lock: false,
|
||||
left_handed: false,
|
||||
middle_emulation: false,
|
||||
},
|
||||
trackball: Trackball {
|
||||
off: true,
|
||||
natural_scroll: true,
|
||||
accel_speed: 0.0,
|
||||
accel_speed: FloatOrInt(
|
||||
0.0,
|
||||
),
|
||||
accel_profile: Some(
|
||||
Flat,
|
||||
),
|
||||
@@ -4044,6 +4316,7 @@ mod tests {
|
||||
scroll_button: Some(
|
||||
275,
|
||||
),
|
||||
scroll_button_lock: true,
|
||||
left_handed: true,
|
||||
middle_emulation: true,
|
||||
},
|
||||
@@ -4121,12 +4394,15 @@ mod tests {
|
||||
},
|
||||
),
|
||||
focus_at_startup: true,
|
||||
background_color: Color {
|
||||
r: 0.09803922,
|
||||
g: 0.09803922,
|
||||
b: 0.4,
|
||||
a: 1.0,
|
||||
},
|
||||
background_color: Some(
|
||||
Color {
|
||||
r: 0.09803922,
|
||||
g: 0.09803922,
|
||||
b: 0.4,
|
||||
a: 1.0,
|
||||
},
|
||||
),
|
||||
backdrop_color: None,
|
||||
},
|
||||
],
|
||||
),
|
||||
@@ -4157,6 +4433,12 @@ mod tests {
|
||||
b: 0.39215687,
|
||||
a: 0.0,
|
||||
},
|
||||
urgent_color: Color {
|
||||
r: 0.60784316,
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 1.0,
|
||||
},
|
||||
active_gradient: Some(
|
||||
Gradient {
|
||||
from: Color {
|
||||
@@ -4180,6 +4462,7 @@ mod tests {
|
||||
},
|
||||
),
|
||||
inactive_gradient: None,
|
||||
urgent_gradient: None,
|
||||
},
|
||||
border: Border {
|
||||
off: false,
|
||||
@@ -4198,8 +4481,15 @@ mod tests {
|
||||
b: 0.39215687,
|
||||
a: 0.0,
|
||||
},
|
||||
urgent_color: Color {
|
||||
r: 0.60784316,
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 1.0,
|
||||
},
|
||||
active_gradient: None,
|
||||
inactive_gradient: None,
|
||||
urgent_gradient: None,
|
||||
},
|
||||
shadow: Shadow {
|
||||
on: false,
|
||||
@@ -4250,8 +4540,10 @@ mod tests {
|
||||
),
|
||||
active_color: None,
|
||||
inactive_color: None,
|
||||
urgent_color: None,
|
||||
active_gradient: None,
|
||||
inactive_gradient: None,
|
||||
urgent_gradient: None,
|
||||
},
|
||||
insert_hint: InsertHint {
|
||||
off: false,
|
||||
@@ -4342,6 +4634,12 @@ mod tests {
|
||||
0.0,
|
||||
),
|
||||
},
|
||||
background_color: Color {
|
||||
r: 0.25,
|
||||
g: 0.25,
|
||||
b: 0.25,
|
||||
a: 1.0,
|
||||
},
|
||||
},
|
||||
prefer_no_csd: true,
|
||||
cursor: Cursor {
|
||||
@@ -4360,10 +4658,13 @@ mod tests {
|
||||
},
|
||||
hotkey_overlay: HotkeyOverlay {
|
||||
skip_at_startup: true,
|
||||
hide_not_bound: false,
|
||||
},
|
||||
animations: Animations {
|
||||
off: false,
|
||||
slowdown: 2.0,
|
||||
slowdown: FloatOrInt(
|
||||
2.0,
|
||||
),
|
||||
workspace_switch: WorkspaceSwitchAnim(
|
||||
Animation {
|
||||
off: false,
|
||||
@@ -4459,6 +4760,18 @@ mod tests {
|
||||
),
|
||||
},
|
||||
),
|
||||
overview_open_close: OverviewOpenCloseAnim(
|
||||
Animation {
|
||||
off: false,
|
||||
kind: Spring(
|
||||
SpringParams {
|
||||
damping_ratio: 1.0,
|
||||
stiffness: 800,
|
||||
epsilon: 0.0001,
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
gestures: Gestures {
|
||||
dnd_edge_view_scroll: DndEdgeViewScroll {
|
||||
@@ -4470,6 +4783,52 @@ mod tests {
|
||||
50.0,
|
||||
),
|
||||
},
|
||||
dnd_edge_workspace_switch: DndEdgeWorkspaceSwitch {
|
||||
trigger_height: FloatOrInt(
|
||||
50.0,
|
||||
),
|
||||
delay_ms: 100,
|
||||
max_speed: FloatOrInt(
|
||||
1500.0,
|
||||
),
|
||||
},
|
||||
hot_corners: HotCorners {
|
||||
off: false,
|
||||
},
|
||||
},
|
||||
overview: Overview {
|
||||
zoom: FloatOrInt(
|
||||
0.5,
|
||||
),
|
||||
backdrop_color: Color {
|
||||
r: 0.15,
|
||||
g: 0.15,
|
||||
b: 0.15,
|
||||
a: 1.0,
|
||||
},
|
||||
workspace_shadow: WorkspaceShadow {
|
||||
off: false,
|
||||
offset: ShadowOffset {
|
||||
x: FloatOrInt(
|
||||
0.0,
|
||||
),
|
||||
y: FloatOrInt(
|
||||
10.0,
|
||||
),
|
||||
},
|
||||
softness: FloatOrInt(
|
||||
40.0,
|
||||
),
|
||||
spread: FloatOrInt(
|
||||
10.0,
|
||||
),
|
||||
color: Color {
|
||||
r: 0.0,
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 0.3137255,
|
||||
},
|
||||
},
|
||||
},
|
||||
environment: Environment(
|
||||
[
|
||||
@@ -4485,6 +4844,10 @@ mod tests {
|
||||
},
|
||||
],
|
||||
),
|
||||
xwayland_satellite: XwaylandSatellite {
|
||||
off: false,
|
||||
path: "xwayland-satellite",
|
||||
},
|
||||
window_rules: [
|
||||
WindowRule {
|
||||
matches: [
|
||||
@@ -4502,6 +4865,7 @@ mod tests {
|
||||
is_active_in_column: None,
|
||||
is_floating: None,
|
||||
is_window_cast_target: None,
|
||||
is_urgent: None,
|
||||
at_startup: None,
|
||||
},
|
||||
],
|
||||
@@ -4520,6 +4884,7 @@ mod tests {
|
||||
is_active_in_column: None,
|
||||
is_floating: None,
|
||||
is_window_cast_target: None,
|
||||
is_urgent: None,
|
||||
at_startup: None,
|
||||
},
|
||||
Match {
|
||||
@@ -4534,6 +4899,7 @@ mod tests {
|
||||
is_active_in_column: None,
|
||||
is_floating: None,
|
||||
is_window_cast_target: None,
|
||||
is_urgent: None,
|
||||
at_startup: None,
|
||||
},
|
||||
],
|
||||
@@ -4577,8 +4943,10 @@ mod tests {
|
||||
),
|
||||
active_color: None,
|
||||
inactive_color: None,
|
||||
urgent_color: None,
|
||||
active_gradient: None,
|
||||
inactive_gradient: None,
|
||||
urgent_gradient: None,
|
||||
},
|
||||
border: BorderRule {
|
||||
off: false,
|
||||
@@ -4590,8 +4958,10 @@ mod tests {
|
||||
),
|
||||
active_color: None,
|
||||
inactive_color: None,
|
||||
urgent_color: None,
|
||||
active_gradient: None,
|
||||
inactive_gradient: None,
|
||||
urgent_gradient: None,
|
||||
},
|
||||
shadow: ShadowRule {
|
||||
off: false,
|
||||
@@ -4613,8 +4983,10 @@ mod tests {
|
||||
},
|
||||
),
|
||||
inactive_color: None,
|
||||
urgent_color: None,
|
||||
active_gradient: None,
|
||||
inactive_gradient: None,
|
||||
urgent_gradient: None,
|
||||
},
|
||||
draw_border_with_background: None,
|
||||
opacity: None,
|
||||
@@ -4671,6 +5043,8 @@ mod tests {
|
||||
inactive_color: None,
|
||||
},
|
||||
geometry_corner_radius: None,
|
||||
place_within_backdrop: None,
|
||||
baba_is_float: None,
|
||||
},
|
||||
],
|
||||
binds: Binds(
|
||||
@@ -4969,6 +5343,8 @@ mod tests {
|
||||
disable_monitor_names: false,
|
||||
strict_new_window_focus_policy: false,
|
||||
honor_xdg_activation_with_invalid_serial: false,
|
||||
deactivate_unfocused_windows: false,
|
||||
skip_cursor_only_updates_during_vrr: false,
|
||||
},
|
||||
workspaces: [
|
||||
Workspace {
|
||||
@@ -5323,8 +5699,10 @@ mod tests {
|
||||
width: None,
|
||||
active_color: None,
|
||||
inactive_color: None,
|
||||
urgent_color: None,
|
||||
active_gradient: None,
|
||||
inactive_gradient: None,
|
||||
urgent_gradient: None,
|
||||
};
|
||||
|
||||
for rule in rules.iter().copied() {
|
||||
|
||||
@@ -95,8 +95,7 @@ fn wiki_docs_parses() {
|
||||
}
|
||||
} else if must_fail {
|
||||
errors.push(format!(
|
||||
"Expected error parsing wiki KDL code block at {}:{}",
|
||||
filename, line_number
|
||||
"Expected error parsing wiki KDL code block at {filename}:{line_number}",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ readme = "README.md"
|
||||
|
||||
[dependencies]
|
||||
clap = { workspace = true, optional = true }
|
||||
schemars = { version = "0.8.22", optional = true }
|
||||
schemars = { version = "1.0.4", optional = true }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
|
||||
+1
-1
@@ -12,5 +12,5 @@ Use an exact version requirement to avoid breaking changes:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
niri-ipc = "=25.2.0"
|
||||
niri-ipc = "=25.5.1"
|
||||
```
|
||||
|
||||
+107
-8
@@ -1,8 +1,23 @@
|
||||
//! Types for communicating with niri via IPC.
|
||||
//!
|
||||
//! After connecting to the niri socket, you can send a single [`Request`] and receive a single
|
||||
//! [`Reply`], which is a `Result` wrapping a [`Response`]. If you requested an event stream, you
|
||||
//! can keep reading [`Event`]s from the socket after the response.
|
||||
//! After connecting to the niri socket, you can send [`Request`]s. Niri will process them one by
|
||||
//! one, in order, and to each request it will respond with a single [`Reply`], which is a `Result`
|
||||
//! wrapping a [`Response`].
|
||||
//!
|
||||
//! If you send a [`Request::EventStream`], niri will *stop* reading subsequent [`Request`]s, and
|
||||
//! will start continuously writing compositor [`Event`]s to the socket. If you'd like to read an
|
||||
//! event stream and write more requests at the same time, you need to use two IPC sockets.
|
||||
//!
|
||||
//! <div class="warning">
|
||||
//!
|
||||
//! Requests are *always* processed separately. Time passes between requests, even when sending
|
||||
//! multiple requests to the socket at once. For example, sending [`Request::Workspaces`] and
|
||||
//! [`Request::Windows`] together may not return consistent results (e.g. a window may open on a
|
||||
//! new workspace in-between the two responses). This goes for actions too: sending
|
||||
//! [`Action::FocusWindow`] and <code>[Action::CloseWindow] { id: None }</code> together may close
|
||||
//! the wrong window because a different window got focused in-between these requests.
|
||||
//!
|
||||
//! </div>
|
||||
//!
|
||||
//! You can use the [`socket::Socket`] helper if you're fine with blocking communication. However,
|
||||
//! it is a fairly simple helper, so if you need async, or if you're using a different language,
|
||||
@@ -12,7 +27,9 @@
|
||||
//! 2. Connect to the socket and write a JSON-formatted [`Request`] on a single line. You can follow
|
||||
//! up with a line break and a flush, or just flush and shutdown the write end of the socket.
|
||||
//! 3. Niri will respond with a single line JSON-formatted [`Reply`].
|
||||
//! 4. If you requested an event stream, niri will keep responding with JSON-formatted [`Event`]s,
|
||||
//! 4. You can keep writing [`Request`]s, each on a single line, and read [`Reply`]s, also each on a
|
||||
//! separate line.
|
||||
//! 5. After you request an event stream, niri will keep responding with JSON-formatted [`Event`]s,
|
||||
//! on a single line each.
|
||||
//!
|
||||
//! ## Backwards compatibility
|
||||
@@ -24,7 +41,7 @@
|
||||
//!
|
||||
//! ```toml
|
||||
//! [dependencies]
|
||||
//! niri-ipc = "=25.2.0"
|
||||
//! niri-ipc = "=25.5.1"
|
||||
//! ```
|
||||
//!
|
||||
//! ## Features
|
||||
@@ -97,6 +114,8 @@ pub enum Request {
|
||||
EventStream,
|
||||
/// Respond with an error (for testing error handling).
|
||||
ReturnError,
|
||||
/// Request information about the overview.
|
||||
OverviewState,
|
||||
}
|
||||
|
||||
/// Reply from niri to client.
|
||||
@@ -139,6 +158,16 @@ pub enum Response {
|
||||
PickedColor(Option<PickedColor>),
|
||||
/// Output configuration change result.
|
||||
OutputConfigChanged(OutputConfigChanged),
|
||||
/// Information about the overview.
|
||||
OverviewState(Overview),
|
||||
}
|
||||
|
||||
/// Overview information.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct Overview {
|
||||
/// Whether the overview is currently open.
|
||||
pub is_open: bool,
|
||||
}
|
||||
|
||||
/// Color picked from the screen.
|
||||
@@ -303,7 +332,7 @@ pub enum Action {
|
||||
FocusWindowUpOrColumnLeft {},
|
||||
/// Focus the window above or the column to the right.
|
||||
FocusWindowUpOrColumnRight {},
|
||||
/// Focus the window or the workspace above.
|
||||
/// Focus the window or the workspace below.
|
||||
FocusWindowOrWorkspaceDown {},
|
||||
/// Focus the window or the workspace above.
|
||||
FocusWindowOrWorkspaceUp {},
|
||||
@@ -397,6 +426,8 @@ pub enum Action {
|
||||
#[cfg_attr(feature = "clap", arg(long))]
|
||||
id: Option<u64>,
|
||||
},
|
||||
/// Center all fully visible columns on the screen.
|
||||
CenterVisibleColumns {},
|
||||
/// Focus the workspace below.
|
||||
FocusWorkspaceDown {},
|
||||
/// Focus the workspace above.
|
||||
@@ -438,14 +469,35 @@ pub enum Action {
|
||||
focus: bool,
|
||||
},
|
||||
/// Move the focused column to the workspace below.
|
||||
MoveColumnToWorkspaceDown {},
|
||||
MoveColumnToWorkspaceDown {
|
||||
/// Whether the focus should follow the target workspace.
|
||||
///
|
||||
/// If `true` (the default), the focus will follow the column to the new workspace. If
|
||||
/// `false`, the focus will remain on the original workspace.
|
||||
#[cfg_attr(feature = "clap", arg(long, action = clap::ArgAction::Set, default_value_t = true))]
|
||||
focus: bool,
|
||||
},
|
||||
/// Move the focused column to the workspace above.
|
||||
MoveColumnToWorkspaceUp {},
|
||||
MoveColumnToWorkspaceUp {
|
||||
/// Whether the focus should follow the target workspace.
|
||||
///
|
||||
/// If `true` (the default), the focus will follow the column to the new workspace. If
|
||||
/// `false`, the focus will remain on the original workspace.
|
||||
#[cfg_attr(feature = "clap", arg(long, action = clap::ArgAction::Set, default_value_t = true))]
|
||||
focus: bool,
|
||||
},
|
||||
/// Move the focused column to a workspace by reference (index or name).
|
||||
MoveColumnToWorkspace {
|
||||
/// Reference (index or name) of the workspace to move the column to.
|
||||
#[cfg_attr(feature = "clap", arg())]
|
||||
reference: WorkspaceReferenceArg,
|
||||
|
||||
/// Whether the focus should follow the target workspace.
|
||||
///
|
||||
/// If `true` (the default), the focus will follow the column to the new workspace. If
|
||||
/// `false`, the focus will remain on the original workspace.
|
||||
#[cfg_attr(feature = "clap", arg(long, action = clap::ArgAction::Set, default_value_t = true))]
|
||||
focus: bool,
|
||||
},
|
||||
/// Move the focused workspace down.
|
||||
MoveWorkspaceDown {},
|
||||
@@ -764,6 +816,30 @@ pub enum Action {
|
||||
},
|
||||
/// Clear the dynamic cast target, making it show nothing.
|
||||
ClearDynamicCastTarget {},
|
||||
/// Toggle (open/close) the Overview.
|
||||
ToggleOverview {},
|
||||
/// Open the Overview.
|
||||
OpenOverview {},
|
||||
/// Close the Overview.
|
||||
CloseOverview {},
|
||||
/// Toggle urgent status of a window.
|
||||
ToggleWindowUrgent {
|
||||
/// Id of the window to toggle urgent.
|
||||
#[cfg_attr(feature = "clap", arg(long))]
|
||||
id: u64,
|
||||
},
|
||||
/// Set urgent status of a window.
|
||||
SetWindowUrgent {
|
||||
/// Id of the window to set urgent.
|
||||
#[cfg_attr(feature = "clap", arg(long))]
|
||||
id: u64,
|
||||
},
|
||||
/// Unset urgent status of a window.
|
||||
UnsetWindowUrgent {
|
||||
/// Id of the window to unset urgent.
|
||||
#[cfg_attr(feature = "clap", arg(long))]
|
||||
id: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Change in window or column size.
|
||||
@@ -1072,6 +1148,8 @@ pub struct Window {
|
||||
///
|
||||
/// If the window isn't floating then it is in the tiling layout.
|
||||
pub is_floating: bool,
|
||||
/// Whether this window requests your attention.
|
||||
pub is_urgent: bool,
|
||||
}
|
||||
|
||||
/// Output configuration change result.
|
||||
@@ -1111,6 +1189,8 @@ pub struct Workspace {
|
||||
///
|
||||
/// Can be `None` if no outputs are currently connected.
|
||||
pub output: Option<String>,
|
||||
/// Whether the workspace currently has an urgent window in its output.
|
||||
pub is_urgent: bool,
|
||||
/// Whether the workspace is currently active on its output.
|
||||
///
|
||||
/// Every output has one active workspace, the one that is currently visible on that output.
|
||||
@@ -1185,6 +1265,13 @@ pub enum Event {
|
||||
/// workspaces are missing from here, then they were deleted.
|
||||
workspaces: Vec<Workspace>,
|
||||
},
|
||||
/// The workspace urgency changed.
|
||||
WorkspaceUrgencyChanged {
|
||||
/// Id of the workspace.
|
||||
id: u64,
|
||||
/// Whether this workspace has an urgent window.
|
||||
urgent: bool,
|
||||
},
|
||||
/// A workspace was activated on an output.
|
||||
///
|
||||
/// This doesn't always mean the workspace became focused, just that it's now the active
|
||||
@@ -1232,6 +1319,13 @@ pub enum Event {
|
||||
/// Id of the newly focused window, or `None` if no window is now focused.
|
||||
id: Option<u64>,
|
||||
},
|
||||
/// Window urgency changed.
|
||||
WindowUrgencyChanged {
|
||||
/// Id of the window.
|
||||
id: u64,
|
||||
/// The new urgency state of the window.
|
||||
urgent: bool,
|
||||
},
|
||||
/// The configured keyboard layouts have changed.
|
||||
KeyboardLayoutsChanged {
|
||||
/// The new keyboard layout configuration.
|
||||
@@ -1242,6 +1336,11 @@ pub enum Event {
|
||||
/// Index of the newly active layout.
|
||||
idx: u8,
|
||||
},
|
||||
/// The overview was opened or closed.
|
||||
OverviewOpenedOrClosed {
|
||||
/// The new state of the overview.
|
||||
is_open: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl FromStr for WorkspaceReferenceArg {
|
||||
|
||||
+42
-18
@@ -16,7 +16,7 @@ pub const SOCKET_PATH_ENV: &str = "NIRI_SOCKET";
|
||||
/// This struct is used to communicate with the niri IPC server. It handles the socket connection
|
||||
/// and serialization/deserialization of messages.
|
||||
pub struct Socket {
|
||||
stream: UnixStream,
|
||||
stream: BufReader<UnixStream>,
|
||||
}
|
||||
|
||||
impl Socket {
|
||||
@@ -37,6 +37,7 @@ impl Socket {
|
||||
/// Connects to the niri IPC socket at the given path.
|
||||
pub fn connect_to(path: impl AsRef<Path>) -> io::Result<Self> {
|
||||
let stream = UnixStream::connect(path.as_ref())?;
|
||||
let stream = BufReader::new(stream);
|
||||
Ok(Self { stream })
|
||||
}
|
||||
|
||||
@@ -47,31 +48,54 @@ impl Socket {
|
||||
/// * `Ok(Ok(response))`: successful [`Response`](crate::Response) from niri
|
||||
/// * `Ok(Err(message))`: error message from niri
|
||||
/// * `Err(error)`: error communicating with niri
|
||||
///
|
||||
/// This method also returns a blocking function that you can call to keep reading [`Event`]s
|
||||
/// after requesting an [`EventStream`][Request::EventStream]. This function is not useful
|
||||
/// otherwise.
|
||||
pub fn send(self, request: Request) -> io::Result<(Reply, impl FnMut() -> io::Result<Event>)> {
|
||||
let Self { mut stream } = self;
|
||||
|
||||
pub fn send(&mut self, request: Request) -> io::Result<Reply> {
|
||||
let mut buf = serde_json::to_string(&request).unwrap();
|
||||
stream.write_all(buf.as_bytes())?;
|
||||
stream.shutdown(Shutdown::Write)?;
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
buf.push('\n');
|
||||
self.stream.get_mut().write_all(buf.as_bytes())?;
|
||||
|
||||
buf.clear();
|
||||
reader.read_line(&mut buf)?;
|
||||
self.stream.read_line(&mut buf)?;
|
||||
|
||||
let reply = serde_json::from_str(&buf)?;
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
let events = move || {
|
||||
/// Starts reading event stream [`Event`]s from the socket.
|
||||
///
|
||||
/// The returned function will block until the next [`Event`] arrives, then return it.
|
||||
///
|
||||
/// Use this only after requesting an [`EventStream`][Request::EventStream].
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use niri_ipc::{Request, Response};
|
||||
/// use niri_ipc::socket::Socket;
|
||||
///
|
||||
/// fn main() -> std::io::Result<()> {
|
||||
/// let mut socket = Socket::connect()?;
|
||||
///
|
||||
/// let reply = socket.send(Request::EventStream)?;
|
||||
/// if matches!(reply, Ok(Response::Handled)) {
|
||||
/// let mut read_event = socket.read_events();
|
||||
/// while let Ok(event) = read_event() {
|
||||
/// println!("Received event: {event:?}");
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub fn read_events(self) -> impl FnMut() -> io::Result<Event> {
|
||||
let Self { mut stream } = self;
|
||||
let _ = stream.get_mut().shutdown(Shutdown::Write);
|
||||
|
||||
let mut buf = String::new();
|
||||
move || {
|
||||
buf.clear();
|
||||
reader.read_line(&mut buf)?;
|
||||
stream.read_line(&mut buf)?;
|
||||
let event = serde_json::from_str(&buf)?;
|
||||
Ok(event)
|
||||
};
|
||||
|
||||
Ok((reply, events))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ pub struct EventStreamState {
|
||||
|
||||
/// State of the keyboard layouts.
|
||||
pub keyboard_layouts: KeyboardLayoutsState,
|
||||
|
||||
/// State of the overview.
|
||||
pub overview: OverviewState,
|
||||
}
|
||||
|
||||
/// The workspaces state communicated over the event stream.
|
||||
@@ -63,12 +66,20 @@ pub struct KeyboardLayoutsState {
|
||||
pub keyboard_layouts: Option<KeyboardLayouts>,
|
||||
}
|
||||
|
||||
/// The overview state communicated over the event stream.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OverviewState {
|
||||
/// Whether the overview is currently open.
|
||||
pub is_open: bool,
|
||||
}
|
||||
|
||||
impl EventStreamStatePart for EventStreamState {
|
||||
fn replicate(&self) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
events.extend(self.workspaces.replicate());
|
||||
events.extend(self.windows.replicate());
|
||||
events.extend(self.keyboard_layouts.replicate());
|
||||
events.extend(self.overview.replicate());
|
||||
events
|
||||
}
|
||||
|
||||
@@ -76,6 +87,7 @@ impl EventStreamStatePart for EventStreamState {
|
||||
let event = self.workspaces.apply(event)?;
|
||||
let event = self.windows.apply(event)?;
|
||||
let event = self.keyboard_layouts.apply(event)?;
|
||||
let event = self.overview.apply(event)?;
|
||||
Some(event)
|
||||
}
|
||||
}
|
||||
@@ -91,6 +103,13 @@ impl EventStreamStatePart for WorkspacesState {
|
||||
Event::WorkspacesChanged { workspaces } => {
|
||||
self.workspaces = workspaces.into_iter().map(|ws| (ws.id, ws)).collect();
|
||||
}
|
||||
Event::WorkspaceUrgencyChanged { id, urgent } => {
|
||||
for ws in self.workspaces.values_mut() {
|
||||
if ws.id == id {
|
||||
ws.is_urgent = urgent;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::WorkspaceActivated { id, focused } => {
|
||||
let ws = self.workspaces.get(&id);
|
||||
let ws = ws.expect("activated workspace was missing from the map");
|
||||
@@ -162,6 +181,14 @@ impl EventStreamStatePart for WindowsState {
|
||||
win.is_focused = Some(win.id) == id;
|
||||
}
|
||||
}
|
||||
Event::WindowUrgencyChanged { id, urgent } => {
|
||||
for win in self.windows.values_mut() {
|
||||
if win.id == id {
|
||||
win.is_urgent = urgent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
event => return Some(event),
|
||||
}
|
||||
None
|
||||
@@ -192,3 +219,21 @@ impl EventStreamStatePart for KeyboardLayoutsState {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl EventStreamStatePart for OverviewState {
|
||||
fn replicate(&self) -> Vec<Event> {
|
||||
vec![Event::OverviewOpenedOrClosed {
|
||||
is_open: self.is_open,
|
||||
}]
|
||||
}
|
||||
|
||||
fn apply(&mut self, event: Event) -> Option<Event> {
|
||||
match event {
|
||||
Event::OverviewOpenedOrClosed { is_open } => {
|
||||
self.is_open = is_open;
|
||||
}
|
||||
event => return Some(event),
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ repository.workspace = true
|
||||
[dependencies]
|
||||
adw = { version = "0.7.2", package = "libadwaita", features = ["v1_4"] }
|
||||
anyhow.workspace = true
|
||||
gtk = { version = "0.9.6", package = "gtk4", features = ["v4_12"] }
|
||||
niri = { version = "25.2.0", path = ".." }
|
||||
niri-config = { version = "25.2.0", path = "../niri-config" }
|
||||
gtk = { version = "0.9.7", package = "gtk4", features = ["v4_12"] }
|
||||
niri = { version = "25.5.1", path = ".." }
|
||||
niri-config = { version = "25.5.1", path = "../niri-config" }
|
||||
smithay.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
@@ -23,8 +23,10 @@ impl GradientArea {
|
||||
width: FloatOrInt(1.),
|
||||
active_color: Color::from_rgba8_unpremul(255, 255, 255, 128),
|
||||
inactive_color: Color::default(),
|
||||
urgent_color: Color::default(),
|
||||
active_gradient: None,
|
||||
inactive_gradient: None,
|
||||
urgent_gradient: None,
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -81,6 +83,7 @@ impl TestCase for GradientArea {
|
||||
g_size,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
Rectangle::default(),
|
||||
CornerRadius::default(),
|
||||
1.,
|
||||
|
||||
@@ -60,8 +60,10 @@ impl Layout {
|
||||
width: FloatOrInt(4.),
|
||||
active_color: Color::from_rgba8_unpremul(255, 163, 72, 255),
|
||||
inactive_color: Color::from_rgba8_unpremul(50, 50, 50, 255),
|
||||
urgent_color: Color::from_rgba8_unpremul(155, 0, 0, 255),
|
||||
active_gradient: None,
|
||||
inactive_gradient: None,
|
||||
urgent_gradient: None,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
@@ -266,6 +268,7 @@ impl TestCase for Layout {
|
||||
.monitor_for_output(&self.output)
|
||||
.unwrap()
|
||||
.render_elements(renderer, RenderTarget::Output, true)
|
||||
.flat_map(|(_, iter)| iter)
|
||||
.map(|elem| Box::new(elem) as _)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -272,4 +272,8 @@ impl LayoutElement for TestWindow {
|
||||
fn interactive_resize_data(&self) -> Option<InteractiveResizeData> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_urgent(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// This config is in the KDL format: https://kdl.dev
|
||||
// "/-" comments out the following node.
|
||||
// Check the wiki for a full description of the configuration:
|
||||
// https://github.com/YaLTeR/niri/wiki/Configuration:-Overview
|
||||
// https://github.com/YaLTeR/niri/wiki/Configuration:-Introduction
|
||||
|
||||
// Input device configuration.
|
||||
// Find the full list of options on the wiki:
|
||||
@@ -15,11 +15,19 @@ input {
|
||||
// For example:
|
||||
// layout "us,ru"
|
||||
// options "grp:win_space_toggle,compose:ralt,ctrl:nocaps"
|
||||
|
||||
// If this section is empty, niri will fetch xkb settings
|
||||
// from org.freedesktop.locale1. You can control these using
|
||||
// localectl set-x11-keymap.
|
||||
}
|
||||
|
||||
// Enable numlock on startup, omitting this setting disables it.
|
||||
numlock
|
||||
}
|
||||
|
||||
// Next sections include libinput settings.
|
||||
// Omitting settings disables them, or leaves them at their default values.
|
||||
// All commented-out settings here are examples, not defaults.
|
||||
touchpad {
|
||||
// off
|
||||
tap
|
||||
@@ -49,6 +57,7 @@ input {
|
||||
// accel-profile "flat"
|
||||
// scroll-method "on-button-down"
|
||||
// scroll-button 273
|
||||
// scroll-button-lock
|
||||
// middle-emulation
|
||||
}
|
||||
|
||||
@@ -161,6 +170,9 @@ layout {
|
||||
active-color "#7fc8ff"
|
||||
|
||||
// Color of the ring on inactive monitors.
|
||||
//
|
||||
// The focus ring only draws around the active window, so the only place
|
||||
// where you can see its inactive-color is on other monitors.
|
||||
inactive-color "#505050"
|
||||
|
||||
// You can also use gradients. They take precedence over solid colors.
|
||||
@@ -170,7 +182,7 @@ layout {
|
||||
// You can use any CSS linear-gradient tool on the web to set these up.
|
||||
// Changing the color space is also supported, check the wiki for more info.
|
||||
//
|
||||
// active-gradient from="#80c8ff" to="#bbddff" angle=45
|
||||
// active-gradient from="#80c8ff" to="#c7ff7f" angle=45
|
||||
|
||||
// You can also color the gradient relative to the entire view
|
||||
// of the workspace, rather than relative to just the window itself.
|
||||
@@ -189,7 +201,14 @@ layout {
|
||||
active-color "#ffc87f"
|
||||
inactive-color "#505050"
|
||||
|
||||
// active-gradient from="#ffbb66" to="#ffc880" angle=45 relative-to="workspace-view"
|
||||
// Color of the border around windows that request your attention.
|
||||
urgent-color "#9b0000"
|
||||
|
||||
// Gradients can use a few different interpolation color spaces.
|
||||
// For example, this is a pastel rainbow gradient via in="oklch longer hue".
|
||||
//
|
||||
// active-gradient from="#e5989b" to="#ffb4a2" angle=45 relative-to="workspace-view" in="oklch longer hue"
|
||||
|
||||
// inactive-gradient from="#505050" to="#808080" angle=45 relative-to="workspace-view"
|
||||
}
|
||||
|
||||
@@ -205,13 +224,13 @@ layout {
|
||||
// radius. It has to assume that windows have square corners, leading to
|
||||
// shadow artifacts inside the CSD rounded corners. This setting fixes
|
||||
// those artifacts.
|
||||
//
|
||||
//
|
||||
// However, instead you may want to set prefer-no-csd and/or
|
||||
// geometry-corner-radius. Then, niri will know the corner radius and
|
||||
// draw the shadow correctly, without having to draw it behind the
|
||||
// window. These will also remove client-side shadows if the window
|
||||
// draws any.
|
||||
//
|
||||
//
|
||||
// draw-behind-window true
|
||||
|
||||
// You can change how shadows look. The values below are in logical
|
||||
@@ -251,6 +270,11 @@ layout {
|
||||
// This line starts waybar, a commonly used bar for Wayland compositors.
|
||||
spawn-at-startup "waybar"
|
||||
|
||||
hotkey-overlay {
|
||||
// Uncomment this line to disable the "Important Hotkeys" pop-up at startup.
|
||||
// skip-at-startup
|
||||
}
|
||||
|
||||
// Uncomment this line to ask the clients to omit their client-side decorations if possible.
|
||||
// If the client will specifically ask for CSD, the request will be honored.
|
||||
// Additionally, clients will be informed that they are tiled, removing some client-side rounded corners.
|
||||
@@ -350,7 +374,16 @@ binds {
|
||||
XF86AudioMute allow-when-locked=true { spawn "wpctl" "set-mute" "@DEFAULT_AUDIO_SINK@" "toggle"; }
|
||||
XF86AudioMicMute allow-when-locked=true { spawn "wpctl" "set-mute" "@DEFAULT_AUDIO_SOURCE@" "toggle"; }
|
||||
|
||||
Mod+Q { close-window; }
|
||||
// Example brightness key mappings for brightnessctl.
|
||||
XF86MonBrightnessUp allow-when-locked=true { spawn "brightnessctl" "--class=backlight" "set" "+10%"; }
|
||||
XF86MonBrightnessDown allow-when-locked=true { spawn "brightnessctl" "--class=backlight" "set" "10%-"; }
|
||||
|
||||
// Open/close the Overview: a zoomed-out view of workspaces and windows.
|
||||
// You can also move the mouse into the top-left hot corner,
|
||||
// or do a four-finger swipe up on a touchpad.
|
||||
Mod+O repeat=false { toggle-overview; }
|
||||
|
||||
Mod+Q repeat=false { close-window; }
|
||||
|
||||
Mod+Left { focus-column-left; }
|
||||
Mod+Down { focus-window-down; }
|
||||
@@ -514,6 +547,9 @@ binds {
|
||||
|
||||
Mod+C { center-column; }
|
||||
|
||||
// Center all fully visible columns on screen.
|
||||
Mod+Ctrl+C { center-visible-columns; }
|
||||
|
||||
// Finer width adjustments.
|
||||
// This command can also:
|
||||
// * set width in pixels: "1000"
|
||||
|
||||
+11
-1
@@ -1,5 +1,15 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Detect if being run as a user service, which implies external session management,
|
||||
# exec compositor directly
|
||||
if [ -n "${MANAGERPID:-}" ] && [ "${SYSTEMD_EXEC_PID:-}" = "$$" ]; then
|
||||
case "$(ps -p "$MANAGERPID" -o cmd=)" in
|
||||
*systemd*--user*)
|
||||
exec niri --session
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ -n "$SHELL" ] &&
|
||||
grep -q "$SHELL" /etc/shells &&
|
||||
! (echo "$SHELL" | grep -q "false") &&
|
||||
@@ -40,7 +50,7 @@ if hash systemctl >/dev/null 2>&1; then
|
||||
systemctl --user start --job-mode=replace-irreversibly niri-shutdown.target
|
||||
|
||||
# Unset environment that we've set.
|
||||
systemctl --user unset-environment WAYLAND_DISPLAY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP NIRI_SOCKET
|
||||
systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP NIRI_SOCKET
|
||||
elif hash dinitctl >/dev/null 2>&1; then
|
||||
# Check that the user dinit daemon is running
|
||||
if ! pgrep -u "$(id -u)" dinit >/dev/null 2>&1; then
|
||||
|
||||
@@ -94,6 +94,12 @@ impl Spring {
|
||||
|
||||
x1 = (self.to - y0 + m * x0) / m;
|
||||
y1 = self.oscillate(x1);
|
||||
|
||||
// Overdamped springs have some numerical stability issues...
|
||||
if !y1.is_finite() {
|
||||
return Duration::from_secs_f64(x0);
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
@@ -187,4 +193,17 @@ mod tests {
|
||||
let _ = spring.clamped_duration();
|
||||
let _ = spring.value_at(Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overdamped_spring_duration_panic() {
|
||||
let spring = Spring {
|
||||
from: 0.,
|
||||
to: 1.,
|
||||
initial_velocity: 0.,
|
||||
params: SpringParams::new(6., 1200., 0.0001),
|
||||
};
|
||||
let _ = spring.duration();
|
||||
let _ = spring.clamped_duration();
|
||||
let _ = spring.value_at(Duration::ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub use winit::Winit;
|
||||
pub mod headless;
|
||||
pub use headless::Headless;
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum Backend {
|
||||
Tty(Tty),
|
||||
Winit(Winit),
|
||||
|
||||
+13
-6
@@ -19,6 +19,7 @@ use smithay::backend::allocator::format::FormatSet;
|
||||
use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice};
|
||||
use smithay::backend::allocator::Fourcc;
|
||||
use smithay::backend::drm::compositor::{DrmCompositor, FrameFlags, PrimaryPlaneElement};
|
||||
use smithay::backend::drm::exporter::gbm::GbmFramebufferExporter;
|
||||
use smithay::backend::drm::{
|
||||
DrmDevice, DrmDeviceFd, DrmEvent, DrmEventMetadata, DrmEventTime, DrmNode, NodeType, VrrSupport,
|
||||
};
|
||||
@@ -114,7 +115,7 @@ pub type TtyRendererError<'render> = <TtyRenderer<'render> as RendererSuper>::Er
|
||||
|
||||
type GbmDrmCompositor = DrmCompositor<
|
||||
GbmAllocator<DrmDeviceFd>,
|
||||
GbmDevice<DrmDeviceFd>,
|
||||
GbmFramebufferExporter<DrmDeviceFd>,
|
||||
(OutputPresentationFeedback, Duration),
|
||||
DrmDeviceFd,
|
||||
>;
|
||||
@@ -315,11 +316,11 @@ impl Tty {
|
||||
|
||||
let mut node_path = String::new();
|
||||
if let Some(path) = primary_render_node.dev_path() {
|
||||
write!(node_path, "{:?}", path).unwrap();
|
||||
write!(node_path, "{path:?}").unwrap();
|
||||
} else {
|
||||
write!(node_path, "{}", primary_render_node).unwrap();
|
||||
write!(node_path, "{primary_render_node}").unwrap();
|
||||
}
|
||||
info!("using as the render node: {}", node_path);
|
||||
info!("using as the render node: {node_path}");
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
@@ -971,7 +972,7 @@ impl Tty {
|
||||
surface,
|
||||
None,
|
||||
allocator.clone(),
|
||||
device.gbm.clone(),
|
||||
GbmFramebufferExporter::new(device.gbm.clone(), Some(device.render_node)),
|
||||
SUPPORTED_COLOR_FORMATS,
|
||||
// This is only used to pick a good internal format, so it can use the surface's render
|
||||
// formats, even though we only ever render on the primary GPU.
|
||||
@@ -1001,7 +1002,7 @@ impl Tty {
|
||||
surface,
|
||||
None,
|
||||
allocator,
|
||||
device.gbm.clone(),
|
||||
GbmFramebufferExporter::new(device.gbm.clone(), Some(device.render_node)),
|
||||
SUPPORTED_COLOR_FORMATS,
|
||||
render_formats,
|
||||
device.drm.cursor_size(),
|
||||
@@ -1421,6 +1422,12 @@ impl Tty {
|
||||
if debug.disable_cursor_plane {
|
||||
flags.remove(FrameFlags::ALLOW_CURSOR_PLANE_SCANOUT);
|
||||
}
|
||||
if debug.skip_cursor_only_updates_during_vrr {
|
||||
let output_state = niri.output_state.get(output).unwrap();
|
||||
if output_state.frame_clock.vrr() {
|
||||
flags.insert(FrameFlags::SKIP_CURSOR_ONLY_UPDATES);
|
||||
}
|
||||
}
|
||||
|
||||
flags
|
||||
};
|
||||
|
||||
+28
-1
@@ -56,7 +56,7 @@ pub enum Sub {
|
||||
/// Cause a panic to check if the backtraces are good.
|
||||
Panic,
|
||||
/// Generate shell completions.
|
||||
Completions { shell: Shell },
|
||||
Completions { shell: CompletionShell },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -105,4 +105,31 @@ pub enum Msg {
|
||||
Version,
|
||||
/// Request an error from the running niri instance.
|
||||
RequestError,
|
||||
/// Print the overview state.
|
||||
OverviewState,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, clap::ValueEnum)]
|
||||
pub enum CompletionShell {
|
||||
Bash,
|
||||
Elvish,
|
||||
Fish,
|
||||
PowerShell,
|
||||
Zsh,
|
||||
Nushell,
|
||||
}
|
||||
|
||||
impl TryFrom<CompletionShell> for Shell {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(shell: CompletionShell) -> Result<Self, Self::Error> {
|
||||
match shell {
|
||||
CompletionShell::Bash => Ok(Shell::Bash),
|
||||
CompletionShell::Elvish => Ok(Shell::Elvish),
|
||||
CompletionShell::Fish => Ok(Shell::Fish),
|
||||
CompletionShell::PowerShell => Ok(Shell::PowerShell),
|
||||
CompletionShell::Zsh => Ok(Shell::Zsh),
|
||||
CompletionShell::Nushell => Err("Nushell should be handled separately"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
use futures_util::StreamExt;
|
||||
use niri_config::Xkb;
|
||||
use zbus::names::InterfaceName;
|
||||
use zbus::{fdo, zvariant};
|
||||
|
||||
pub enum Locale1ToNiri {
|
||||
XkbChanged(Xkb),
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
to_niri: calloop::channel::Sender<Locale1ToNiri>,
|
||||
) -> anyhow::Result<zbus::blocking::Connection> {
|
||||
let conn = zbus::blocking::Connection::system()?;
|
||||
|
||||
let async_conn = conn.inner().clone();
|
||||
let future = async move {
|
||||
let proxy = fdo::PropertiesProxy::new(
|
||||
&async_conn,
|
||||
"org.freedesktop.locale1",
|
||||
"/org/freedesktop/locale1",
|
||||
)
|
||||
.await;
|
||||
let proxy = match proxy {
|
||||
Ok(x) => x,
|
||||
Err(err) => {
|
||||
warn!("error creating PropertiesProxy: {err:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut props_changed = match proxy.receive_properties_changed().await {
|
||||
Ok(x) => x,
|
||||
Err(err) => {
|
||||
warn!("error subscribing to PropertiesChanged: {err:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let props = proxy
|
||||
.get_all(InterfaceName::try_from("org.freedesktop.locale1").unwrap())
|
||||
.await;
|
||||
let mut props = match props {
|
||||
Ok(x) => x,
|
||||
Err(err) => {
|
||||
warn!("error receiving initial properties: {err:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
trace!("initial properties: {props:?}");
|
||||
|
||||
let mut get = |name| {
|
||||
props
|
||||
.remove(name)
|
||||
.and_then(|x| String::try_from(x).ok())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut xkb = Xkb {
|
||||
rules: String::new(),
|
||||
model: get("X11Model"),
|
||||
layout: get("X11Layout"),
|
||||
variant: get("X11Variant"),
|
||||
options: match get("X11Options") {
|
||||
x if x.is_empty() => None,
|
||||
x => Some(x),
|
||||
},
|
||||
file: None,
|
||||
};
|
||||
|
||||
// Send the initial properties.
|
||||
if let Err(err) = to_niri.send(Locale1ToNiri::XkbChanged(xkb.clone())) {
|
||||
warn!("error sending message to niri: {err:?}");
|
||||
return;
|
||||
};
|
||||
|
||||
while let Some(changed) = props_changed.next().await {
|
||||
let args = match changed.args() {
|
||||
Ok(args) => args,
|
||||
Err(err) => {
|
||||
warn!("error parsing locale1 PropertiesChanged args: {err:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut changed = false;
|
||||
for (name, value) in args.changed_properties() {
|
||||
trace!("changed property: {name} => {value:?}");
|
||||
|
||||
let value = zvariant::Str::try_from(value).unwrap_or_default();
|
||||
let value = value.as_str();
|
||||
|
||||
match *name {
|
||||
"X11Model" => {
|
||||
if xkb.model != value {
|
||||
xkb.model = String::from(value);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
"X11Layout" => {
|
||||
if xkb.layout != value {
|
||||
xkb.layout = String::from(value);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
"X11Variant" => {
|
||||
if xkb.variant != value {
|
||||
xkb.variant = String::from(value);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
"X11Options" => {
|
||||
let value = match value {
|
||||
"" => None,
|
||||
x => Some(x),
|
||||
};
|
||||
if xkb.options.as_deref() != value {
|
||||
xkb.options = value.map(String::from);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = to_niri.send(Locale1ToNiri::XkbChanged(xkb.clone())) {
|
||||
warn!("error sending message to niri: {err:?}");
|
||||
return;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let task = conn
|
||||
.inner()
|
||||
.executor()
|
||||
.spawn(future, "monitor locale1 property changes");
|
||||
task.detach();
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use anyhow::Context;
|
||||
@@ -13,11 +13,12 @@ use zbus::{interface, Task};
|
||||
|
||||
use super::Start;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ScreenSaver {
|
||||
is_inhibited: Arc<AtomicBool>,
|
||||
is_broken: Arc<AtomicBool>,
|
||||
inhibitors: Arc<Mutex<HashMap<u32, OwnedUniqueName>>>,
|
||||
counter: u32,
|
||||
counter: Arc<AtomicU32>,
|
||||
monitor_task: Arc<OnceLock<Task<()>>>,
|
||||
}
|
||||
|
||||
@@ -43,16 +44,16 @@ impl ScreenSaver {
|
||||
|
||||
let mut cookie = None;
|
||||
for _ in 0..3 {
|
||||
// Start from 1 because some clients don't like 0.
|
||||
self.counter = self.counter.wrapping_add(1);
|
||||
if self.counter == 0 {
|
||||
self.counter += 1;
|
||||
let mut inhibitor_key = self.counter.fetch_add(1, Ordering::SeqCst);
|
||||
if inhibitor_key == 0 {
|
||||
// Some clients don't like 0, add one more.
|
||||
inhibitor_key = self.counter.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
if let Entry::Vacant(entry) = inhibitors.entry(self.counter) {
|
||||
if let Entry::Vacant(entry) = inhibitors.entry(inhibitor_key) {
|
||||
entry.insert(name);
|
||||
self.is_inhibited.store(true, Ordering::SeqCst);
|
||||
cookie = Some(self.counter);
|
||||
let _ = cookie.insert(inhibitor_key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -83,7 +84,8 @@ impl ScreenSaver {
|
||||
is_inhibited,
|
||||
is_broken: Arc::new(AtomicBool::new(false)),
|
||||
inhibitors: Arc::new(Mutex::new(HashMap::new())),
|
||||
counter: 0,
|
||||
// Start from 1 because some clients don't like 0.
|
||||
counter: Arc::new(AtomicU32::new(1)),
|
||||
monitor_task: Arc::new(OnceLock::new()),
|
||||
}
|
||||
}
|
||||
@@ -138,8 +140,15 @@ impl Start for ScreenSaver {
|
||||
| RequestNameFlags::ReplaceExisting
|
||||
| RequestNameFlags::DoNotQueue;
|
||||
|
||||
conn.object_server()
|
||||
.at("/org/freedesktop/ScreenSaver", self)?;
|
||||
let org_fd_ss_registered = conn
|
||||
.object_server()
|
||||
.at("/org/freedesktop/ScreenSaver", self.clone())?;
|
||||
let ss_registered = conn.object_server().at("/ScreenSaver", self)?;
|
||||
|
||||
if !org_fd_ss_registered && !ss_registered {
|
||||
anyhow::bail!("failed to register any org.freedesktop.ScreenSaver interface")
|
||||
}
|
||||
|
||||
conn.request_name_with_flags("org.freedesktop.ScreenSaver", flags)?;
|
||||
|
||||
let async_conn = conn.inner();
|
||||
|
||||
@@ -3,6 +3,7 @@ use zbus::object_server::Interface;
|
||||
|
||||
use crate::niri::State;
|
||||
|
||||
pub mod freedesktop_locale1;
|
||||
pub mod freedesktop_screensaver;
|
||||
pub mod gnome_shell_introspect;
|
||||
pub mod gnome_shell_screenshot;
|
||||
@@ -32,6 +33,7 @@ pub struct DBusServers {
|
||||
pub conn_introspect: Option<Connection>,
|
||||
#[cfg(feature = "xdp-gnome-screencast")]
|
||||
pub conn_screen_cast: Option<Connection>,
|
||||
pub conn_locale1: Option<Connection>,
|
||||
}
|
||||
|
||||
impl DBusServers {
|
||||
@@ -125,6 +127,22 @@ impl DBusServers {
|
||||
}
|
||||
}
|
||||
|
||||
let (to_niri, from_locale1) = calloop::channel::channel();
|
||||
niri.event_loop
|
||||
.insert_source(from_locale1, move |event, _, state| match event {
|
||||
calloop::channel::Event::Msg(msg) => state.on_locale1_msg(msg),
|
||||
calloop::channel::Event::Closed => (),
|
||||
})
|
||||
.unwrap();
|
||||
match freedesktop_locale1::start(to_niri) {
|
||||
Ok(conn) => {
|
||||
dbus.conn_locale1 = Some(conn);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("error starting locale1 watcher: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
niri.dbus = Some(dbus);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,8 +189,7 @@ impl DisplayConfig {
|
||||
for (connector, mode, _props) in requested_config.monitors {
|
||||
if !current_conf.values().any(|o| o.name == connector) {
|
||||
return Err(zbus::fdo::Error::Failed(format!(
|
||||
"Connector '{}' not found",
|
||||
connector
|
||||
"Connector '{connector}' not found",
|
||||
)));
|
||||
}
|
||||
new_conf.insert(
|
||||
@@ -210,8 +209,7 @@ impl DisplayConfig {
|
||||
7 => niri_ipc::Transform::Flipped270,
|
||||
x => {
|
||||
return Err(zbus::fdo::Error::Failed(format!(
|
||||
"Unknown transform {}",
|
||||
x
|
||||
"Unknown transform {x}",
|
||||
)))
|
||||
}
|
||||
},
|
||||
@@ -220,10 +218,7 @@ impl DisplayConfig {
|
||||
y: requested_config.y,
|
||||
}),
|
||||
mode: Some(niri_ipc::ConfiguredMode::from_str(&mode).map_err(|e| {
|
||||
zbus::fdo::Error::Failed(format!(
|
||||
"Could not parse mode '{}': {}",
|
||||
mode, e
|
||||
))
|
||||
zbus::fdo::Error::Failed(format!("Could not parse mode '{mode}': {e}"))
|
||||
})?),
|
||||
// FIXME: VRR
|
||||
..Default::default()
|
||||
|
||||
@@ -120,7 +120,7 @@ impl ScreenCast {
|
||||
|
||||
static NUMBER: AtomicUsize = AtomicUsize::new(0);
|
||||
let session_id = NUMBER.fetch_add(1, Ordering::SeqCst);
|
||||
let path = format!("/org/gnome/Mutter/ScreenCast/Session/u{}", session_id);
|
||||
let path = format!("/org/gnome/Mutter/ScreenCast/Session/u{session_id}");
|
||||
let path = OwnedObjectPath::try_from(path).unwrap();
|
||||
|
||||
let session = Session::new(session_id, self.ipc_outputs.clone(), self.to_niri.clone());
|
||||
|
||||
@@ -12,7 +12,7 @@ use smithay::wayland::shell::xdg::PopupSurface;
|
||||
|
||||
use crate::layer::{MappedLayer, ResolvedLayerRules};
|
||||
use crate::niri::State;
|
||||
use crate::utils::{is_mapped, send_scale_transform};
|
||||
use crate::utils::{is_mapped, output_size, send_scale_transform};
|
||||
|
||||
impl WlrLayerShellHandler for State {
|
||||
fn shell_state(&mut self) -> &mut WlrLayerShellState {
|
||||
@@ -125,10 +125,23 @@ impl State {
|
||||
// Resolve rules for newly mapped layer surfaces.
|
||||
if was_unmapped {
|
||||
let config = self.niri.config.borrow();
|
||||
|
||||
let rules = &config.layer_rules;
|
||||
let rules =
|
||||
ResolvedLayerRules::compute(rules, layer, self.niri.is_at_startup);
|
||||
let mapped = MappedLayer::new(layer.clone(), rules, &config);
|
||||
|
||||
let output_size = output_size(&output);
|
||||
let scale = output.current_scale().fractional_scale();
|
||||
|
||||
let mapped = MappedLayer::new(
|
||||
layer.clone(),
|
||||
rules,
|
||||
output_size,
|
||||
scale,
|
||||
self.niri.clock.clone(),
|
||||
&config,
|
||||
);
|
||||
|
||||
let prev = self
|
||||
.niri
|
||||
.mapped_layer_surfaces
|
||||
@@ -161,8 +174,24 @@ impl State {
|
||||
self.niri.layer_shell_on_demand_focus = Some(layer.clone());
|
||||
}
|
||||
} else {
|
||||
self.niri.mapped_layer_surfaces.remove(layer);
|
||||
let was_mapped = self.niri.mapped_layer_surfaces.remove(layer).is_some();
|
||||
self.niri.unmapped_layer_surfaces.insert(surface.clone());
|
||||
|
||||
// After layer surface unmaps it has to perform the initial commit-configure
|
||||
// sequence again. This is a workaround until Smithay properly resets
|
||||
// initial_configure_sent upon the surface unmapping itself as it does for
|
||||
// toplevels.
|
||||
if was_mapped {
|
||||
with_states(surface, |states| {
|
||||
let mut data = states
|
||||
.data_map
|
||||
.get::<LayerSurfaceData>()
|
||||
.unwrap()
|
||||
.lock()
|
||||
.unwrap();
|
||||
data.initial_configure_sent = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let scale = output.current_scale();
|
||||
|
||||
+59
-13
@@ -25,7 +25,7 @@ use smithay::reexports::wayland_server::protocol::wl_data_source::WlDataSource;
|
||||
use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
use smithay::utils::{Logical, Point, Rectangle, Size};
|
||||
use smithay::utils::{Logical, Point, Rectangle};
|
||||
use smithay::wayland::compositor::{get_parent, with_states};
|
||||
use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier};
|
||||
use smithay::wayland::drm_lease::{
|
||||
@@ -76,8 +76,10 @@ use smithay::{
|
||||
};
|
||||
|
||||
pub use crate::handlers::xdg_shell::KdeDecorationsModeState;
|
||||
use crate::layout::workspace::WorkspaceId;
|
||||
use crate::layout::ActivateWindow;
|
||||
use crate::niri::{DndIcon, NewClient, State};
|
||||
use crate::protocols::ext_workspace::{self, ExtWorkspaceHandler, ExtWorkspaceManagerState};
|
||||
use crate::protocols::foreign_toplevel::{
|
||||
self, ForeignToplevelHandler, ForeignToplevelManagerState,
|
||||
};
|
||||
@@ -92,8 +94,9 @@ use crate::protocols::virtual_pointer::{
|
||||
};
|
||||
use crate::utils::{output_size, send_scale_transform, with_toplevel_role};
|
||||
use crate::{
|
||||
delegate_foreign_toplevel, delegate_gamma_control, delegate_mutter_x11_interop,
|
||||
delegate_output_management, delegate_screencopy, delegate_virtual_pointer,
|
||||
delegate_ext_workspace, delegate_foreign_toplevel, delegate_gamma_control,
|
||||
delegate_mutter_x11_interop, delegate_output_management, delegate_screencopy,
|
||||
delegate_virtual_pointer,
|
||||
};
|
||||
|
||||
pub const XDG_ACTIVATION_TOKEN_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
@@ -211,7 +214,7 @@ impl PointerConstraintsHandler for State {
|
||||
pointer.set_location(target);
|
||||
|
||||
// Redraw to update the cursor position if it's visible.
|
||||
if !self.niri.pointer_hidden {
|
||||
if self.niri.pointer_visibility.is_visible() {
|
||||
// FIXME: redraw only outputs overlapping the cursor.
|
||||
self.niri.queue_redraw_all();
|
||||
}
|
||||
@@ -369,7 +372,7 @@ impl ClientDndGrabHandler for State {
|
||||
// parameters from Smithay I guess.
|
||||
//
|
||||
// Assume that hidden pointer means touch DnD.
|
||||
if !self.niri.pointer_hidden {
|
||||
if self.niri.pointer_visibility.is_visible() {
|
||||
// We can't even get the current pointer location because it's locked (we're deep
|
||||
// in the grab call stack here). So use the last known one.
|
||||
if let Some(output) = &self.niri.pointer_contents.output {
|
||||
@@ -414,6 +417,7 @@ delegate_ext_data_control!(State);
|
||||
impl OutputHandler for State {
|
||||
fn output_bound(&mut self, output: Output, wl_output: WlOutput) {
|
||||
foreign_toplevel::on_output_bound(self, &output, &wl_output);
|
||||
ext_workspace::on_output_bound(self, &output, &wl_output);
|
||||
}
|
||||
}
|
||||
delegate_output!(State);
|
||||
@@ -470,7 +474,7 @@ delegate_session_lock!(State);
|
||||
pub fn configure_lock_surface(surface: &LockSurface, output: &Output) {
|
||||
surface.with_pending_state(|states| {
|
||||
let size = output_size(output);
|
||||
states.size = Some(Size::from((size.w as u32, size.h as u32)));
|
||||
states.size = Some(size.to_i32_round());
|
||||
});
|
||||
let scale = output.current_scale();
|
||||
let transform = output.current_transform();
|
||||
@@ -574,6 +578,42 @@ impl ForeignToplevelHandler for State {
|
||||
}
|
||||
delegate_foreign_toplevel!(State);
|
||||
|
||||
impl ExtWorkspaceHandler for State {
|
||||
fn ext_workspace_manager_state(&mut self) -> &mut ExtWorkspaceManagerState {
|
||||
&mut self.niri.ext_workspace_state
|
||||
}
|
||||
|
||||
fn activate_workspace(&mut self, id: WorkspaceId) {
|
||||
let reference = niri_config::WorkspaceReference::Id(id.get());
|
||||
if let Some((mut output, index)) = self.niri.find_output_and_workspace_index(reference) {
|
||||
if let Some(active) = self.niri.layout.active_output() {
|
||||
if output.as_ref() == Some(active) {
|
||||
output = None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(output) = output {
|
||||
self.niri.layout.focus_output(&output);
|
||||
}
|
||||
self.niri.layout.switch_workspace(index);
|
||||
// No mouse warp: assuming the layer-shell bar workspaces use-case.
|
||||
|
||||
// FIXME: granular
|
||||
self.niri.queue_redraw_all();
|
||||
}
|
||||
}
|
||||
|
||||
fn assign_workspace(&mut self, ws_id: WorkspaceId, output: Output) {
|
||||
let reference = niri_config::WorkspaceReference::Id(ws_id.get());
|
||||
if let Some((old_output, old_idx)) = self.niri.find_output_and_workspace_index(reference) {
|
||||
self.niri
|
||||
.layout
|
||||
.move_workspace_to_output_by_id(old_idx, old_output, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
delegate_ext_workspace!(State);
|
||||
|
||||
impl ScreencopyHandler for State {
|
||||
fn frame(&mut self, manager: &ZwlrScreencopyManagerV1, screencopy: Screencopy) {
|
||||
// If with_damage then push it onto the queue for redraw of the output,
|
||||
@@ -707,6 +747,8 @@ impl GammaControlHandler for State {
|
||||
}
|
||||
delegate_gamma_control!(State);
|
||||
|
||||
struct UrgentOnlyMarker;
|
||||
|
||||
impl XdgActivationHandler for State {
|
||||
fn activation_state(&mut self) -> &mut XdgActivationState {
|
||||
&mut self.niri.activation_state
|
||||
@@ -716,11 +758,10 @@ impl XdgActivationHandler for State {
|
||||
// Tokens without a serial are urgency-only. This is not specified, but it seems to be the
|
||||
// common client behavior.
|
||||
//
|
||||
// We don't have urgency yet, so just ignore such tokens.
|
||||
//
|
||||
// See also: https://gitlab.freedesktop.org/wayland/wayland-protocols/-/issues/150
|
||||
let Some((serial, seat)) = data.serial else {
|
||||
return false;
|
||||
data.user_data.insert_if_missing(|| UrgentOnlyMarker);
|
||||
return true;
|
||||
};
|
||||
let Some(seat) = Seat::<State>::from_resource(&seat) else {
|
||||
return false;
|
||||
@@ -760,11 +801,16 @@ impl XdgActivationHandler for State {
|
||||
surface: WlSurface,
|
||||
) {
|
||||
if token_data.timestamp.elapsed() < XDG_ACTIVATION_TOKEN_TIMEOUT {
|
||||
if let Some((mapped, _)) = self.niri.layout.find_window_and_output(&surface) {
|
||||
if let Some((mapped, _)) = self.niri.layout.find_window_and_output_mut(&surface) {
|
||||
let window = mapped.window.clone();
|
||||
self.niri.layout.activate_window(&window);
|
||||
self.niri.layer_shell_on_demand_focus = None;
|
||||
self.niri.queue_redraw_all();
|
||||
if token_data.user_data.get::<UrgentOnlyMarker>().is_some() {
|
||||
mapped.set_urgent(true);
|
||||
self.niri.queue_redraw_all();
|
||||
} else {
|
||||
self.niri.layout.activate_window(&window);
|
||||
self.niri.layer_shell_on_demand_focus = None;
|
||||
self.niri.queue_redraw_all();
|
||||
}
|
||||
} else if let Some(unmapped) = self.niri.unmapped_windows.get_mut(&surface) {
|
||||
unmapped.activation_token_data = Some(token_data);
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ impl XdgShellHandler for State {
|
||||
|
||||
match start_data {
|
||||
PointerOrTouchStartData::Pointer(start_data) => {
|
||||
let grab = MoveGrab::new(start_data, window);
|
||||
let grab = MoveGrab::new(start_data, window, false);
|
||||
pointer.set_grab(self, grab, serial, Focus::Clear);
|
||||
}
|
||||
PointerOrTouchStartData::Touch(start_data) => {
|
||||
@@ -316,17 +316,28 @@ impl XdgShellHandler for State {
|
||||
} else if let Some(output) = self.niri.layout.active_output() {
|
||||
let layers = layer_map_for_output(output);
|
||||
|
||||
if layers
|
||||
.layer_for_surface(&root, WindowSurfaceType::TOPLEVEL)
|
||||
.is_none()
|
||||
{
|
||||
// FIXME: somewhere here we probably need to check is_overview_open to match the logic
|
||||
// in update_keyboard_focus().
|
||||
|
||||
if let Some(layer) = layers.layer_for_surface(&root, WindowSurfaceType::TOPLEVEL) {
|
||||
// This is a grab for a layer surface.
|
||||
|
||||
if let Some(mapped) = self.niri.mapped_layer_surfaces.get(layer) {
|
||||
if mapped.place_within_backdrop() {
|
||||
trace!("ignoring popup grab for a layer surface within overview backdrop");
|
||||
let _ = PopupManager::dismiss_popup(&root, &popup);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// This is a grab for a regular window; check that there's no layer surface with a
|
||||
// higher input priority.
|
||||
|
||||
if layers.layers_on(Layer::Overlay).any(|l| {
|
||||
l.cached_state().keyboard_interactivity
|
||||
(l.cached_state().keyboard_interactivity
|
||||
== wlr_layer::KeyboardInteractivity::Exclusive
|
||||
|| Some(l) == self.niri.layer_shell_on_demand_focus.as_ref()
|
||||
|| Some(l) == self.niri.layer_shell_on_demand_focus.as_ref())
|
||||
&& self.niri.mapped_layer_surfaces.contains_key(l)
|
||||
}) {
|
||||
trace!("ignoring toplevel popup grab because the overlay layer has focus");
|
||||
let _ = PopupManager::dismiss_popup(&root, &popup);
|
||||
@@ -336,9 +347,10 @@ impl XdgShellHandler for State {
|
||||
let mon = self.niri.layout.monitor_for_output(output).unwrap();
|
||||
if !mon.render_above_top_layer()
|
||||
&& layers.layers_on(Layer::Top).any(|l| {
|
||||
l.cached_state().keyboard_interactivity
|
||||
(l.cached_state().keyboard_interactivity
|
||||
== wlr_layer::KeyboardInteractivity::Exclusive
|
||||
|| Some(l) == self.niri.layer_shell_on_demand_focus.as_ref()
|
||||
|| Some(l) == self.niri.layer_shell_on_demand_focus.as_ref())
|
||||
&& self.niri.mapped_layer_surfaces.contains_key(l)
|
||||
})
|
||||
{
|
||||
trace!("ignoring toplevel popup grab because the top layer has focus");
|
||||
@@ -1062,6 +1074,19 @@ impl State {
|
||||
// The target geometry for the positioner should be relative to its parent's geometry, so
|
||||
// we will compute that here.
|
||||
let mut target = Rectangle::from_size(output_geo.size);
|
||||
|
||||
// Background and bottom layer popups render below the top and the overlay layer, so let's
|
||||
// put them into the non-exclusive zone.
|
||||
//
|
||||
// FIXME: ideally this should use the "top and overlay layer" non-exclusive zone, but
|
||||
// Smithay only computes the "all layers" non-exclusive zone atm.
|
||||
//
|
||||
// FIXME: related to the above, top layer popups should use the "overlay layer"
|
||||
// non-exclusive zone.
|
||||
if matches!(layer_surface.layer(), Layer::Background | Layer::Bottom) {
|
||||
target = map.non_exclusive_zone();
|
||||
}
|
||||
|
||||
target.loc -= layer_geo.loc;
|
||||
target.loc -= get_popup_toplevel_coords(popup);
|
||||
|
||||
|
||||
+967
-178
File diff suppressed because it is too large
Load Diff
+42
-5
@@ -1,10 +1,11 @@
|
||||
use smithay::backend::input::ButtonState;
|
||||
use smithay::desktop::Window;
|
||||
use smithay::input::pointer::{
|
||||
AxisFrame, ButtonEvent, CursorImageStatus, GestureHoldBeginEvent, GestureHoldEndEvent,
|
||||
GesturePinchBeginEvent, GesturePinchEndEvent, GesturePinchUpdateEvent, GestureSwipeBeginEvent,
|
||||
GestureSwipeEndEvent, GestureSwipeUpdateEvent, GrabStartData as PointerGrabStartData,
|
||||
MotionEvent, PointerGrab, PointerInnerHandle, RelativeMotionEvent,
|
||||
AxisFrame, ButtonEvent, CursorIcon, CursorImageStatus, GestureHoldBeginEvent,
|
||||
GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent, GesturePinchUpdateEvent,
|
||||
GestureSwipeBeginEvent, GestureSwipeEndEvent, GestureSwipeUpdateEvent,
|
||||
GrabStartData as PointerGrabStartData, MotionEvent, PointerGrab, PointerInnerHandle,
|
||||
RelativeMotionEvent,
|
||||
};
|
||||
use smithay::input::SeatHandler;
|
||||
use smithay::utils::{IsAlive, Logical, Point};
|
||||
@@ -15,14 +16,32 @@ pub struct MoveGrab {
|
||||
start_data: PointerGrabStartData<State>,
|
||||
last_location: Point<f64, Logical>,
|
||||
window: Window,
|
||||
gesture: GestureState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GestureState {
|
||||
Recognizing,
|
||||
Move,
|
||||
}
|
||||
|
||||
impl MoveGrab {
|
||||
pub fn new(start_data: PointerGrabStartData<State>, window: Window) -> Self {
|
||||
pub fn new(
|
||||
start_data: PointerGrabStartData<State>,
|
||||
window: Window,
|
||||
use_threshold: bool,
|
||||
) -> Self {
|
||||
let gesture = if use_threshold {
|
||||
GestureState::Recognizing
|
||||
} else {
|
||||
GestureState::Move
|
||||
};
|
||||
|
||||
Self {
|
||||
last_location: start_data.location,
|
||||
start_data,
|
||||
window,
|
||||
gesture,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +72,24 @@ impl PointerGrab<State> for MoveGrab {
|
||||
let output = output.clone();
|
||||
let event_delta = event.location - self.last_location;
|
||||
self.last_location = event.location;
|
||||
|
||||
if self.gesture == GestureState::Recognizing {
|
||||
let c = event.location - self.start_data.location;
|
||||
|
||||
// Check if the gesture moved far enough to decide.
|
||||
if c.x * c.x + c.y * c.y >= 8. * 8. {
|
||||
self.gesture = GestureState::Move;
|
||||
|
||||
data.niri
|
||||
.cursor_manager
|
||||
.set_cursor_image(CursorImageStatus::Named(CursorIcon::Move));
|
||||
}
|
||||
}
|
||||
|
||||
if self.gesture != GestureState::Move {
|
||||
return;
|
||||
}
|
||||
|
||||
let ongoing = data.niri.layout.interactive_move_update(
|
||||
&self.window,
|
||||
event_delta,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Swipe gesture from scroll events.
|
||||
//!
|
||||
//! Tracks when to begin, update, and end a swipe gesture from pointer axis events, also whether
|
||||
//! the gesture is vertical or horizontal. Necessary because libinput only provides touchpad swipe
|
||||
//! gesture events for 3+ fingers.
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ScrollSwipeGesture {
|
||||
ongoing: bool,
|
||||
vertical: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
BeginUpdate,
|
||||
Update,
|
||||
End,
|
||||
}
|
||||
|
||||
impl ScrollSwipeGesture {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
ongoing: false,
|
||||
vertical: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, dx: f64, dy: f64) -> Action {
|
||||
if dx == 0. && dy == 0. {
|
||||
self.ongoing = false;
|
||||
Action::End
|
||||
} else if !self.ongoing {
|
||||
self.ongoing = true;
|
||||
self.vertical = dy != 0.;
|
||||
Action::BeginUpdate
|
||||
} else {
|
||||
Action::Update
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) -> bool {
|
||||
if self.ongoing {
|
||||
self.ongoing = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_vertical(&self) -> bool {
|
||||
self.vertical
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ScrollSwipeGesture {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn begin(self) -> bool {
|
||||
self == Action::BeginUpdate
|
||||
}
|
||||
|
||||
pub fn end(self) -> bool {
|
||||
self == Action::End
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,14 @@ use smithay::input::SeatHandler;
|
||||
use smithay::output::Output;
|
||||
use smithay::utils::{Logical, Point};
|
||||
|
||||
use crate::layout::workspace::WorkspaceId;
|
||||
use crate::niri::State;
|
||||
|
||||
pub struct SpatialMovementGrab {
|
||||
start_data: PointerGrabStartData<State>,
|
||||
last_location: Point<f64, Logical>,
|
||||
output: Output,
|
||||
workspace_id: WorkspaceId,
|
||||
gesture: GestureState,
|
||||
}
|
||||
|
||||
@@ -27,12 +29,24 @@ enum GestureState {
|
||||
}
|
||||
|
||||
impl SpatialMovementGrab {
|
||||
pub fn new(start_data: PointerGrabStartData<State>, output: Output) -> Self {
|
||||
pub fn new(
|
||||
start_data: PointerGrabStartData<State>,
|
||||
output: Output,
|
||||
workspace_id: WorkspaceId,
|
||||
is_view_offset: bool,
|
||||
) -> Self {
|
||||
let gesture = if is_view_offset {
|
||||
GestureState::ViewOffset
|
||||
} else {
|
||||
GestureState::Recognizing
|
||||
};
|
||||
|
||||
Self {
|
||||
last_location: start_data.location,
|
||||
start_data,
|
||||
output,
|
||||
gesture: GestureState::Recognizing,
|
||||
workspace_id,
|
||||
gesture,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,10 +54,8 @@ impl SpatialMovementGrab {
|
||||
let layout = &mut state.niri.layout;
|
||||
let res = match self.gesture {
|
||||
GestureState::Recognizing => None,
|
||||
GestureState::ViewOffset => layout.view_offset_gesture_end(false, Some(false)),
|
||||
GestureState::WorkspaceSwitch => {
|
||||
layout.workspace_switch_gesture_end(false, Some(false))
|
||||
}
|
||||
GestureState::ViewOffset => layout.view_offset_gesture_end(Some(false)),
|
||||
GestureState::WorkspaceSwitch => layout.workspace_switch_gesture_end(Some(false)),
|
||||
};
|
||||
|
||||
if let Some(output) = res {
|
||||
@@ -81,8 +93,16 @@ impl PointerGrab<State> for SpatialMovementGrab {
|
||||
if c.x * c.x + c.y * c.y >= 8. * 8. {
|
||||
if c.x.abs() > c.y.abs() {
|
||||
self.gesture = GestureState::ViewOffset;
|
||||
layout.view_offset_gesture_begin(&self.output, false);
|
||||
layout.view_offset_gesture_update(-c.x, timestamp, false)
|
||||
if let Some((ws_idx, ws)) = layout.find_workspace_by_id(self.workspace_id) {
|
||||
if ws.current_output() == Some(&self.output) {
|
||||
layout.view_offset_gesture_begin(&self.output, Some(ws_idx), false);
|
||||
layout.view_offset_gesture_update(-c.x, timestamp, false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
self.gesture = GestureState::WorkspaceSwitch;
|
||||
layout.workspace_switch_gesture_begin(&self.output, false);
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use smithay::desktop::Window;
|
||||
use smithay::input::touch::{
|
||||
DownEvent, GrabStartData as TouchGrabStartData, MotionEvent, OrientationEvent, ShapeEvent,
|
||||
TouchGrab, TouchInnerHandle, UpEvent,
|
||||
};
|
||||
use smithay::input::SeatHandler;
|
||||
use smithay::output::Output;
|
||||
use smithay::utils::{IsAlive, Logical, Point, Serial};
|
||||
|
||||
use crate::layout::workspace::{Workspace, WorkspaceId};
|
||||
use crate::niri::State;
|
||||
use crate::window::Mapped;
|
||||
|
||||
// When the touch is stationary for this much time, it becomes an interactive move.
|
||||
const INTERACTIVE_MOVE_THRESHOLD: Duration = Duration::from_millis(500);
|
||||
|
||||
pub struct TouchOverviewGrab {
|
||||
start_data: TouchGrabStartData<State>,
|
||||
start_timestamp: Duration,
|
||||
last_location: Point<f64, Logical>,
|
||||
output: Output,
|
||||
start_pos_within_output: Point<f64, Logical>,
|
||||
workspace_id: Option<WorkspaceId>,
|
||||
workspace_matched_narrow: bool,
|
||||
window: Option<Window>,
|
||||
gesture: GestureState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum GestureState {
|
||||
Recognizing,
|
||||
ViewOffset,
|
||||
WorkspaceSwitch,
|
||||
InteractiveMove,
|
||||
}
|
||||
|
||||
impl TouchOverviewGrab {
|
||||
pub fn new(
|
||||
start_data: TouchGrabStartData<State>,
|
||||
start_timestamp: Duration,
|
||||
output: Output,
|
||||
start_pos_within_output: Point<f64, Logical>,
|
||||
workspace_id: Option<WorkspaceId>,
|
||||
workspace_matched_narrow: bool,
|
||||
window: Option<Window>,
|
||||
) -> Self {
|
||||
Self {
|
||||
last_location: start_data.location,
|
||||
start_timestamp,
|
||||
start_data,
|
||||
output,
|
||||
start_pos_within_output,
|
||||
workspace_id,
|
||||
workspace_matched_narrow,
|
||||
window,
|
||||
gesture: GestureState::Recognizing,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_ungrab(&mut self, state: &mut State) {
|
||||
let layout = &mut state.niri.layout;
|
||||
match self.gesture {
|
||||
GestureState::Recognizing => {
|
||||
// Tap to activate.
|
||||
layout.focus_output(&self.output);
|
||||
|
||||
// Activate the workspace if necessary.
|
||||
if self.window.is_some() || self.workspace_matched_narrow {
|
||||
// When activating a window, we want to activate the window's current
|
||||
// workspace. Otherwise, find the workspace that we tapped on.
|
||||
let ws_matches = |ws: &Workspace<Mapped>| {
|
||||
if let Some(window) = &self.window {
|
||||
ws.has_window(window)
|
||||
} else if let Some(ws_id) = self.workspace_id {
|
||||
ws.id() == ws_id
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let ws_idx = if let Some((Some(mon), ws_idx, _)) =
|
||||
layout.workspaces().find(|(_, _, ws)| ws_matches(ws))
|
||||
{
|
||||
// The workspace could've moved to a different output in the meantime.
|
||||
(*mon.output() == self.output).then_some(ws_idx)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(ws_idx) = ws_idx {
|
||||
layout.toggle_overview_to_workspace(ws_idx);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(window) = self.window.as_ref() {
|
||||
layout.activate_window(window);
|
||||
}
|
||||
}
|
||||
GestureState::ViewOffset => {
|
||||
layout.view_offset_gesture_end(Some(false));
|
||||
}
|
||||
GestureState::WorkspaceSwitch => {
|
||||
layout.workspace_switch_gesture_end(Some(false));
|
||||
}
|
||||
GestureState::InteractiveMove => {
|
||||
layout.interactive_move_end(self.window.as_ref().unwrap());
|
||||
}
|
||||
};
|
||||
|
||||
state.niri.queue_redraw_all();
|
||||
}
|
||||
}
|
||||
|
||||
impl TouchGrab<State> for TouchOverviewGrab {
|
||||
fn down(
|
||||
&mut self,
|
||||
data: &mut State,
|
||||
handle: &mut TouchInnerHandle<'_, State>,
|
||||
_focus: Option<(<State as SeatHandler>::TouchFocus, Point<f64, Logical>)>,
|
||||
event: &DownEvent,
|
||||
seq: Serial,
|
||||
) {
|
||||
handle.down(data, None, event, seq);
|
||||
}
|
||||
|
||||
fn up(
|
||||
&mut self,
|
||||
data: &mut State,
|
||||
handle: &mut TouchInnerHandle<'_, State>,
|
||||
event: &UpEvent,
|
||||
seq: Serial,
|
||||
) {
|
||||
handle.up(data, event, seq);
|
||||
|
||||
if event.slot != self.start_data.slot {
|
||||
return;
|
||||
}
|
||||
|
||||
handle.unset_grab(self, data);
|
||||
}
|
||||
|
||||
fn motion(
|
||||
&mut self,
|
||||
data: &mut State,
|
||||
handle: &mut TouchInnerHandle<'_, State>,
|
||||
_focus: Option<(<State as SeatHandler>::TouchFocus, Point<f64, Logical>)>,
|
||||
event: &MotionEvent,
|
||||
seq: Serial,
|
||||
) {
|
||||
handle.motion(data, None, event, seq);
|
||||
|
||||
if event.slot != self.start_data.slot {
|
||||
return;
|
||||
}
|
||||
|
||||
let timestamp = Duration::from_millis(u64::from(event.time));
|
||||
let layout = &mut data.niri.layout;
|
||||
|
||||
// Check if we should become interactive move.
|
||||
if matches!(self.gesture, GestureState::Recognizing) {
|
||||
if let Some(window) = self.window.as_ref().filter(|win| win.alive()) {
|
||||
let passed = timestamp.saturating_sub(self.start_timestamp);
|
||||
if INTERACTIVE_MOVE_THRESHOLD <= passed
|
||||
&& layout.interactive_move_begin(
|
||||
window.clone(),
|
||||
&self.output,
|
||||
self.start_pos_within_output,
|
||||
)
|
||||
{
|
||||
self.gesture = GestureState::InteractiveMove;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should become a spatial scroll.
|
||||
if matches!(self.gesture, GestureState::Recognizing) {
|
||||
let c = event.location - self.start_data.location;
|
||||
|
||||
// Check if the gesture moved far enough to decide. Threshold copied from libadwaita.
|
||||
if c.x * c.x + c.y * c.y >= 16. * 16. {
|
||||
if let Some(ws_id) = self.workspace_id.filter(|_| c.x.abs() > c.y.abs()) {
|
||||
if let Some((ws_idx, ws)) = layout.find_workspace_by_id(ws_id) {
|
||||
if ws.current_output() == Some(&self.output) {
|
||||
layout.view_offset_gesture_begin(&self.output, Some(ws_idx), false);
|
||||
self.gesture = GestureState::ViewOffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(self.gesture, GestureState::Recognizing) {
|
||||
layout.workspace_switch_gesture_begin(&self.output, false);
|
||||
self.gesture = GestureState::WorkspaceSwitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Do nothing if still recognizing.
|
||||
if matches!(self.gesture, GestureState::Recognizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
let delta = event.location - self.last_location;
|
||||
self.last_location = event.location;
|
||||
|
||||
let ongoing = match self.gesture {
|
||||
GestureState::Recognizing => unreachable!(),
|
||||
GestureState::ViewOffset => layout
|
||||
.view_offset_gesture_update(-delta.x, timestamp, false)
|
||||
.is_some(),
|
||||
GestureState::WorkspaceSwitch => layout
|
||||
.workspace_switch_gesture_update(-delta.y, timestamp, false)
|
||||
.is_some(),
|
||||
GestureState::InteractiveMove => {
|
||||
let window = self.window.as_ref().unwrap();
|
||||
if let Some((output, pos_within_output)) = data.niri.output_under(event.location) {
|
||||
let output = output.clone();
|
||||
data.niri.layout.interactive_move_update(
|
||||
window,
|
||||
delta,
|
||||
output,
|
||||
pos_within_output,
|
||||
)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if ongoing {
|
||||
data.niri.queue_redraw_all();
|
||||
} else {
|
||||
handle.unset_grab(self, data);
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(&mut self, data: &mut State, handle: &mut TouchInnerHandle<'_, State>, seq: Serial) {
|
||||
handle.frame(data, seq);
|
||||
}
|
||||
|
||||
fn cancel(&mut self, data: &mut State, handle: &mut TouchInnerHandle<'_, State>, seq: Serial) {
|
||||
handle.cancel(data, seq);
|
||||
handle.unset_grab(self, data);
|
||||
}
|
||||
|
||||
fn shape(
|
||||
&mut self,
|
||||
data: &mut State,
|
||||
handle: &mut TouchInnerHandle<'_, State>,
|
||||
event: &ShapeEvent,
|
||||
seq: Serial,
|
||||
) {
|
||||
handle.shape(data, event, seq);
|
||||
}
|
||||
|
||||
fn orientation(
|
||||
&mut self,
|
||||
data: &mut State,
|
||||
handle: &mut TouchInnerHandle<'_, State>,
|
||||
event: &OrientationEvent,
|
||||
seq: Serial,
|
||||
) {
|
||||
handle.orientation(data, event, seq);
|
||||
}
|
||||
|
||||
fn start_data(&self) -> &TouchGrabStartData<State> {
|
||||
&self.start_data
|
||||
}
|
||||
|
||||
fn unset(&mut self, data: &mut State) {
|
||||
self.on_ungrab(data);
|
||||
}
|
||||
}
|
||||
+79
-38
@@ -1,3 +1,4 @@
|
||||
use std::io::ErrorKind;
|
||||
use std::iter::Peekable;
|
||||
use std::slice;
|
||||
|
||||
@@ -5,8 +6,8 @@ use anyhow::{anyhow, bail, Context};
|
||||
use niri_config::OutputName;
|
||||
use niri_ipc::socket::Socket;
|
||||
use niri_ipc::{
|
||||
Event, KeyboardLayouts, LogicalOutput, Mode, Output, OutputConfigChanged, Request, Response,
|
||||
Transform, Window,
|
||||
Event, KeyboardLayouts, LogicalOutput, Mode, Output, OutputConfigChanged, Overview, Request,
|
||||
Response, Transform, Window,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -32,24 +33,35 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> {
|
||||
Msg::KeyboardLayouts => Request::KeyboardLayouts,
|
||||
Msg::EventStream => Request::EventStream,
|
||||
Msg::RequestError => Request::ReturnError,
|
||||
Msg::OverviewState => Request::OverviewState,
|
||||
};
|
||||
|
||||
let socket = Socket::connect().context("error connecting to the niri socket")?;
|
||||
let mut socket = Socket::connect().context("error connecting to the niri socket")?;
|
||||
|
||||
let (reply, mut read_event) = socket
|
||||
.send(request)
|
||||
.context("error communicating with niri")?;
|
||||
let result = socket.send(request);
|
||||
|
||||
let compositor_version = match reply {
|
||||
Err(_) if !matches!(msg, Msg::Version) => {
|
||||
// If we got an error, it might be that the CLI is a different version from the running
|
||||
// niri instance. Request the running instance version to compare and print a message.
|
||||
Socket::connect()
|
||||
.and_then(|socket| socket.send(Request::Version))
|
||||
.ok()
|
||||
.map(|(reply, _read_event)| reply)
|
||||
// For errors that can be caused by a version mismatch between the running niri instance and
|
||||
// the niri msg CLI, we will try to fetch and compare the versions.
|
||||
let check_compositor_version = match &result {
|
||||
Err(err) => {
|
||||
// Response JSON parsing errors.
|
||||
matches!(
|
||||
err.kind(),
|
||||
ErrorKind::InvalidData | ErrorKind::UnexpectedEof
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
// Error returned from niri.
|
||||
Ok(Err(_)) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let compositor_version = if check_compositor_version && !matches!(msg, Msg::Version) {
|
||||
// Reconnect to support older niri versions with one request per connection.
|
||||
Socket::connect()
|
||||
.and_then(|mut socket| socket.send(Request::Version))
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Default SIGPIPE so that our prints don't panic on stdout closing.
|
||||
@@ -57,32 +69,31 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> {
|
||||
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
|
||||
}
|
||||
|
||||
let response = reply.map_err(|err_msg| {
|
||||
// Check for CLI-server version mismatch to add helpful context.
|
||||
match compositor_version {
|
||||
Some(Ok(Response::Version(compositor_version))) => {
|
||||
let cli_version = version();
|
||||
if cli_version != compositor_version {
|
||||
eprintln!("Running niri compositor has a different version from the niri CLI:");
|
||||
eprintln!("Compositor version: {compositor_version}");
|
||||
eprintln!("CLI version: {cli_version}");
|
||||
eprintln!("Did you forget to restart niri after an update?");
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
eprintln!("Unable to get the running niri compositor version.");
|
||||
// Check for CLI-server version mismatch to add helpful context.
|
||||
match compositor_version {
|
||||
Some(Ok(Response::Version(compositor_version))) => {
|
||||
let cli_version = version();
|
||||
if cli_version != compositor_version {
|
||||
eprintln!("Running niri compositor has a different version from the niri CLI:");
|
||||
eprintln!("Compositor version: {compositor_version}");
|
||||
eprintln!("CLI version: {cli_version}");
|
||||
eprintln!("Did you forget to restart niri after an update?");
|
||||
eprintln!();
|
||||
}
|
||||
None => {
|
||||
// Communication error, or the original request was already a version request.
|
||||
// Don't add irrelevant context.
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
eprintln!("Unable to get the running niri compositor version.");
|
||||
eprintln!("Did you forget to restart niri after an update?");
|
||||
eprintln!();
|
||||
}
|
||||
None => {
|
||||
// Communication error, or the original request was already a version request, or the
|
||||
// original request had succeeded. Don't add irrelevant context.
|
||||
}
|
||||
}
|
||||
|
||||
anyhow!(err_msg).context("niri returned an error")
|
||||
})?;
|
||||
let reply = result.context("error communicating with niri")?;
|
||||
let response = reply.map_err(|err_msg| anyhow!(err_msg).context("niri returned an error"))?;
|
||||
|
||||
match msg {
|
||||
Msg::RequestError => {
|
||||
@@ -286,7 +297,7 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> {
|
||||
let [r, g, b] = color.rgb.map(|v| (v.clamp(0., 1.) * 255.).round() as u8);
|
||||
|
||||
println!("Picked color: rgb({r}, {g}, {b})",);
|
||||
println!("Hex: #{:02x}{:02x}{:02x}", r, g, b);
|
||||
println!("Hex: #{r:02x}{g:02x}{b:02x}");
|
||||
} else {
|
||||
println!("No color was picked.");
|
||||
}
|
||||
@@ -391,6 +402,7 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> {
|
||||
println!("Started reading events.");
|
||||
}
|
||||
|
||||
let mut read_event = socket.read_events();
|
||||
loop {
|
||||
let event = read_event().context("error reading event from niri")?;
|
||||
|
||||
@@ -404,6 +416,9 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> {
|
||||
Event::WorkspacesChanged { workspaces } => {
|
||||
println!("Workspaces changed: {workspaces:?}");
|
||||
}
|
||||
Event::WorkspaceUrgencyChanged { id, urgent } => {
|
||||
println!("Workspace {id}: urgency changed to {urgent}");
|
||||
}
|
||||
Event::WorkspaceActivated { id, focused } => {
|
||||
let word = if focused { "focused" } else { "activated" };
|
||||
println!("Workspace {word}: {id}");
|
||||
@@ -429,15 +444,40 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> {
|
||||
Event::WindowFocusChanged { id } => {
|
||||
println!("Window focus changed: {id:?}");
|
||||
}
|
||||
Event::WindowUrgencyChanged { id, urgent } => {
|
||||
println!("Window {id}: urgency changed to {urgent}");
|
||||
}
|
||||
Event::KeyboardLayoutsChanged { keyboard_layouts } => {
|
||||
println!("Keyboard layouts changed: {keyboard_layouts:?}");
|
||||
}
|
||||
Event::KeyboardLayoutSwitched { idx } => {
|
||||
println!("Keyboard layout switched: {idx}");
|
||||
}
|
||||
Event::OverviewOpenedOrClosed { is_open: opened } => {
|
||||
println!("Overview toggled: {opened}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Msg::OverviewState => {
|
||||
let Response::OverviewState(response) = response else {
|
||||
bail!("unexpected response: expected Overview, got {response:?}");
|
||||
};
|
||||
|
||||
if json {
|
||||
let response =
|
||||
serde_json::to_string(&response).context("error formatting response")?;
|
||||
println!("{response}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Overview { is_open } = response;
|
||||
if is_open {
|
||||
println!("Overview is open.");
|
||||
} else {
|
||||
println!("Overview is closed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -541,7 +581,8 @@ fn print_output(output: Output) -> anyhow::Result<()> {
|
||||
|
||||
fn print_window(window: &Window) {
|
||||
let focused = if window.is_focused { " (focused)" } else { "" };
|
||||
println!("Window ID {}:{focused}", window.id);
|
||||
let urgent = if window.is_urgent { " (urgent)" } else { "" };
|
||||
println!("Window ID {}:{focused}{urgent}", window.id);
|
||||
|
||||
if let Some(title) = &window.title {
|
||||
println!(" Title: \"{title}\"");
|
||||
|
||||
+113
-64
@@ -16,7 +16,9 @@ use futures_util::io::{AsyncReadExt, BufReader};
|
||||
use futures_util::{select_biased, AsyncBufReadExt, AsyncWrite, AsyncWriteExt, FutureExt as _};
|
||||
use niri_config::OutputName;
|
||||
use niri_ipc::state::{EventStreamState, EventStreamStatePart as _};
|
||||
use niri_ipc::{Event, KeyboardLayouts, OutputConfigChanged, Reply, Request, Response, Workspace};
|
||||
use niri_ipc::{
|
||||
Event, KeyboardLayouts, OutputConfigChanged, Overview, Reply, Request, Response, Workspace,
|
||||
};
|
||||
use smithay::desktop::layer_map_for_output;
|
||||
use smithay::input::pointer::{
|
||||
CursorIcon, CursorImageStatus, Focus, GrabStartData as PointerGrabStartData,
|
||||
@@ -183,76 +185,86 @@ fn on_new_ipc_client(state: &mut State, stream: UnixStream) {
|
||||
|
||||
async fn handle_client(ctx: ClientCtx, stream: Async<'static, UnixStream>) -> anyhow::Result<()> {
|
||||
let (read, mut write) = stream.split();
|
||||
let mut buf = String::new();
|
||||
let mut read = BufReader::new(read);
|
||||
|
||||
// Read a single line to allow extensibility in the future to keep reading.
|
||||
BufReader::new(read)
|
||||
.read_line(&mut buf)
|
||||
.await
|
||||
.context("error reading request")?;
|
||||
|
||||
let request = serde_json::from_str(&buf)
|
||||
.context("error parsing request")
|
||||
.map_err(|err| err.to_string());
|
||||
let requested_error = matches!(request, Ok(Request::ReturnError));
|
||||
let requested_event_stream = matches!(request, Ok(Request::EventStream));
|
||||
|
||||
let reply = match request {
|
||||
Ok(request) => process(&ctx, request).await,
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
|
||||
if let Err(err) = &reply {
|
||||
if !requested_error {
|
||||
warn!("error processing IPC request: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
let mut buf = serde_json::to_vec(&reply).context("error formatting reply")?;
|
||||
buf.push(b'\n');
|
||||
write.write_all(&buf).await.context("error writing reply")?;
|
||||
|
||||
if requested_event_stream {
|
||||
let (events_tx, events_rx) = async_channel::bounded(EVENT_STREAM_BUFFER_SIZE);
|
||||
let (disconnect_tx, disconnect_rx) = async_channel::bounded(1);
|
||||
|
||||
// Spawn a task for the client.
|
||||
let client = EventStreamClient {
|
||||
events: events_rx,
|
||||
disconnect: disconnect_rx,
|
||||
write: Box::new(write) as _,
|
||||
};
|
||||
let future = async move {
|
||||
if let Err(err) = handle_event_stream_client(client).await {
|
||||
warn!("error handling IPC event stream client: {err:?}");
|
||||
}
|
||||
};
|
||||
if let Err(err) = ctx.scheduler.schedule(future) {
|
||||
warn!("error scheduling IPC event stream future: {err:?}");
|
||||
}
|
||||
|
||||
// Send the initial state.
|
||||
{
|
||||
let state = ctx.event_stream_state.borrow();
|
||||
for event in state.replicate() {
|
||||
events_tx
|
||||
.try_send(event)
|
||||
.expect("initial event burst had more events than buffer size");
|
||||
loop {
|
||||
// Don't keep buf around to avoid clients wasting RAM by filling it with bogus data.
|
||||
let mut buf = Vec::new();
|
||||
let res = read.read_until(b'\n', &mut buf).await;
|
||||
match res {
|
||||
Ok(0) => return Ok(()),
|
||||
Ok(_) => (),
|
||||
// Normal client disconnection.
|
||||
Err(err) if err.kind() == io::ErrorKind::BrokenPipe => return Ok(()),
|
||||
Err(err) => {
|
||||
return Err(err).context("error reading request");
|
||||
}
|
||||
}
|
||||
|
||||
// Add it to the list.
|
||||
{
|
||||
let mut streams = ctx.event_streams.borrow_mut();
|
||||
let sender = EventStreamSender {
|
||||
events: events_tx,
|
||||
disconnect: disconnect_tx,
|
||||
let request = serde_json::from_slice(&buf)
|
||||
.context("error parsing request")
|
||||
.map_err(|err| err.to_string());
|
||||
let requested_error = matches!(request, Ok(Request::ReturnError));
|
||||
let requested_event_stream = matches!(request, Ok(Request::EventStream));
|
||||
|
||||
let reply = match request {
|
||||
Ok(request) => process(&ctx, request).await,
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
|
||||
if let Err(err) = &reply {
|
||||
if !requested_error {
|
||||
warn!("error processing IPC request: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
buf.clear();
|
||||
serde_json::to_writer(&mut buf, &reply).context("error formatting reply")?;
|
||||
buf.push(b'\n');
|
||||
write.write_all(&buf).await.context("error writing reply")?;
|
||||
|
||||
if requested_event_stream {
|
||||
let (events_tx, events_rx) = async_channel::bounded(EVENT_STREAM_BUFFER_SIZE);
|
||||
let (disconnect_tx, disconnect_rx) = async_channel::bounded(1);
|
||||
|
||||
// Spawn a task for the client.
|
||||
let client = EventStreamClient {
|
||||
events: events_rx,
|
||||
disconnect: disconnect_rx,
|
||||
write: Box::new(write) as _,
|
||||
};
|
||||
streams.push(sender);
|
||||
let future = async move {
|
||||
if let Err(err) = handle_event_stream_client(client).await {
|
||||
warn!("error handling IPC event stream client: {err:?}");
|
||||
}
|
||||
};
|
||||
if let Err(err) = ctx.scheduler.schedule(future) {
|
||||
warn!("error scheduling IPC event stream future: {err:?}");
|
||||
}
|
||||
|
||||
// Send the initial state.
|
||||
{
|
||||
let state = ctx.event_stream_state.borrow();
|
||||
for event in state.replicate() {
|
||||
events_tx
|
||||
.try_send(event)
|
||||
.expect("initial event burst had more events than buffer size");
|
||||
}
|
||||
}
|
||||
|
||||
// Add it to the list.
|
||||
{
|
||||
let mut streams = ctx.event_streams.borrow_mut();
|
||||
let sender = EventStreamSender {
|
||||
events: events_tx,
|
||||
disconnect: disconnect_tx,
|
||||
};
|
||||
streams.push(sender);
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process(ctx: &ClientCtx, request: Request) -> Reply {
|
||||
@@ -428,6 +440,11 @@ async fn process(ctx: &ClientCtx, request: Request) -> Reply {
|
||||
Response::FocusedOutput(output)
|
||||
}
|
||||
Request::EventStream => Response::Handled,
|
||||
Request::OverviewState => {
|
||||
let state = ctx.event_stream_state.borrow();
|
||||
let is_open = state.overview.is_open;
|
||||
Response::OverviewState(Overview { is_open })
|
||||
}
|
||||
};
|
||||
|
||||
Ok(response)
|
||||
@@ -469,6 +486,7 @@ fn make_ipc_window(mapped: &Mapped, workspace_id: Option<WorkspaceId>) -> niri_i
|
||||
workspace_id: workspace_id.map(|id| id.get()),
|
||||
is_focused: mapped.is_focused(),
|
||||
is_floating: mapped.is_floating(),
|
||||
is_urgent: mapped.is_urgent(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -524,6 +542,7 @@ impl State {
|
||||
pub fn ipc_refresh_layout(&mut self) {
|
||||
self.ipc_refresh_workspaces();
|
||||
self.ipc_refresh_windows();
|
||||
self.ipc_refresh_overview();
|
||||
}
|
||||
|
||||
fn ipc_refresh_workspaces(&mut self) {
|
||||
@@ -571,6 +590,12 @@ impl State {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if this workspace urgent state changed.
|
||||
let urgent = ws.is_urgent();
|
||||
if urgent != ipc_ws.is_urgent {
|
||||
events.push(Event::WorkspaceUrgencyChanged { id, urgent });
|
||||
}
|
||||
|
||||
// Check if this workspace became focused.
|
||||
let is_focused = Some(id) == focused_ws_id;
|
||||
if is_focused && !ipc_ws.is_focused {
|
||||
@@ -602,6 +627,7 @@ impl State {
|
||||
idx: u8::try_from(ws_idx + 1).unwrap_or(u8::MAX),
|
||||
name: ws.name().cloned(),
|
||||
output: mon.map(|mon| mon.output_name().clone()),
|
||||
is_urgent: ws.is_urgent(),
|
||||
is_active: mon.is_some_and(|mon| mon.active_workspace_idx() == ws_idx),
|
||||
is_focused: Some(id) == focused_ws_id,
|
||||
active_window_id: ws.active_window().map(|win| win.id().get()),
|
||||
@@ -665,6 +691,11 @@ impl State {
|
||||
if mapped.is_focused() && !ipc_win.is_focused {
|
||||
events.push(Event::WindowFocusChanged { id: Some(id) });
|
||||
}
|
||||
|
||||
let urgent = mapped.is_urgent();
|
||||
if urgent != ipc_win.is_urgent {
|
||||
events.push(Event::WindowUrgencyChanged { id, urgent })
|
||||
}
|
||||
});
|
||||
|
||||
// Check for closed windows.
|
||||
@@ -690,4 +721,22 @@ impl State {
|
||||
server.send_event(event);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ipc_refresh_overview(&mut self) {
|
||||
let Some(server) = &self.niri.ipc_server else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut state = server.event_stream_state.borrow_mut();
|
||||
let state = &mut state.overview;
|
||||
let is_open = self.niri.layout.is_overview_open();
|
||||
|
||||
if state.is_open == is_open {
|
||||
return;
|
||||
}
|
||||
|
||||
let event = Event::OverviewOpenedOrClosed { is_open };
|
||||
state.apply(event.clone());
|
||||
server.send_event(event);
|
||||
}
|
||||
}
|
||||
|
||||
+66
-5
@@ -6,14 +6,17 @@ use smithay::backend::renderer::element::surface::{
|
||||
use smithay::backend::renderer::element::Kind;
|
||||
use smithay::desktop::{LayerSurface, PopupManager};
|
||||
use smithay::utils::{Logical, Point, Scale, Size};
|
||||
use smithay::wayland::shell::wlr_layer::{ExclusiveZone, Layer};
|
||||
|
||||
use super::ResolvedLayerRules;
|
||||
use crate::animation::Clock;
|
||||
use crate::layout::shadow::Shadow;
|
||||
use crate::niri_render_elements;
|
||||
use crate::render_helpers::renderer::NiriRenderer;
|
||||
use crate::render_helpers::shadow::ShadowRenderElement;
|
||||
use crate::render_helpers::solid_color::{SolidColorBuffer, SolidColorRenderElement};
|
||||
use crate::render_helpers::{RenderTarget, SplitElements};
|
||||
use crate::utils::{baba_is_float_offset, round_logical_in_physical};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MappedLayer {
|
||||
@@ -28,6 +31,15 @@ pub struct MappedLayer {
|
||||
|
||||
/// The shadow around the surface.
|
||||
shadow: Shadow,
|
||||
|
||||
/// The view size for the layer surface's output.
|
||||
view_size: Size<f64, Logical>,
|
||||
|
||||
/// Scale of the output the layer surface is on (and rounds its sizes to).
|
||||
scale: f64,
|
||||
|
||||
/// Clock for driving animations.
|
||||
clock: Clock,
|
||||
}
|
||||
|
||||
niri_render_elements! {
|
||||
@@ -39,7 +51,14 @@ niri_render_elements! {
|
||||
}
|
||||
|
||||
impl MappedLayer {
|
||||
pub fn new(surface: LayerSurface, rules: ResolvedLayerRules, config: &Config) -> Self {
|
||||
pub fn new(
|
||||
surface: LayerSurface,
|
||||
rules: ResolvedLayerRules,
|
||||
view_size: Size<f64, Logical>,
|
||||
scale: f64,
|
||||
clock: Clock,
|
||||
config: &Config,
|
||||
) -> Self {
|
||||
let mut shadow_config = config.layout.shadow;
|
||||
// Shadows for layer surfaces need to be explicitly enabled.
|
||||
shadow_config.on = false;
|
||||
@@ -49,7 +68,10 @@ impl MappedLayer {
|
||||
surface,
|
||||
rules,
|
||||
block_out_buffer: SolidColorBuffer::new((0., 0.), [0., 0., 0., 1.]),
|
||||
view_size,
|
||||
scale,
|
||||
shadow: Shadow::new(shadow_config),
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,16 +87,27 @@ impl MappedLayer {
|
||||
self.shadow.update_shaders();
|
||||
}
|
||||
|
||||
pub fn update_render_elements(&mut self, size: Size<f64, Logical>, scale: Scale<f64>) {
|
||||
pub fn update_sizes(&mut self, view_size: Size<f64, Logical>, scale: f64) {
|
||||
self.view_size = view_size;
|
||||
self.scale = scale;
|
||||
}
|
||||
|
||||
pub fn update_render_elements(&mut self, size: Size<f64, Logical>) {
|
||||
// Round to physical pixels.
|
||||
let size = size.to_physical_precise_round(scale).to_logical(scale);
|
||||
let size = size
|
||||
.to_physical_precise_round(self.scale)
|
||||
.to_logical(self.scale);
|
||||
|
||||
self.block_out_buffer.resize(size);
|
||||
|
||||
let radius = self.rules.geometry_corner_radius.unwrap_or_default();
|
||||
// FIXME: is_active based on keyboard focus?
|
||||
self.shadow
|
||||
.update_render_elements(size, true, radius, scale.x, 1.);
|
||||
.update_render_elements(size, true, radius, self.scale, 1.);
|
||||
}
|
||||
|
||||
pub fn are_animations_ongoing(&self) -> bool {
|
||||
self.rules.baba_is_float
|
||||
}
|
||||
|
||||
pub fn surface(&self) -> &LayerSurface {
|
||||
@@ -96,16 +129,44 @@ impl MappedLayer {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn place_within_backdrop(&self) -> bool {
|
||||
if !self.rules.place_within_backdrop {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.surface.layer() != Layer::Background {
|
||||
return false;
|
||||
}
|
||||
|
||||
let state = self.surface.cached_state();
|
||||
if state.exclusive_zone != ExclusiveZone::DontCare {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn bob_offset(&self) -> Point<f64, Logical> {
|
||||
if !self.rules.baba_is_float {
|
||||
return Point::from((0., 0.));
|
||||
}
|
||||
|
||||
let y = baba_is_float_offset(self.clock.now(), self.view_size.h);
|
||||
let y = round_logical_in_physical(self.scale, y);
|
||||
Point::from((0., y))
|
||||
}
|
||||
|
||||
pub fn render<R: NiriRenderer>(
|
||||
&self,
|
||||
renderer: &mut R,
|
||||
location: Point<f64, Logical>,
|
||||
scale: Scale<f64>,
|
||||
target: RenderTarget,
|
||||
) -> SplitElements<LayerSurfaceRenderElement<R>> {
|
||||
let mut rv = SplitElements::default();
|
||||
|
||||
let scale = Scale::from(self.scale);
|
||||
let alpha = self.rules.opacity.unwrap_or(1.).clamp(0., 1.);
|
||||
let location = location + self.bob_offset();
|
||||
|
||||
if target.should_block_out(self.rules.block_out_from) {
|
||||
// Round to physical pixels.
|
||||
|
||||
@@ -19,6 +19,12 @@ pub struct ResolvedLayerRules {
|
||||
|
||||
/// Corner radius to assume this layer surface has.
|
||||
pub geometry_corner_radius: Option<CornerRadius>,
|
||||
|
||||
/// Whether to place this layer surface within the overview backdrop.
|
||||
pub place_within_backdrop: bool,
|
||||
|
||||
/// Whether to bob this window up and down.
|
||||
pub baba_is_float: bool,
|
||||
}
|
||||
|
||||
impl ResolvedLayerRules {
|
||||
@@ -37,6 +43,8 @@ impl ResolvedLayerRules {
|
||||
inactive_color: None,
|
||||
},
|
||||
geometry_corner_radius: None,
|
||||
place_within_backdrop: false,
|
||||
baba_is_float: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +81,12 @@ impl ResolvedLayerRules {
|
||||
if let Some(x) = rule.geometry_corner_radius {
|
||||
resolved.geometry_corner_radius = Some(x);
|
||||
}
|
||||
if let Some(x) = rule.place_within_backdrop {
|
||||
resolved.place_within_backdrop = x;
|
||||
}
|
||||
if let Some(x) = rule.baba_is_float {
|
||||
resolved.baba_is_float = x;
|
||||
}
|
||||
|
||||
resolved.shadow.merge_with(&rule.shadow);
|
||||
}
|
||||
|
||||
@@ -1090,7 +1090,7 @@ impl<W: LayoutElement> FloatingSpace<W> {
|
||||
self.interactive_resize = None;
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self, is_active: bool) {
|
||||
pub fn refresh(&mut self, is_active: bool, is_focused: bool) {
|
||||
let active = self.active_window_id.clone();
|
||||
for tile in &mut self.tiles {
|
||||
let win = tile.window_mut();
|
||||
@@ -1098,7 +1098,10 @@ impl<W: LayoutElement> FloatingSpace<W> {
|
||||
win.set_active_in_column(true);
|
||||
win.set_floating(true);
|
||||
|
||||
let is_active = is_active && Some(win.id()) == active.as_ref();
|
||||
let mut is_active = is_active && Some(win.id()) == active.as_ref();
|
||||
if self.options.deactivate_unfocused_windows {
|
||||
is_active &= is_focused;
|
||||
}
|
||||
win.set_activated(is_active);
|
||||
|
||||
let resize_data = self
|
||||
|
||||
@@ -59,6 +59,7 @@ impl FocusRing {
|
||||
win_size: Size<f64, Logical>,
|
||||
is_active: bool,
|
||||
is_border: bool,
|
||||
is_urgent: bool,
|
||||
view_rect: Rectangle<f64, Logical>,
|
||||
radius: CornerRadius,
|
||||
scale: f64,
|
||||
@@ -67,7 +68,9 @@ impl FocusRing {
|
||||
let width = self.config.width.0;
|
||||
self.full_size = win_size + Size::from((width, width)).upscale(2.);
|
||||
|
||||
let color = if is_active {
|
||||
let color = if is_urgent {
|
||||
self.config.urgent_color
|
||||
} else if is_active {
|
||||
self.config.active_color
|
||||
} else {
|
||||
self.config.inactive_color
|
||||
@@ -79,7 +82,9 @@ impl FocusRing {
|
||||
|
||||
let radius = radius.fit_to(self.full_size.w as f32, self.full_size.h as f32);
|
||||
|
||||
let gradient = if is_active {
|
||||
let gradient = if is_urgent {
|
||||
self.config.urgent_gradient
|
||||
} else if is_active {
|
||||
self.config.active_gradient
|
||||
} else {
|
||||
self.config.inactive_gradient
|
||||
|
||||
@@ -19,8 +19,10 @@ impl InsertHintElement {
|
||||
width: FloatOrInt(0.),
|
||||
active_color: config.color,
|
||||
inactive_color: config.color,
|
||||
urgent_color: config.color,
|
||||
active_gradient: config.gradient,
|
||||
inactive_gradient: config.gradient,
|
||||
urgent_gradient: config.gradient,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -31,8 +33,10 @@ impl InsertHintElement {
|
||||
width: FloatOrInt(0.),
|
||||
active_color: config.color,
|
||||
inactive_color: config.color,
|
||||
urgent_color: config.color,
|
||||
active_gradient: config.gradient,
|
||||
inactive_gradient: config.gradient,
|
||||
urgent_gradient: config.gradient,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,7 +52,7 @@ impl InsertHintElement {
|
||||
scale: f64,
|
||||
) {
|
||||
self.inner
|
||||
.update_render_elements(size, true, false, view_rect, radius, scale, 1.);
|
||||
.update_render_elements(size, true, false, false, view_rect, radius, scale, 1.);
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
|
||||
+772
-177
File diff suppressed because it is too large
Load Diff
+977
-200
File diff suppressed because it is too large
Load Diff
+225
-156
@@ -3,14 +3,14 @@ use std::iter::{self, zip};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use niri_config::{CenterFocusedColumn, CornerRadius, PresetSize, Struts};
|
||||
use niri_config::{CenterFocusedColumn, PresetSize, Struts};
|
||||
use niri_ipc::{ColumnDisplay, SizeChange};
|
||||
use ordered_float::NotNan;
|
||||
use smithay::backend::renderer::gles::GlesRenderer;
|
||||
use smithay::utils::{Logical, Point, Rectangle, Scale, Serial, Size};
|
||||
|
||||
use super::closing_window::{ClosingWindow, ClosingWindowRenderElement};
|
||||
use super::insert_hint_element::{InsertHintElement, InsertHintRenderElement};
|
||||
use super::monitor::InsertPosition;
|
||||
use super::tab_indicator::{TabIndicator, TabIndicatorRenderElement, TabInfo};
|
||||
use super::tile::{Tile, TileRenderElement, TileRenderSnapshot};
|
||||
use super::workspace::{InteractiveResize, ResolvedSize};
|
||||
@@ -67,12 +67,6 @@ pub struct ScrollingSpace<W: LayoutElement> {
|
||||
/// Windows in the closing animation.
|
||||
closing_windows: Vec<ClosingWindow>,
|
||||
|
||||
/// Indication where an interactively-moved window is about to be placed.
|
||||
insert_hint: Option<InsertHint>,
|
||||
|
||||
/// Insert hint element for rendering.
|
||||
insert_hint_element: InsertHintElement,
|
||||
|
||||
/// View size for this space.
|
||||
view_size: Size<f64, Logical>,
|
||||
|
||||
@@ -81,6 +75,12 @@ pub struct ScrollingSpace<W: LayoutElement> {
|
||||
/// Takes into account layer-shell exclusive zones and niri struts.
|
||||
working_area: Rectangle<f64, Logical>,
|
||||
|
||||
/// Working area for this space excluding struts.
|
||||
///
|
||||
/// Used for popup unconstraining. Popups can go over struts, but they shouldn't go over
|
||||
/// the layer-shell top layer (which renders on top of popups).
|
||||
parent_area: Rectangle<f64, Logical>,
|
||||
|
||||
/// Scale of the output the space is on (and rounds its sizes to).
|
||||
scale: f64,
|
||||
|
||||
@@ -96,23 +96,9 @@ niri_render_elements! {
|
||||
Tile = TileRenderElement<R>,
|
||||
ClosingWindow = ClosingWindowRenderElement,
|
||||
TabIndicator = TabIndicatorRenderElement,
|
||||
InsertHint = InsertHintRenderElement,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum InsertPosition {
|
||||
NewColumn(usize),
|
||||
InColumn(usize, usize),
|
||||
Floating,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InsertHint {
|
||||
pub position: InsertPosition,
|
||||
pub corner_radius: CornerRadius,
|
||||
}
|
||||
|
||||
/// Extra per-column data.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct ColumnData {
|
||||
@@ -133,6 +119,10 @@ pub(super) enum ViewOffset {
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ViewGesture {
|
||||
current_view_offset: f64,
|
||||
/// Animation for the extra offset to the current position.
|
||||
///
|
||||
/// For example, when we need to activate a specific window during a DnD scroll.
|
||||
animation: Option<Animation>,
|
||||
tracker: SwipeTracker,
|
||||
delta_from_tracker: f64,
|
||||
// The view offset we'll use if needed for activate_prev_column_on_removal.
|
||||
@@ -267,12 +257,12 @@ pub enum ScrollDirection {
|
||||
impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
pub fn new(
|
||||
view_size: Size<f64, Logical>,
|
||||
working_area: Rectangle<f64, Logical>,
|
||||
parent_area: Rectangle<f64, Logical>,
|
||||
scale: f64,
|
||||
clock: Clock,
|
||||
options: Rc<Options>,
|
||||
) -> Self {
|
||||
let working_area = compute_working_area(working_area, scale, options.struts);
|
||||
let working_area = compute_working_area(parent_area, scale, options.struts);
|
||||
|
||||
Self {
|
||||
columns: Vec::new(),
|
||||
@@ -283,10 +273,9 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
activate_prev_column_on_removal: None,
|
||||
view_offset_before_fullscreen: None,
|
||||
closing_windows: Vec::new(),
|
||||
insert_hint: None,
|
||||
insert_hint_element: InsertHintElement::new(options.insert_hint),
|
||||
view_size,
|
||||
working_area,
|
||||
parent_area,
|
||||
scale,
|
||||
clock,
|
||||
options,
|
||||
@@ -296,31 +285,33 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
pub fn update_config(
|
||||
&mut self,
|
||||
view_size: Size<f64, Logical>,
|
||||
working_area: Rectangle<f64, Logical>,
|
||||
parent_area: Rectangle<f64, Logical>,
|
||||
scale: f64,
|
||||
options: Rc<Options>,
|
||||
) {
|
||||
let working_area = compute_working_area(working_area, scale, options.struts);
|
||||
let working_area = compute_working_area(parent_area, scale, options.struts);
|
||||
|
||||
for (column, data) in zip(&mut self.columns, &mut self.data) {
|
||||
column.update_config(view_size, working_area, scale, options.clone());
|
||||
data.update(column);
|
||||
}
|
||||
|
||||
self.insert_hint_element.update_config(options.insert_hint);
|
||||
|
||||
self.view_size = view_size;
|
||||
self.working_area = working_area;
|
||||
self.parent_area = parent_area;
|
||||
self.scale = scale;
|
||||
self.options = options;
|
||||
|
||||
// Apply always-center and such right away.
|
||||
if !self.columns.is_empty() && !self.view_offset.is_gesture() {
|
||||
self.animate_view_offset_to_column(None, self.active_column_idx, None);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_shaders(&mut self) {
|
||||
for tile in self.tiles_mut() {
|
||||
tile.update_shaders();
|
||||
}
|
||||
|
||||
self.insert_hint_element.update_shaders();
|
||||
}
|
||||
|
||||
pub fn advance_animations(&mut self) {
|
||||
@@ -347,6 +338,12 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
gesture.dnd_nonzero_start_time = None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(anim) = &mut gesture.animation {
|
||||
if anim.is_done() {
|
||||
gesture.animation = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for col in &mut self.columns {
|
||||
@@ -360,7 +357,7 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
}
|
||||
|
||||
pub fn are_animations_ongoing(&self) -> bool {
|
||||
self.view_offset.is_animation()
|
||||
self.view_offset.is_animation_ongoing()
|
||||
|| self.columns.iter().any(Column::are_animations_ongoing)
|
||||
|| !self.closing_windows.is_empty()
|
||||
}
|
||||
@@ -382,18 +379,6 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
let view_rect = Rectangle::new(col_pos, view_size);
|
||||
col.update_render_elements(is_active, view_rect);
|
||||
}
|
||||
|
||||
if let Some(insert_hint) = &self.insert_hint {
|
||||
if let Some(area) = self.insert_hint_area(insert_hint) {
|
||||
let view_rect = Rectangle::new(area.loc.upscale(-1.), view_size);
|
||||
self.insert_hint_element.update_render_elements(
|
||||
area.size,
|
||||
view_rect,
|
||||
insert_hint.corner_radius,
|
||||
self.scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tiles(&self) -> impl Iterator<Item = &Tile<W>> + '_ {
|
||||
@@ -616,6 +601,11 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
return self.compute_new_view_offset_for_column_fit(target_x, idx);
|
||||
};
|
||||
|
||||
// Activating the same column.
|
||||
if prev_idx == idx {
|
||||
return self.compute_new_view_offset_for_column_fit(target_x, idx);
|
||||
}
|
||||
|
||||
// Always take the left or right neighbor of the target as the source.
|
||||
let source_idx = if prev_idx > idx {
|
||||
min(idx + 1, self.columns.len() - 1)
|
||||
@@ -664,8 +654,6 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
new_view_offset: f64,
|
||||
config: niri_config::Animation,
|
||||
) {
|
||||
self.view_offset.cancel_gesture();
|
||||
|
||||
let new_col_x = self.column_x(idx);
|
||||
let old_col_x = self.column_x(self.active_column_idx);
|
||||
let offset_delta = old_col_x - new_col_x;
|
||||
@@ -682,14 +670,28 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME: also compute and use current velocity.
|
||||
self.view_offset = ViewOffset::Animation(Animation::new(
|
||||
self.clock.clone(),
|
||||
self.view_offset.current(),
|
||||
new_view_offset,
|
||||
0.,
|
||||
config,
|
||||
));
|
||||
match &mut self.view_offset {
|
||||
ViewOffset::Gesture(gesture) if gesture.dnd_last_event_time.is_some() => {
|
||||
gesture.stationary_view_offset = new_view_offset;
|
||||
|
||||
let current_pos = gesture.current_view_offset - gesture.delta_from_tracker;
|
||||
gesture.delta_from_tracker = new_view_offset - current_pos;
|
||||
let offset_delta = new_view_offset - gesture.current_view_offset;
|
||||
gesture.current_view_offset = new_view_offset;
|
||||
|
||||
gesture.animate_from(-offset_delta, self.clock.clone(), config);
|
||||
}
|
||||
_ => {
|
||||
// FIXME: also compute and use current velocity.
|
||||
self.view_offset = ViewOffset::Animation(Animation::new(
|
||||
self.clock.clone(),
|
||||
self.view_offset.current(),
|
||||
new_view_offset,
|
||||
0.,
|
||||
config,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn animate_view_offset_to_column_centered(
|
||||
@@ -735,7 +737,10 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
}
|
||||
|
||||
fn activate_column_with_anim_config(&mut self, idx: usize, config: niri_config::Animation) {
|
||||
if self.active_column_idx == idx {
|
||||
if self.active_column_idx == idx
|
||||
// During a DnD scroll, animate even when activating the same window, for DnD hold.
|
||||
&& (self.columns.is_empty() || !self.view_offset.is_dnd_scroll())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -746,26 +751,17 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
config,
|
||||
);
|
||||
|
||||
self.active_column_idx = idx;
|
||||
if self.active_column_idx != idx {
|
||||
self.active_column_idx = idx;
|
||||
|
||||
// A different column was activated; reset the flag.
|
||||
self.activate_prev_column_on_removal = None;
|
||||
self.view_offset_before_fullscreen = None;
|
||||
self.interactive_resize = None;
|
||||
}
|
||||
|
||||
pub fn set_insert_hint(&mut self, insert_hint: InsertHint) {
|
||||
if self.options.insert_hint.off {
|
||||
return;
|
||||
// A different column was activated; reset the flag.
|
||||
self.activate_prev_column_on_removal = None;
|
||||
self.view_offset_before_fullscreen = None;
|
||||
self.interactive_resize = None;
|
||||
}
|
||||
self.insert_hint = Some(insert_hint);
|
||||
}
|
||||
|
||||
pub fn clear_insert_hint(&mut self) {
|
||||
self.insert_hint = None;
|
||||
}
|
||||
|
||||
pub fn get_insert_position(&self, pos: Point<f64, Logical>) -> InsertPosition {
|
||||
pub(super) fn insert_position(&self, pos: Point<f64, Logical>) -> InsertPosition {
|
||||
if self.columns.is_empty() {
|
||||
return InsertPosition::NewColumn(0);
|
||||
}
|
||||
@@ -1295,20 +1291,28 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
self.view_offset.offset(offset);
|
||||
}
|
||||
|
||||
if self.interactive_resize.is_none() && !self.view_offset.is_gesture() {
|
||||
// We might need to move the view to ensure the resized window is still visible.
|
||||
// Upon unfullscreening, restore the view offset.
|
||||
//
|
||||
// In tabbed display mode, there can be multiple tiles in a fullscreen column. They
|
||||
// will unfullscreen one by one, and the column width will shrink only when the
|
||||
// last tile unfullscreens. This is when we want to restore the view offset,
|
||||
// otherwise it will immediately reset back by the animate_view_offset below.
|
||||
let is_fullscreen = self.columns[col_idx].tiles.iter().any(Tile::is_fullscreen);
|
||||
let unfullscreen_offset = if was_fullscreen && !is_fullscreen {
|
||||
// Take the value unconditionally, even if the view is currently frozen by
|
||||
// a view gesture. It shouldn't linger around because it's only valid for this
|
||||
// particular unfullscreen.
|
||||
self.view_offset_before_fullscreen.take()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Upon unfullscreening, restore the view offset.
|
||||
//
|
||||
// In tabbed display mode, there can be multiple tiles in a fullscreen column. They
|
||||
// will unfullscreen one by one, and the column width will shrink only when the
|
||||
// last tile unfullscreens. This is when we want to restore the view offset,
|
||||
// otherwise it will immediately reset back by the animate_view_offset below.
|
||||
let is_fullscreen = self.columns[col_idx].tiles.iter().any(Tile::is_fullscreen);
|
||||
if was_fullscreen && !is_fullscreen {
|
||||
if let Some(prev_offset) = self.view_offset_before_fullscreen.take() {
|
||||
self.animate_view_offset(col_idx, prev_offset);
|
||||
}
|
||||
// We might need to move the view to ensure the resized window is still visible. But
|
||||
// only do it when the view isn't frozen by an interactive resize or a view gesture.
|
||||
if self.interactive_resize.is_none() && !self.view_offset.is_gesture() {
|
||||
// Restore the view offset upon unfullscreening if needed.
|
||||
if let Some(prev_offset) = unfullscreen_offset {
|
||||
self.animate_view_offset(col_idx, prev_offset);
|
||||
}
|
||||
|
||||
// Synchronize the horizontal view movement with the resize so that it looks nice.
|
||||
@@ -1612,7 +1616,6 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
|
||||
// Preserve the camera position when moving to the left.
|
||||
let view_offset_delta = -self.column_x(self.active_column_idx) + current_col_x;
|
||||
self.view_offset.cancel_gesture();
|
||||
self.view_offset.offset(view_offset_delta);
|
||||
|
||||
// The column we just moved is offset by the difference between its new and old position.
|
||||
@@ -2152,6 +2155,64 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
self.center_column();
|
||||
}
|
||||
|
||||
pub fn center_visible_columns(&mut self) {
|
||||
if self.columns.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.is_centering_focused_column() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Consider the end of an ongoing animation because that's what compute to fit does too.
|
||||
let view_x = self.target_view_pos();
|
||||
let working_x = self.working_area.loc.x;
|
||||
let working_w = self.working_area.size.w;
|
||||
|
||||
// Count all columns that are fully visible inside the working area.
|
||||
let mut width_taken = 0.;
|
||||
let mut leftmost_col_x = None;
|
||||
let mut active_col_x = None;
|
||||
|
||||
let gap = self.options.gaps;
|
||||
let col_xs = self.column_xs(self.data.iter().copied());
|
||||
for (idx, col_x) in col_xs.take(self.columns.len()).enumerate() {
|
||||
if col_x < view_x + working_x + gap {
|
||||
// Column goes off-screen to the left.
|
||||
continue;
|
||||
}
|
||||
|
||||
leftmost_col_x.get_or_insert(col_x);
|
||||
|
||||
let width = self.data[idx].width;
|
||||
if view_x + working_x + working_w < col_x + width + gap {
|
||||
// Column goes off-screen to the right. We can stop here.
|
||||
break;
|
||||
}
|
||||
|
||||
if idx == self.active_column_idx {
|
||||
active_col_x = Some(col_x);
|
||||
}
|
||||
|
||||
width_taken += width + gap;
|
||||
}
|
||||
|
||||
if active_col_x.is_none() {
|
||||
// The active column wasn't fully on screen, so we can't meaningfully do anything.
|
||||
return;
|
||||
}
|
||||
|
||||
let col = &mut self.columns[self.active_column_idx];
|
||||
cancel_resize_for_column(&mut self.interactive_resize, col);
|
||||
|
||||
let free_space = working_w - width_taken + gap;
|
||||
let new_view_x = leftmost_col_x.unwrap() - free_space / 2. - working_x;
|
||||
|
||||
self.animate_view_offset(self.active_column_idx, new_view_x - active_col_x.unwrap());
|
||||
// Just in case.
|
||||
self.animate_view_offset_to_column(None, self.active_column_idx, None);
|
||||
}
|
||||
|
||||
pub fn view_pos(&self) -> f64 {
|
||||
self.column_x(self.active_column_idx) + self.view_offset.current()
|
||||
}
|
||||
@@ -2274,8 +2335,11 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_hint_area(&self, insert_hint: &InsertHint) -> Option<Rectangle<f64, Logical>> {
|
||||
let mut hint_area = match insert_hint.position {
|
||||
pub(super) fn insert_hint_area(
|
||||
&self,
|
||||
position: InsertPosition,
|
||||
) -> Option<Rectangle<f64, Logical>> {
|
||||
let mut hint_area = match position {
|
||||
InsertPosition::NewColumn(column_index) => {
|
||||
if column_index == 0 || column_index == self.columns.len() {
|
||||
let size =
|
||||
@@ -2366,19 +2430,6 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
hint_area.loc.x -= self.view_pos();
|
||||
}
|
||||
|
||||
let view_size = self.view_size;
|
||||
|
||||
// Make sure the hint is at least partially visible.
|
||||
if matches!(insert_hint.position, InsertPosition::NewColumn(_)) {
|
||||
hint_area.loc.x = hint_area.loc.x.max(-hint_area.size.w / 2.);
|
||||
hint_area.loc.x = hint_area.loc.x.min(view_size.w - hint_area.size.w / 2.);
|
||||
}
|
||||
|
||||
// Round to physical pixels.
|
||||
hint_area = hint_area
|
||||
.to_physical_precise_round(self.scale)
|
||||
.to_logical(self.scale);
|
||||
|
||||
Some(hint_area)
|
||||
}
|
||||
|
||||
@@ -2402,9 +2453,26 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
}
|
||||
|
||||
pub fn popup_target_rect(&self, id: &W::Id) -> Option<Rectangle<f64, Logical>> {
|
||||
self.columns
|
||||
.iter()
|
||||
.find_map(|col| col.popup_target_rect(id))
|
||||
for col in &self.columns {
|
||||
for (tile, pos) in col.tiles() {
|
||||
if tile.window().id() == id {
|
||||
// In the scrolling layout, we try to position popups horizontally within the
|
||||
// window geometry (so they remain visible even if the window scrolls flush with
|
||||
// the left/right edge of the screen), and vertically wihin the whole parent
|
||||
// working area.
|
||||
let width = tile.window_size().w;
|
||||
let height = self.parent_area.size.h;
|
||||
|
||||
let mut target = Rectangle::from_size(Size::from((width, height)));
|
||||
target.loc.y += self.parent_area.loc.y;
|
||||
target.loc.y -= pos.y;
|
||||
target.loc.y -= tile.window_loc().y;
|
||||
|
||||
return Some(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn toggle_width(&mut self) {
|
||||
@@ -2729,17 +2797,6 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
|
||||
let scale = Scale::from(self.scale);
|
||||
|
||||
// Draw the insert hint.
|
||||
if let Some(insert_hint) = &self.insert_hint {
|
||||
if let Some(area) = self.insert_hint_area(insert_hint) {
|
||||
rv.extend(
|
||||
self.insert_hint_element
|
||||
.render(renderer, area.loc)
|
||||
.map(ScrollingSpaceRenderElement::InsertHint),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw the closing windows on top of the other windows.
|
||||
let view_rect = Rectangle::new(Point::from((self.view_pos(), 0.)), self.view_size);
|
||||
for closing in self.closing_windows.iter().rev() {
|
||||
@@ -2854,6 +2911,7 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
|
||||
let gesture = ViewGesture {
|
||||
current_view_offset: self.view_offset.current(),
|
||||
animation: None,
|
||||
tracker: SwipeTracker::new(),
|
||||
delta_from_tracker: self.view_offset.current(),
|
||||
stationary_view_offset: self.view_offset.stationary(),
|
||||
@@ -2876,6 +2934,7 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
|
||||
let gesture = ViewGesture {
|
||||
current_view_offset: self.view_offset.current(),
|
||||
animation: None,
|
||||
tracker: SwipeTracker::new(),
|
||||
delta_from_tracker: self.view_offset.current(),
|
||||
stationary_view_offset: self.view_offset.stationary(),
|
||||
@@ -2916,14 +2975,14 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
Some(true)
|
||||
}
|
||||
|
||||
pub fn dnd_scroll_gesture_scroll(&mut self, delta: f64) {
|
||||
pub fn dnd_scroll_gesture_scroll(&mut self, delta: f64) -> bool {
|
||||
let ViewOffset::Gesture(gesture) = &mut self.view_offset else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(last_time) = gesture.dnd_last_event_time else {
|
||||
// Not a DnD scroll.
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
|
||||
let config = &self.options.gestures.dnd_edge_view_scroll;
|
||||
@@ -2934,7 +2993,7 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
if delta == 0. {
|
||||
// We're outside the scrolling zone.
|
||||
gesture.dnd_nonzero_start_time = None;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
let nonzero_start = *gesture.dnd_nonzero_start_time.get_or_insert(now);
|
||||
@@ -2943,7 +3002,7 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
// monitors.
|
||||
let delay = Duration::from_millis(u64::from(config.delay_ms));
|
||||
if now.saturating_sub(nonzero_start) < delay {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
let time_delta = now.saturating_sub(last_time).as_secs_f64();
|
||||
@@ -2987,9 +3046,10 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
|
||||
gesture.delta_from_tracker += clamped_offset - view_offset;
|
||||
gesture.current_view_offset = clamped_offset;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn view_offset_gesture_end(&mut self, _cancelled: bool, is_touchpad: Option<bool>) -> bool {
|
||||
pub fn view_offset_gesture_end(&mut self, is_touchpad: Option<bool>) -> bool {
|
||||
let ViewOffset::Gesture(gesture) = &mut self.view_offset else {
|
||||
return false;
|
||||
};
|
||||
@@ -3279,7 +3339,7 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
return;
|
||||
}
|
||||
|
||||
self.view_offset_gesture_end(false, None);
|
||||
self.view_offset_gesture_end(None);
|
||||
}
|
||||
|
||||
pub fn interactive_resize_begin(&mut self, window: W::Id, edges: ResizeEdge) -> bool {
|
||||
@@ -3406,7 +3466,7 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
self.interactive_resize = None;
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self, is_active: bool) {
|
||||
pub fn refresh(&mut self, is_active: bool, is_focused: bool) {
|
||||
for (col_idx, col) in self.columns.iter_mut().enumerate() {
|
||||
let mut col_resize_data = None;
|
||||
if let Some(resize) = &self.interactive_resize {
|
||||
@@ -3451,11 +3511,14 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
win.set_active_in_column(active_in_column);
|
||||
win.set_floating(false);
|
||||
|
||||
let active = is_active
|
||||
&& self.active_column_idx == col_idx
|
||||
let mut active = is_active && self.active_column_idx == col_idx;
|
||||
if self.options.deactivate_unfocused_windows {
|
||||
active &= active_in_column && is_focused;
|
||||
} else {
|
||||
// In tabbed mode, all tabs have activated state to reduce unnecessary
|
||||
// animations when switching tabs.
|
||||
&& (active_in_column || is_tabbed);
|
||||
active &= active_in_column || is_tabbed;
|
||||
}
|
||||
win.set_activated(active);
|
||||
|
||||
win.set_interactive_resize(col_resize_data);
|
||||
@@ -3492,6 +3555,11 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
self.view_size
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn parent_area(&self) -> Rectangle<f64, Logical> {
|
||||
self.parent_area
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn clock(&self) -> &Clock {
|
||||
&self.clock
|
||||
@@ -3513,7 +3581,7 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn verify_invariants(&self, working_area: Rectangle<f64, Logical>) {
|
||||
pub fn verify_invariants(&self) {
|
||||
assert!(self.view_size.w > 0.);
|
||||
assert!(self.view_size.h > 0.);
|
||||
assert!(self.scale > 0.);
|
||||
@@ -3521,7 +3589,7 @@ impl<W: LayoutElement> ScrollingSpace<W> {
|
||||
assert_eq!(self.columns.len(), self.data.len());
|
||||
assert_eq!(
|
||||
self.working_area,
|
||||
compute_working_area(working_area, self.scale, self.options.struts)
|
||||
compute_working_area(self.parent_area, self.scale, self.options.struts)
|
||||
);
|
||||
|
||||
if !self.columns.is_empty() {
|
||||
@@ -3570,7 +3638,10 @@ impl ViewOffset {
|
||||
match self {
|
||||
ViewOffset::Static(offset) => *offset,
|
||||
ViewOffset::Animation(anim) => anim.value(),
|
||||
ViewOffset::Gesture(gesture) => gesture.current_view_offset,
|
||||
ViewOffset::Gesture(gesture) => {
|
||||
gesture.current_view_offset
|
||||
+ gesture.animation.as_ref().map_or(0., |anim| anim.value())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3600,21 +3671,30 @@ impl ViewOffset {
|
||||
matches!(self, Self::Static(_))
|
||||
}
|
||||
|
||||
pub fn is_animation(&self) -> bool {
|
||||
matches!(self, Self::Animation(_))
|
||||
}
|
||||
|
||||
pub fn is_gesture(&self) -> bool {
|
||||
matches!(self, Self::Gesture(_))
|
||||
}
|
||||
|
||||
pub fn is_dnd_scroll(&self) -> bool {
|
||||
matches!(&self, ViewOffset::Gesture(gesture) if gesture.dnd_last_event_time.is_some())
|
||||
}
|
||||
|
||||
pub fn is_animation_ongoing(&self) -> bool {
|
||||
match self {
|
||||
ViewOffset::Static(_) => false,
|
||||
ViewOffset::Animation(_) => true,
|
||||
ViewOffset::Gesture(gesture) => gesture.animation.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn offset(&mut self, delta: f64) {
|
||||
match self {
|
||||
ViewOffset::Static(offset) => *offset += delta,
|
||||
ViewOffset::Animation(anim) => anim.offset(delta),
|
||||
ViewOffset::Gesture(_gesture) => {
|
||||
// Is this needed?
|
||||
error!("cancel gesture before offsetting");
|
||||
ViewOffset::Gesture(gesture) => {
|
||||
gesture.stationary_view_offset += delta;
|
||||
gesture.delta_from_tracker += delta;
|
||||
gesture.current_view_offset += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3630,6 +3710,13 @@ impl ViewOffset {
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewGesture {
|
||||
fn animate_from(&mut self, from: f64, clock: Clock, config: niri_config::Animation) {
|
||||
let current = self.animation.as_ref().map_or(0., Animation::value);
|
||||
self.animation = Some(Animation::new(clock, from + current, 0., 0., config));
|
||||
}
|
||||
}
|
||||
|
||||
impl ColumnData {
|
||||
pub fn new<W: LayoutElement>(column: &Column<W>) -> Self {
|
||||
let mut rv = Self { width: 0. };
|
||||
@@ -3832,8 +3919,9 @@ impl<W: LayoutElement> Column<W> {
|
||||
.enumerate()
|
||||
.map(|(tile_idx, (tile, tile_off))| {
|
||||
let is_active = tile_idx == active_idx;
|
||||
let is_urgent = tile.window().is_urgent();
|
||||
let tile_pos = tile_off + tile.render_offset();
|
||||
TabInfo::from_tile(tile, tile_pos, is_active, &config)
|
||||
TabInfo::from_tile(tile, tile_pos, is_active, is_urgent, &config)
|
||||
});
|
||||
|
||||
// Hide the tab indicator in fullscreen. If you have it configured to overlap the window,
|
||||
@@ -4709,25 +4797,6 @@ impl<W: LayoutElement> Column<W> {
|
||||
self.update_tile_sizes(true);
|
||||
}
|
||||
|
||||
fn popup_target_rect(&self, id: &W::Id) -> Option<Rectangle<f64, Logical>> {
|
||||
for (tile, pos) in self.tiles() {
|
||||
if tile.window().id() == id {
|
||||
// In the scrolling layout, we try to position popups horizontally within the
|
||||
// window geometry (so they remain visible even if the window scrolls flush with
|
||||
// the left/right edge of the screen), and vertically wihin the whole view size.
|
||||
let width = tile.window_size().w;
|
||||
let height = self.view_size.h;
|
||||
|
||||
let mut target = Rectangle::from_size(Size::from((width, height)));
|
||||
target.loc.y -= pos.y;
|
||||
target.loc.y -= tile.window_loc().y;
|
||||
|
||||
return Some(target);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn tiles_origin(&self) -> Point<f64, Logical> {
|
||||
let mut origin = Point::from((0., 0.));
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ use crate::animation::{Animation, Clock};
|
||||
use crate::niri_render_elements;
|
||||
use crate::render_helpers::border::BorderRenderElement;
|
||||
use crate::render_helpers::renderer::NiriRenderer;
|
||||
use crate::utils::{floor_logical_in_physical_max1, round_logical_in_physical};
|
||||
use crate::utils::{
|
||||
floor_logical_in_physical_max1, round_logical_in_physical, round_logical_in_physical_max1,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TabIndicator {
|
||||
@@ -77,12 +79,14 @@ impl TabIndicator {
|
||||
scale: f64,
|
||||
) -> impl Iterator<Item = Rectangle<f64, Logical>> {
|
||||
let round = |logical: f64| round_logical_in_physical(scale, logical);
|
||||
let round_max1 = |logical: f64| round_logical_in_physical_max1(scale, logical);
|
||||
|
||||
let progress = self.open_anim.as_ref().map_or(1., |a| a.value().max(0.));
|
||||
|
||||
let width = round(self.config.width.0);
|
||||
let gap = round(self.config.gap.0);
|
||||
let gaps_between = round(self.config.gaps_between_tabs.0);
|
||||
let width = round_max1(self.config.width.0);
|
||||
let gap = self.config.gap.0;
|
||||
let gap = round_max1(gap.abs()).copysign(gap);
|
||||
let gaps_between = round_max1(self.config.gaps_between_tabs.0);
|
||||
|
||||
let position = self.config.position;
|
||||
let side = match position {
|
||||
@@ -346,13 +350,16 @@ impl TabInfo {
|
||||
tile: &Tile<W>,
|
||||
position: Point<f64, Logical>,
|
||||
is_active: bool,
|
||||
is_urgent: bool,
|
||||
config: &niri_config::TabIndicator,
|
||||
) -> Self {
|
||||
let rules = tile.window().rules();
|
||||
let rule = rules.tab_indicator;
|
||||
|
||||
let gradient_from_rule = || {
|
||||
let (color, gradient) = if is_active {
|
||||
let (color, gradient) = if is_urgent {
|
||||
(rule.urgent_color, rule.urgent_gradient)
|
||||
} else if is_active {
|
||||
(rule.active_color, rule.active_gradient)
|
||||
} else {
|
||||
(rule.inactive_color, rule.inactive_gradient)
|
||||
@@ -362,7 +369,9 @@ impl TabInfo {
|
||||
};
|
||||
|
||||
let gradient_from_config = || {
|
||||
let (color, gradient) = if is_active {
|
||||
let (color, gradient) = if is_urgent {
|
||||
(config.urgent_color, config.urgent_gradient)
|
||||
} else if is_active {
|
||||
(config.active_color, config.active_gradient)
|
||||
} else {
|
||||
(config.inactive_color, config.inactive_gradient)
|
||||
@@ -382,7 +391,9 @@ impl TabInfo {
|
||||
focus_ring_config
|
||||
};
|
||||
|
||||
let (color, gradient) = if is_active {
|
||||
let (color, gradient) = if is_urgent {
|
||||
(config.urgent_color, config.urgent_gradient)
|
||||
} else if is_active {
|
||||
(config.active_color, config.active_gradient)
|
||||
} else {
|
||||
(config.inactive_color, config.inactive_gradient)
|
||||
|
||||
+228
-29
@@ -261,6 +261,10 @@ impl LayoutElement for TestWindow {
|
||||
fn interactive_resize_data(&self) -> Option<InteractiveResizeData> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_urgent(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn arbitrary_bbox() -> impl Strategy<Value = Rectangle<i32, Logical>> {
|
||||
@@ -460,6 +464,7 @@ enum Op {
|
||||
#[proptest(strategy = "proptest::option::of(1..=5usize)")]
|
||||
id: Option<usize>,
|
||||
},
|
||||
CenterVisibleColumns,
|
||||
FocusWorkspaceDown,
|
||||
FocusWorkspaceUp,
|
||||
FocusWorkspace(#[proptest(strategy = "0..=4usize")] usize),
|
||||
@@ -473,9 +478,9 @@ enum Op {
|
||||
#[proptest(strategy = "0..=4usize")]
|
||||
workspace_idx: usize,
|
||||
},
|
||||
MoveColumnToWorkspaceDown,
|
||||
MoveColumnToWorkspaceUp,
|
||||
MoveColumnToWorkspace(#[proptest(strategy = "0..=4usize")] usize),
|
||||
MoveColumnToWorkspaceDown(bool),
|
||||
MoveColumnToWorkspaceUp(bool),
|
||||
MoveColumnToWorkspace(#[proptest(strategy = "0..=4usize")] usize, bool),
|
||||
MoveWorkspaceDown,
|
||||
MoveWorkspaceUp,
|
||||
MoveWorkspaceToIndex {
|
||||
@@ -508,7 +513,13 @@ enum Op {
|
||||
#[proptest(strategy = "proptest::option::of(0..=4usize)")]
|
||||
target_ws_idx: Option<usize>,
|
||||
},
|
||||
MoveColumnToOutput(#[proptest(strategy = "1..=5usize")] usize),
|
||||
MoveColumnToOutput {
|
||||
#[proptest(strategy = "1..=5usize")]
|
||||
output_id: usize,
|
||||
#[proptest(strategy = "proptest::option::of(0..=4usize)")]
|
||||
target_ws_idx: Option<usize>,
|
||||
activate: bool,
|
||||
},
|
||||
SwitchPresetColumnWidth,
|
||||
SwitchPresetWindowWidth {
|
||||
#[proptest(strategy = "proptest::option::of(1..=5usize)")]
|
||||
@@ -576,6 +587,8 @@ enum Op {
|
||||
ViewOffsetGestureBegin {
|
||||
#[proptest(strategy = "1..=5usize")]
|
||||
output_idx: usize,
|
||||
#[proptest(strategy = "proptest::option::of(0..=4usize)")]
|
||||
workspace_idx: Option<usize>,
|
||||
is_touchpad: bool,
|
||||
},
|
||||
ViewOffsetGestureUpdate {
|
||||
@@ -599,9 +612,15 @@ enum Op {
|
||||
is_touchpad: bool,
|
||||
},
|
||||
WorkspaceSwitchGestureEnd {
|
||||
cancelled: bool,
|
||||
is_touchpad: Option<bool>,
|
||||
},
|
||||
OverviewGestureBegin,
|
||||
OverviewGestureUpdate {
|
||||
#[proptest(strategy = "-400f64..400f64")]
|
||||
delta: f64,
|
||||
timestamp: Duration,
|
||||
},
|
||||
OverviewGestureEnd,
|
||||
InteractiveMoveBegin {
|
||||
#[proptest(strategy = "1..=5usize")]
|
||||
window: usize,
|
||||
@@ -657,6 +676,7 @@ enum Op {
|
||||
#[proptest(strategy = "1..=5usize")]
|
||||
window: usize,
|
||||
},
|
||||
ToggleOverview,
|
||||
}
|
||||
|
||||
impl Op {
|
||||
@@ -1049,6 +1069,7 @@ impl Op {
|
||||
let id = id.filter(|id| layout.has_window(id));
|
||||
layout.center_window(id.as_ref());
|
||||
}
|
||||
Op::CenterVisibleColumns => layout.center_visible_columns(),
|
||||
Op::FocusWorkspaceDown => layout.switch_workspace_down(),
|
||||
Op::FocusWorkspaceUp => layout.switch_workspace_up(),
|
||||
Op::FocusWorkspace(idx) => layout.switch_workspace(idx),
|
||||
@@ -1065,9 +1086,9 @@ impl Op {
|
||||
let window_id = window_id.filter(|id| layout.has_window(id));
|
||||
layout.move_to_workspace(window_id.as_ref(), workspace_idx, ActivateWindow::Smart);
|
||||
}
|
||||
Op::MoveColumnToWorkspaceDown => layout.move_column_to_workspace_down(),
|
||||
Op::MoveColumnToWorkspaceUp => layout.move_column_to_workspace_up(),
|
||||
Op::MoveColumnToWorkspace(idx) => layout.move_column_to_workspace(idx),
|
||||
Op::MoveColumnToWorkspaceDown(focus) => layout.move_column_to_workspace_down(focus),
|
||||
Op::MoveColumnToWorkspaceUp(focus) => layout.move_column_to_workspace_up(focus),
|
||||
Op::MoveColumnToWorkspace(idx, focus) => layout.move_column_to_workspace(idx, focus),
|
||||
Op::MoveWindowToOutput {
|
||||
window_id,
|
||||
output_id: id,
|
||||
@@ -1088,13 +1109,17 @@ impl Op {
|
||||
ActivateWindow::Smart,
|
||||
);
|
||||
}
|
||||
Op::MoveColumnToOutput(id) => {
|
||||
Op::MoveColumnToOutput {
|
||||
output_id: id,
|
||||
target_ws_idx,
|
||||
activate,
|
||||
} => {
|
||||
let name = format!("output{id}");
|
||||
let Some(output) = layout.outputs().find(|o| o.name() == name).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
layout.move_column_to_output(&output);
|
||||
layout.move_column_to_output(&output, target_ws_idx, activate);
|
||||
}
|
||||
Op::MoveWorkspaceDown => layout.move_workspace_down(),
|
||||
Op::MoveWorkspaceUp => layout.move_workspace_up(),
|
||||
@@ -1345,6 +1370,7 @@ impl Op {
|
||||
}
|
||||
Op::ViewOffsetGestureBegin {
|
||||
output_idx: id,
|
||||
workspace_idx,
|
||||
is_touchpad: normalize,
|
||||
} => {
|
||||
let name = format!("output{id}");
|
||||
@@ -1352,7 +1378,7 @@ impl Op {
|
||||
return;
|
||||
};
|
||||
|
||||
layout.view_offset_gesture_begin(&output, normalize);
|
||||
layout.view_offset_gesture_begin(&output, workspace_idx, normalize);
|
||||
}
|
||||
Op::ViewOffsetGestureUpdate {
|
||||
delta,
|
||||
@@ -1362,8 +1388,7 @@ impl Op {
|
||||
layout.view_offset_gesture_update(delta, timestamp, is_touchpad);
|
||||
}
|
||||
Op::ViewOffsetGestureEnd { is_touchpad } => {
|
||||
// We don't handle cancels in this gesture.
|
||||
layout.view_offset_gesture_end(false, is_touchpad);
|
||||
layout.view_offset_gesture_end(is_touchpad);
|
||||
}
|
||||
Op::WorkspaceSwitchGestureBegin {
|
||||
output_idx: id,
|
||||
@@ -1383,11 +1408,17 @@ impl Op {
|
||||
} => {
|
||||
layout.workspace_switch_gesture_update(delta, timestamp, is_touchpad);
|
||||
}
|
||||
Op::WorkspaceSwitchGestureEnd {
|
||||
cancelled,
|
||||
is_touchpad,
|
||||
} => {
|
||||
layout.workspace_switch_gesture_end(cancelled, is_touchpad);
|
||||
Op::WorkspaceSwitchGestureEnd { is_touchpad } => {
|
||||
layout.workspace_switch_gesture_end(is_touchpad);
|
||||
}
|
||||
Op::OverviewGestureBegin => {
|
||||
layout.overview_gesture_begin();
|
||||
}
|
||||
Op::OverviewGestureUpdate { delta, timestamp } => {
|
||||
layout.overview_gesture_update(delta, timestamp);
|
||||
}
|
||||
Op::OverviewGestureEnd => {
|
||||
layout.overview_gesture_end();
|
||||
}
|
||||
Op::InteractiveMoveBegin {
|
||||
window,
|
||||
@@ -1442,6 +1473,9 @@ impl Op {
|
||||
Op::InteractiveResizeEnd { window } => {
|
||||
layout.interactive_resize_end(&window);
|
||||
}
|
||||
Op::ToggleOverview => {
|
||||
layout.toggle_overview();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1542,10 +1576,10 @@ fn operations_dont_panic() {
|
||||
window_id: None,
|
||||
workspace_idx: 2,
|
||||
},
|
||||
Op::MoveColumnToWorkspaceDown,
|
||||
Op::MoveColumnToWorkspaceUp,
|
||||
Op::MoveColumnToWorkspace(1),
|
||||
Op::MoveColumnToWorkspace(2),
|
||||
Op::MoveColumnToWorkspaceDown(true),
|
||||
Op::MoveColumnToWorkspaceUp(true),
|
||||
Op::MoveColumnToWorkspace(1, true),
|
||||
Op::MoveColumnToWorkspace(2, true),
|
||||
Op::MoveWindowDown,
|
||||
Op::MoveWindowDownOrToWorkspaceDown,
|
||||
Op::MoveWindowUp,
|
||||
@@ -1717,11 +1751,11 @@ fn operations_from_starting_state_dont_panic() {
|
||||
window_id: None,
|
||||
workspace_idx: 3,
|
||||
},
|
||||
Op::MoveColumnToWorkspaceDown,
|
||||
Op::MoveColumnToWorkspaceUp,
|
||||
Op::MoveColumnToWorkspace(1),
|
||||
Op::MoveColumnToWorkspace(2),
|
||||
Op::MoveColumnToWorkspace(3),
|
||||
Op::MoveColumnToWorkspaceDown(true),
|
||||
Op::MoveColumnToWorkspaceUp(true),
|
||||
Op::MoveColumnToWorkspace(1, true),
|
||||
Op::MoveColumnToWorkspace(2, true),
|
||||
Op::MoveColumnToWorkspace(3, true),
|
||||
Op::MoveWindowDown,
|
||||
Op::MoveWindowDownOrToWorkspaceDown,
|
||||
Op::MoveWindowUp,
|
||||
@@ -2040,8 +2074,8 @@ fn workspace_transfer_during_switch_gets_cleaned_up() {
|
||||
},
|
||||
Op::RemoveOutput(1),
|
||||
Op::AddOutput(2),
|
||||
Op::MoveColumnToWorkspaceDown,
|
||||
Op::MoveColumnToWorkspaceDown,
|
||||
Op::MoveColumnToWorkspaceDown(true),
|
||||
Op::MoveColumnToWorkspaceDown(true),
|
||||
Op::AddOutput(1),
|
||||
];
|
||||
|
||||
@@ -2265,6 +2299,7 @@ fn unfullscreen_view_offset_not_reset_on_gesture() {
|
||||
Op::FullscreenWindow(1),
|
||||
Op::ViewOffsetGestureBegin {
|
||||
output_idx: 1,
|
||||
workspace_idx: None,
|
||||
is_touchpad: true,
|
||||
},
|
||||
Op::ViewOffsetGestureEnd {
|
||||
@@ -3335,6 +3370,170 @@ fn interactive_resize_on_pending_unfullscreen_column() {
|
||||
check_ops(&ops);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_column_to_workspace_unfocused_with_multiple_monitors() {
|
||||
let ops = [
|
||||
Op::AddOutput(1),
|
||||
Op::SetWorkspaceName {
|
||||
new_ws_name: 101,
|
||||
ws_name: None,
|
||||
},
|
||||
Op::AddWindow {
|
||||
params: TestWindowParams::new(1),
|
||||
},
|
||||
Op::FocusWorkspaceDown,
|
||||
Op::SetWorkspaceName {
|
||||
new_ws_name: 102,
|
||||
ws_name: None,
|
||||
},
|
||||
Op::AddWindow {
|
||||
params: TestWindowParams::new(2),
|
||||
},
|
||||
Op::AddOutput(2),
|
||||
Op::FocusOutput(2),
|
||||
Op::SetWorkspaceName {
|
||||
new_ws_name: 201,
|
||||
ws_name: None,
|
||||
},
|
||||
Op::AddWindow {
|
||||
params: TestWindowParams::new(3),
|
||||
},
|
||||
Op::AddWindow {
|
||||
params: TestWindowParams::new(4),
|
||||
},
|
||||
Op::MoveColumnToOutput {
|
||||
output_id: 1,
|
||||
target_ws_idx: Some(0),
|
||||
activate: false,
|
||||
},
|
||||
Op::FocusOutput(1),
|
||||
];
|
||||
|
||||
let layout = check_ops(&ops);
|
||||
|
||||
assert_eq!(layout.active_workspace().unwrap().name().unwrap(), "ws102");
|
||||
|
||||
for (mon, win) in layout.windows() {
|
||||
let mon = mon.unwrap();
|
||||
let ws = mon
|
||||
.workspaces
|
||||
.iter()
|
||||
.find(|w| w.has_window(win.id()))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
ws.name().unwrap(),
|
||||
match win.id() {
|
||||
1 | 4 => "ws101",
|
||||
2 => "ws102",
|
||||
3 => "ws201",
|
||||
_ => unreachable!(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_move_unfullscreen_to_floating_stops_dnd_scroll() {
|
||||
let ops = [
|
||||
Op::AddOutput(3),
|
||||
Op::AddWindow {
|
||||
params: TestWindowParams {
|
||||
is_floating: true,
|
||||
..TestWindowParams::new(4)
|
||||
},
|
||||
},
|
||||
// This moves the window to tiling.
|
||||
Op::SetFullscreenWindow {
|
||||
window: 4,
|
||||
is_fullscreen: true,
|
||||
},
|
||||
// This starts a DnD scroll since we're dragging a tiled window.
|
||||
Op::InteractiveMoveBegin {
|
||||
window: 4,
|
||||
output_idx: 3,
|
||||
px: 0.0,
|
||||
py: 0.0,
|
||||
},
|
||||
// This will cause the window to unfullscreen to floating, and should stop the DnD scroll
|
||||
// since we're no longer dragging a tiled window, but rather a floating one.
|
||||
Op::InteractiveMoveUpdate {
|
||||
window: 4,
|
||||
dx: 0.0,
|
||||
dy: 15035.31210741684,
|
||||
output_idx: 3,
|
||||
px: 0.0,
|
||||
py: 0.0,
|
||||
},
|
||||
Op::InteractiveMoveEnd { window: 4 },
|
||||
];
|
||||
|
||||
check_ops(&ops);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfullscreen_view_offset_not_reset_during_dnd_gesture() {
|
||||
let ops = [
|
||||
Op::AddOutput(1),
|
||||
Op::AddWindow {
|
||||
params: TestWindowParams::new(3),
|
||||
},
|
||||
Op::FullscreenWindow(3),
|
||||
Op::Communicate(3),
|
||||
Op::DndUpdate {
|
||||
output_idx: 1,
|
||||
px: 0.0,
|
||||
py: 0.0,
|
||||
},
|
||||
Op::FullscreenWindow(3),
|
||||
Op::Communicate(3),
|
||||
];
|
||||
|
||||
check_ops(&ops);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfullscreen_view_offset_not_reset_during_gesture() {
|
||||
let ops = [
|
||||
Op::AddOutput(1),
|
||||
Op::AddWindow {
|
||||
params: TestWindowParams::new(3),
|
||||
},
|
||||
Op::FullscreenWindow(3),
|
||||
Op::Communicate(3),
|
||||
Op::ViewOffsetGestureBegin {
|
||||
output_idx: 1,
|
||||
workspace_idx: None,
|
||||
is_touchpad: false,
|
||||
},
|
||||
Op::FullscreenWindow(3),
|
||||
Op::Communicate(3),
|
||||
];
|
||||
|
||||
check_ops(&ops);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfullscreen_view_offset_not_reset_during_ongoing_gesture() {
|
||||
let ops = [
|
||||
Op::AddOutput(1),
|
||||
Op::AddWindow {
|
||||
params: TestWindowParams::new(3),
|
||||
},
|
||||
Op::ViewOffsetGestureBegin {
|
||||
output_idx: 1,
|
||||
workspace_idx: None,
|
||||
is_touchpad: false,
|
||||
},
|
||||
Op::FullscreenWindow(3),
|
||||
Op::Communicate(3),
|
||||
Op::FullscreenWindow(3),
|
||||
Op::Communicate(3),
|
||||
];
|
||||
|
||||
check_ops(&ops);
|
||||
}
|
||||
|
||||
fn parent_id_causes_loop(layout: &Layout<TestWindow>, id: usize, mut parent_id: usize) -> bool {
|
||||
if parent_id == id {
|
||||
return true;
|
||||
|
||||
+4
-4
@@ -25,8 +25,8 @@ use crate::render_helpers::shadow::ShadowRenderElement;
|
||||
use crate::render_helpers::snapshot::RenderSnapshot;
|
||||
use crate::render_helpers::solid_color::{SolidColorBuffer, SolidColorRenderElement};
|
||||
use crate::render_helpers::RenderTarget;
|
||||
use crate::utils::round_logical_in_physical;
|
||||
use crate::utils::transaction::Transaction;
|
||||
use crate::utils::{baba_is_float_offset, round_logical_in_physical};
|
||||
|
||||
/// Toplevel window with decorations.
|
||||
#[derive(Debug)]
|
||||
@@ -366,6 +366,7 @@ impl<W: LayoutElement> Tile<W> {
|
||||
self.animated_window_size(),
|
||||
is_active,
|
||||
!draw_border_with_background,
|
||||
self.window.is_urgent(),
|
||||
Rectangle::new(
|
||||
view_rect.loc - Point::from((border_width, border_width)),
|
||||
view_rect.size,
|
||||
@@ -400,6 +401,7 @@ impl<W: LayoutElement> Tile<W> {
|
||||
self.animated_tile_size(),
|
||||
is_active,
|
||||
!draw_focus_ring_with_background,
|
||||
self.window.is_urgent(),
|
||||
view_rect,
|
||||
radius,
|
||||
self.scale,
|
||||
@@ -798,9 +800,7 @@ impl<W: LayoutElement> Tile<W> {
|
||||
return Point::from((0., 0.));
|
||||
}
|
||||
|
||||
let now = self.clock.now().as_secs_f64();
|
||||
let amplitude = self.view_size.h / 96.;
|
||||
let y = amplitude * ((f64::consts::TAU * now / 3.6).sin() - 1.);
|
||||
let y = baba_is_float_offset(self.clock.now(), self.view_size.h);
|
||||
let y = round_logical_in_physical(self.scale, y);
|
||||
Point::from((0., y))
|
||||
}
|
||||
|
||||
+99
-24
@@ -2,7 +2,9 @@ use std::cmp::max;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use niri_config::{CenterFocusedColumn, OutputName, PresetSize, Workspace as WorkspaceConfig};
|
||||
use niri_config::{
|
||||
CenterFocusedColumn, CornerRadius, OutputName, PresetSize, Workspace as WorkspaceConfig,
|
||||
};
|
||||
use niri_ipc::{ColumnDisplay, PositionChange, SizeChange};
|
||||
use smithay::backend::renderer::gles::GlesRenderer;
|
||||
use smithay::desktop::{layer_map_for_output, Window};
|
||||
@@ -15,16 +17,18 @@ use smithay::wayland::shell::xdg::SurfaceCachedState;
|
||||
|
||||
use super::floating::{FloatingSpace, FloatingSpaceRenderElement};
|
||||
use super::scrolling::{
|
||||
Column, ColumnWidth, InsertHint, InsertPosition, ScrollDirection, ScrollingSpace,
|
||||
ScrollingSpaceRenderElement,
|
||||
Column, ColumnWidth, ScrollDirection, ScrollingSpace, ScrollingSpaceRenderElement,
|
||||
};
|
||||
use super::shadow::Shadow;
|
||||
use super::tile::{Tile, TileRenderSnapshot};
|
||||
use super::{
|
||||
ActivateWindow, HitType, InteractiveResizeData, LayoutElement, Options, RemovedTile, SizeFrac,
|
||||
ActivateWindow, HitType, InsertPosition, InteractiveResizeData, LayoutElement, Options,
|
||||
RemovedTile, SizeFrac,
|
||||
};
|
||||
use crate::animation::Clock;
|
||||
use crate::niri_render_elements;
|
||||
use crate::render_helpers::renderer::NiriRenderer;
|
||||
use crate::render_helpers::shadow::ShadowRenderElement;
|
||||
use crate::render_helpers::RenderTarget;
|
||||
use crate::utils::id::IdCounter;
|
||||
use crate::utils::transaction::{Transaction, TransactionBlocker};
|
||||
@@ -80,6 +84,9 @@ pub struct Workspace<W: LayoutElement> {
|
||||
/// zones.
|
||||
working_area: Rectangle<f64, Logical>,
|
||||
|
||||
/// This workspace's shadow in the overview.
|
||||
shadow: Shadow,
|
||||
|
||||
/// Clock for driving animations.
|
||||
pub(super) clock: Clock,
|
||||
|
||||
@@ -228,6 +235,9 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
options.clone(),
|
||||
);
|
||||
|
||||
let shadow_config =
|
||||
compute_workspace_shadow_config(options.overview.workspace_shadow, view_size);
|
||||
|
||||
Self {
|
||||
scrolling,
|
||||
floating,
|
||||
@@ -237,6 +247,7 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
transform: output.current_transform(),
|
||||
view_size,
|
||||
working_area,
|
||||
shadow: Shadow::new(shadow_config),
|
||||
output: Some(output),
|
||||
clock,
|
||||
base_options,
|
||||
@@ -281,6 +292,9 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
options.clone(),
|
||||
);
|
||||
|
||||
let shadow_config =
|
||||
compute_workspace_shadow_config(options.overview.workspace_shadow, view_size);
|
||||
|
||||
Self {
|
||||
scrolling,
|
||||
floating,
|
||||
@@ -291,6 +305,7 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
original_output,
|
||||
view_size,
|
||||
working_area,
|
||||
shadow: Shadow::new(shadow_config),
|
||||
clock,
|
||||
base_options,
|
||||
options,
|
||||
@@ -343,6 +358,14 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
let view_rect = Rectangle::from_size(self.view_size);
|
||||
self.floating
|
||||
.update_render_elements(is_active && self.floating_is_active.get(), view_rect);
|
||||
|
||||
self.shadow.update_render_elements(
|
||||
self.view_size,
|
||||
true,
|
||||
CornerRadius::default(),
|
||||
self.scale.fractional_scale(),
|
||||
1.,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn update_config(&mut self, base_options: Rc<Options>) {
|
||||
@@ -363,6 +386,10 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
options.clone(),
|
||||
);
|
||||
|
||||
let shadow_config =
|
||||
compute_workspace_shadow_config(options.overview.workspace_shadow, self.view_size);
|
||||
self.shadow.update_config(shadow_config);
|
||||
|
||||
self.base_options = base_options;
|
||||
self.options = options;
|
||||
}
|
||||
@@ -370,6 +397,7 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
pub fn update_shaders(&mut self) {
|
||||
self.scrolling.update_shaders();
|
||||
self.floating.update_shaders();
|
||||
self.shadow.update_shaders();
|
||||
}
|
||||
|
||||
pub fn windows(&self) -> impl Iterator<Item = &W> + '_ {
|
||||
@@ -501,6 +529,10 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
scale.fractional_scale(),
|
||||
self.options.clone(),
|
||||
);
|
||||
|
||||
let shadow_config =
|
||||
compute_workspace_shadow_config(self.options.overview.workspace_shadow, size);
|
||||
self.shadow.update_config(shadow_config);
|
||||
}
|
||||
|
||||
if scale_transform_changed {
|
||||
@@ -1068,6 +1100,13 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn center_visible_columns(&mut self) {
|
||||
if self.floating_is_active.get() {
|
||||
return;
|
||||
}
|
||||
self.scrolling.center_visible_columns();
|
||||
}
|
||||
|
||||
pub fn toggle_width(&mut self) {
|
||||
if self.floating_is_active.get() {
|
||||
self.floating.toggle_window_width(None);
|
||||
@@ -1409,7 +1448,10 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
renderer: &mut R,
|
||||
target: RenderTarget,
|
||||
focus_ring: bool,
|
||||
) -> impl Iterator<Item = WorkspaceRenderElement<R>> {
|
||||
) -> (
|
||||
impl Iterator<Item = WorkspaceRenderElement<R>>,
|
||||
impl Iterator<Item = WorkspaceRenderElement<R>>,
|
||||
) {
|
||||
let scrolling_focus_ring = focus_ring && !self.floating_is_active();
|
||||
let scrolling = self
|
||||
.scrolling
|
||||
@@ -1424,8 +1466,16 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
.render_elements(renderer, view_rect, target, floating_focus_ring);
|
||||
floating.into_iter().map(WorkspaceRenderElement::from)
|
||||
});
|
||||
let floating = floating.into_iter().flatten();
|
||||
|
||||
floating.into_iter().flatten().chain(scrolling)
|
||||
(floating, scrolling)
|
||||
}
|
||||
|
||||
pub fn render_shadow<R: NiriRenderer>(
|
||||
&self,
|
||||
renderer: &mut R,
|
||||
) -> impl Iterator<Item = ShadowRenderElement> + '_ {
|
||||
self.shadow.render(renderer, Point::from((0., 0.)))
|
||||
}
|
||||
|
||||
pub fn render_above_top_layer(&self) -> bool {
|
||||
@@ -1550,11 +1600,11 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self, is_active: bool) {
|
||||
pub fn refresh(&mut self, is_active: bool, is_focused: bool) {
|
||||
self.scrolling
|
||||
.refresh(is_active && !self.floating_is_active.get());
|
||||
.refresh(is_active && !self.floating_is_active.get(), is_focused);
|
||||
self.floating
|
||||
.refresh(is_active && self.floating_is_active.get());
|
||||
.refresh(is_active && self.floating_is_active.get(), is_focused);
|
||||
}
|
||||
|
||||
pub fn scroll_amount_to_activate(&self, window: &W::Id) -> f64 {
|
||||
@@ -1565,6 +1615,10 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
self.scrolling.scroll_amount_to_activate(window)
|
||||
}
|
||||
|
||||
pub fn is_urgent(&self) -> bool {
|
||||
self.windows().any(|win| win.is_urgent())
|
||||
}
|
||||
|
||||
pub fn activate_window(&mut self, window: &W::Id) -> bool {
|
||||
if self.floating.activate_window(window) {
|
||||
self.floating_is_active = FloatingActive::Yes;
|
||||
@@ -1593,16 +1647,15 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_insert_hint(&mut self, insert_hint: InsertHint) {
|
||||
self.scrolling.set_insert_hint(insert_hint);
|
||||
pub(super) fn scrolling_insert_position(&self, pos: Point<f64, Logical>) -> InsertPosition {
|
||||
self.scrolling.insert_position(pos)
|
||||
}
|
||||
|
||||
pub fn clear_insert_hint(&mut self) {
|
||||
self.scrolling.clear_insert_hint();
|
||||
}
|
||||
|
||||
pub fn get_insert_position(&self, pos: Point<f64, Logical>) -> InsertPosition {
|
||||
self.scrolling.get_insert_position(pos)
|
||||
pub(super) fn insert_hint_area(
|
||||
&self,
|
||||
position: InsertPosition,
|
||||
) -> Option<Rectangle<f64, Logical>> {
|
||||
self.scrolling.insert_hint_area(position)
|
||||
}
|
||||
|
||||
pub fn view_offset_gesture_begin(&mut self, is_touchpad: bool) {
|
||||
@@ -1619,16 +1672,15 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
.view_offset_gesture_update(delta_x, timestamp, is_touchpad)
|
||||
}
|
||||
|
||||
pub fn view_offset_gesture_end(&mut self, cancelled: bool, is_touchpad: Option<bool>) -> bool {
|
||||
self.scrolling
|
||||
.view_offset_gesture_end(cancelled, is_touchpad)
|
||||
pub fn view_offset_gesture_end(&mut self, is_touchpad: Option<bool>) -> bool {
|
||||
self.scrolling.view_offset_gesture_end(is_touchpad)
|
||||
}
|
||||
|
||||
pub fn dnd_scroll_gesture_begin(&mut self) {
|
||||
self.scrolling.dnd_scroll_gesture_begin();
|
||||
}
|
||||
|
||||
pub fn dnd_scroll_gesture_scroll(&mut self, pos: Point<f64, Logical>) {
|
||||
pub fn dnd_scroll_gesture_scroll(&mut self, pos: Point<f64, Logical>, speed: f64) -> bool {
|
||||
let config = &self.options.gestures.dnd_edge_view_scroll;
|
||||
let trigger_width = config.trigger_width.0;
|
||||
|
||||
@@ -1654,8 +1706,9 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
// Normalize to [0, 1].
|
||||
delta / trigger_width
|
||||
};
|
||||
let delta = delta * speed;
|
||||
|
||||
self.scrolling.dnd_scroll_gesture_scroll(delta);
|
||||
self.scrolling.dnd_scroll_gesture_scroll(delta)
|
||||
}
|
||||
|
||||
pub fn dnd_scroll_gesture_end(&mut self) {
|
||||
@@ -1706,6 +1759,10 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
self.floating.logical_to_size_frac(logical_pos)
|
||||
}
|
||||
|
||||
pub fn working_area(&self) -> Rectangle<f64, Logical> {
|
||||
self.working_area
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn scrolling(&self) -> &ScrollingSpace<W> {
|
||||
&self.scrolling
|
||||
@@ -1727,9 +1784,10 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
assert!(scale.is_finite());
|
||||
|
||||
assert_eq!(self.view_size, self.scrolling.view_size());
|
||||
assert_eq!(self.working_area, self.scrolling.parent_area());
|
||||
assert_eq!(&self.clock, self.scrolling.clock());
|
||||
assert!(Rc::ptr_eq(&self.options, self.scrolling.options()));
|
||||
self.scrolling.verify_invariants(self.working_area);
|
||||
self.scrolling.verify_invariants();
|
||||
|
||||
assert_eq!(self.view_size, self.floating.view_size());
|
||||
assert_eq!(self.working_area, self.floating.working_area());
|
||||
@@ -1775,6 +1833,23 @@ impl<W: LayoutElement> Workspace<W> {
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_working_area(output: &Output) -> Rectangle<f64, Logical> {
|
||||
pub(super) fn compute_working_area(output: &Output) -> Rectangle<f64, Logical> {
|
||||
layer_map_for_output(output).non_exclusive_zone().to_f64()
|
||||
}
|
||||
|
||||
fn compute_workspace_shadow_config(
|
||||
config: niri_config::WorkspaceShadow,
|
||||
view_size: Size<f64, Logical>,
|
||||
) -> niri_config::Shadow {
|
||||
// Gaps between workspaces are a multiple of the view height, so shadow settings should also be
|
||||
// normalized to the view height to prevent them from overlapping on lower resolutions.
|
||||
let norm = view_size.h / 1080.;
|
||||
|
||||
let mut config = niri_config::Shadow::from(config);
|
||||
config.softness.0 *= norm;
|
||||
config.spread.0 *= norm;
|
||||
config.offset.x.0 *= norm;
|
||||
config.offset.y.0 *= norm;
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
+53
-13
@@ -9,24 +9,26 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::{env, mem};
|
||||
|
||||
use calloop::EventLoop;
|
||||
use clap::{CommandFactory, Parser};
|
||||
use clap_complete::Shell;
|
||||
use clap_complete_nushell::Nushell;
|
||||
use directories::ProjectDirs;
|
||||
use niri::cli::{Cli, Sub};
|
||||
use niri::cli::{Cli, CompletionShell, Sub};
|
||||
#[cfg(feature = "dbus")]
|
||||
use niri::dbus;
|
||||
use niri::ipc::client::handle_msg;
|
||||
use niri::niri::State;
|
||||
use niri::utils::spawning::{
|
||||
spawn, store_and_increase_nofile_rlimit, CHILD_ENV, REMOVE_ENV_RUST_BACKTRACE,
|
||||
spawn, store_and_increase_nofile_rlimit, CHILD_DISPLAY, CHILD_ENV, REMOVE_ENV_RUST_BACKTRACE,
|
||||
REMOVE_ENV_RUST_LIB_BACKTRACE,
|
||||
};
|
||||
use niri::utils::watcher::Watcher;
|
||||
use niri::utils::{cause_panic, version, IS_SYSTEMD_SERVICE};
|
||||
use niri::utils::{cause_panic, version, xwayland, IS_SYSTEMD_SERVICE};
|
||||
use niri_config::Config;
|
||||
use niri_ipc::socket::SOCKET_PATH_ENV;
|
||||
use portable_atomic::Ordering;
|
||||
use sd_notify::NotifyState;
|
||||
use smithay::reexports::calloop::EventLoop;
|
||||
use smithay::reexports::wayland_server::Display;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
@@ -108,12 +110,33 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
Sub::Panic => cause_panic(),
|
||||
Sub::Completions { shell } => {
|
||||
clap_complete::generate(shell, &mut Cli::command(), "niri", &mut io::stdout());
|
||||
match shell {
|
||||
CompletionShell::Nushell => {
|
||||
clap_complete::generate(
|
||||
Nushell,
|
||||
&mut Cli::command(),
|
||||
"niri",
|
||||
&mut io::stdout(),
|
||||
);
|
||||
}
|
||||
other => {
|
||||
let generator = Shell::try_from(other).unwrap();
|
||||
clap_complete::generate(
|
||||
generator,
|
||||
&mut Cli::command(),
|
||||
"niri",
|
||||
&mut io::stdout(),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Needs to be done before starting Tracy, so that it applies to Tracy's threads.
|
||||
niri::utils::signals::block_early().unwrap();
|
||||
|
||||
// Avoid starting Tracy for the `niri msg` code path since starting/stopping Tracy is a bit
|
||||
// slow.
|
||||
tracy_client::Client::start();
|
||||
@@ -161,12 +184,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
let mut config_errored = false;
|
||||
let mut config = Config::load(&path)
|
||||
.map_err(|err| {
|
||||
warn!("{err:?}");
|
||||
config_errored = true;
|
||||
})
|
||||
let config_load_result = Config::load(&path);
|
||||
let config_errored = config_load_result.is_err();
|
||||
let mut config = config_load_result
|
||||
.map_err(|err| warn!("{err:?}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
let spawn_at_startup = mem::take(&mut config.spawn_at_startup);
|
||||
@@ -174,8 +195,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
store_and_increase_nofile_rlimit();
|
||||
|
||||
// Create the main event loop.
|
||||
let mut event_loop = EventLoop::<State>::try_new().unwrap();
|
||||
|
||||
// Handle Ctrl+C and other signals.
|
||||
niri::utils::signals::listen(&event_loop.handle());
|
||||
|
||||
// Create the compositor.
|
||||
let mut event_loop = EventLoop::try_new().unwrap();
|
||||
let display = Display::new().unwrap();
|
||||
let mut state = State::new(
|
||||
config,
|
||||
@@ -184,6 +210,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
display,
|
||||
false,
|
||||
true,
|
||||
cli.session,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -202,6 +229,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!("IPC listening on: {}", socket_path.to_string_lossy());
|
||||
}
|
||||
|
||||
// Setup xwayland-satellite integration.
|
||||
xwayland::satellite::setup(&mut state);
|
||||
if let Some(satellite) = &state.niri.satellite {
|
||||
let name = satellite.display_name();
|
||||
*CHILD_DISPLAY.write().unwrap() = Some(name.to_owned());
|
||||
env::set_var("DISPLAY", name);
|
||||
info!("listening on X11 socket: {name}");
|
||||
} else {
|
||||
// Avoid spawning children in the host X11.
|
||||
env::remove_var("DISPLAY");
|
||||
}
|
||||
|
||||
if cli.session {
|
||||
// We're starting as a session. Import our variables.
|
||||
import_environment();
|
||||
@@ -277,6 +316,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn import_environment() {
|
||||
let variables = [
|
||||
"WAYLAND_DISPLAY",
|
||||
"DISPLAY",
|
||||
"XDG_CURRENT_DESKTOP",
|
||||
"XDG_SESSION_TYPE",
|
||||
SOCKET_PATH_ENV,
|
||||
@@ -355,7 +395,7 @@ fn config_path(cli_path: Option<PathBuf>) -> (PathBuf, PathBuf, bool) {
|
||||
let system_path = system_config_path();
|
||||
if let Some(path) = default_config_path() {
|
||||
if path.exists() {
|
||||
return (path.clone(), path, true);
|
||||
return (path.clone(), path, false);
|
||||
}
|
||||
|
||||
if system_path.exists() {
|
||||
|
||||
+847
-213
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,715 @@
|
||||
//! ext-workspace protocol implementation.
|
||||
//!
|
||||
//! This is how we map the protocol concepts to the niri concepts:
|
||||
//!
|
||||
//! - Workspace groups are outputs.
|
||||
//! - Workspace coordinates: X = 0, Y = workspace index. They need to be two-dimensional because 1D
|
||||
//! coordinates are defined to be a plain list without a geometric interpretation, while we do
|
||||
//! order workspaces in a vertical line.
|
||||
//! - Workspace id: name for named workspaces, unset for unnamed. Because ids in this protocol are
|
||||
//! expected to be stable across sessions.
|
||||
//! - Workspace name: name for named workspaces, index for unnamed.
|
||||
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::HashMap;
|
||||
use std::mem;
|
||||
|
||||
use arrayvec::ArrayVec;
|
||||
use ext_workspace_group_handle_v1::ExtWorkspaceGroupHandleV1;
|
||||
use ext_workspace_handle_v1::ExtWorkspaceHandleV1;
|
||||
use ext_workspace_manager_v1::ExtWorkspaceManagerV1;
|
||||
use smithay::output::{Output, WeakOutput};
|
||||
use smithay::reexports::wayland_protocols::ext::workspace::v1::server::{
|
||||
ext_workspace_group_handle_v1, ext_workspace_handle_v1, ext_workspace_manager_v1,
|
||||
};
|
||||
use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
|
||||
use smithay::reexports::wayland_server::{
|
||||
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
|
||||
};
|
||||
use wayland_backend::server::ClientId;
|
||||
|
||||
use crate::layout::monitor::Monitor;
|
||||
use crate::layout::workspace::{Workspace, WorkspaceId};
|
||||
use crate::niri::State;
|
||||
use crate::window::Mapped;
|
||||
|
||||
const VERSION: u32 = 1;
|
||||
|
||||
pub trait ExtWorkspaceHandler {
|
||||
fn ext_workspace_manager_state(&mut self) -> &mut ExtWorkspaceManagerState;
|
||||
fn activate_workspace(&mut self, id: WorkspaceId);
|
||||
fn assign_workspace(&mut self, ws_id: WorkspaceId, output: Output);
|
||||
}
|
||||
|
||||
enum Action {
|
||||
Assign(WorkspaceId, WeakOutput),
|
||||
Activate(WorkspaceId),
|
||||
}
|
||||
|
||||
impl Action {
|
||||
fn order(&self) -> u8 {
|
||||
// First assign everything (move across outputs), then activate.
|
||||
match self {
|
||||
Action::Assign(_, _) => 0,
|
||||
Action::Activate(_) => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExtWorkspaceManagerState {
|
||||
display: DisplayHandle,
|
||||
instances: HashMap<ExtWorkspaceManagerV1, Vec<Action>>,
|
||||
workspace_groups: HashMap<Output, ExtWorkspaceGroupData>,
|
||||
workspaces: HashMap<WorkspaceId, ExtWorkspaceData>,
|
||||
}
|
||||
|
||||
struct ExtWorkspaceGroupData {
|
||||
instances: Vec<ExtWorkspaceGroupHandleV1>,
|
||||
}
|
||||
|
||||
struct ExtWorkspaceData {
|
||||
// id cannot change once set.
|
||||
id: Option<String>,
|
||||
name: String,
|
||||
coordinates: ArrayVec<u32, 2>,
|
||||
state: ext_workspace_handle_v1::State,
|
||||
instances: Vec<ExtWorkspaceHandleV1>,
|
||||
output: Option<Output>,
|
||||
}
|
||||
|
||||
pub struct ExtWorkspaceGlobalData {
|
||||
filter: Box<dyn for<'c> Fn(&'c Client) -> bool + Send + Sync>,
|
||||
}
|
||||
|
||||
pub fn refresh(state: &mut State) {
|
||||
let _span = tracy_client::span!("ext_workspace::refresh");
|
||||
|
||||
let protocol_state = &mut state.niri.ext_workspace_state;
|
||||
|
||||
let mut changed = false;
|
||||
|
||||
// Remove workspaces that no longer exist (sending workspace_leave to workspace groups).
|
||||
let mut seen_workspaces = HashMap::new();
|
||||
for (mon, _, ws) in state.niri.layout.workspaces() {
|
||||
let output = mon.map(|mon| mon.output());
|
||||
seen_workspaces.insert(ws.id(), output);
|
||||
}
|
||||
|
||||
protocol_state.workspaces.retain(|id, workspace| {
|
||||
if seen_workspaces.contains_key(id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
remove_workspace_instances(&protocol_state.workspace_groups, workspace);
|
||||
changed = true;
|
||||
false
|
||||
});
|
||||
|
||||
// Remove workspace groups for outputs that no longer exist.
|
||||
protocol_state.workspace_groups.retain(|output, data| {
|
||||
if state.niri.sorted_outputs.contains(output) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for group in &data.instances {
|
||||
// Send workspace_leave for all workspaces in this group with matching manager.
|
||||
let manager: &ExtWorkspaceManagerV1 = group.data().unwrap();
|
||||
for ws in protocol_state.workspaces.values() {
|
||||
if ws.output.as_ref() == Some(output) {
|
||||
for workspace in &ws.instances {
|
||||
if workspace.data() == Some(manager) {
|
||||
group.workspace_leave(workspace);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.removed();
|
||||
}
|
||||
|
||||
changed = true;
|
||||
false
|
||||
});
|
||||
|
||||
// Update existing workspaces and create new ones.
|
||||
for (mon, ws_idx, ws) in state.niri.layout.workspaces() {
|
||||
changed |= refresh_workspace(protocol_state, mon, ws_idx, ws);
|
||||
}
|
||||
|
||||
// Update workspace groups and create new ones, sending workspace_enter events as needed.
|
||||
for output in &state.niri.sorted_outputs {
|
||||
changed |= refresh_workspace_group(protocol_state, output);
|
||||
}
|
||||
|
||||
if changed {
|
||||
for manager in protocol_state.instances.keys() {
|
||||
manager.done();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_output_bound(state: &mut State, output: &Output, wl_output: &WlOutput) {
|
||||
let Some(client) = wl_output.client() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut sent = false;
|
||||
|
||||
let protocol_state = &mut state.niri.ext_workspace_state;
|
||||
if let Some(data) = protocol_state.workspace_groups.get_mut(output) {
|
||||
for group in &mut data.instances {
|
||||
if group.client().as_ref() != Some(&client) {
|
||||
continue;
|
||||
}
|
||||
|
||||
group.output_enter(wl_output);
|
||||
sent = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !sent {
|
||||
return;
|
||||
}
|
||||
|
||||
for manager in protocol_state.instances.keys() {
|
||||
if manager.client().as_ref() == Some(&client) {
|
||||
manager.done();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_workspace_group(protocol_state: &mut ExtWorkspaceManagerState, output: &Output) -> bool {
|
||||
if protocol_state.workspace_groups.contains_key(output) {
|
||||
// Existing workspace group. Nothing can actually change since our workspace groups are tied
|
||||
// to an output.
|
||||
return false;
|
||||
}
|
||||
|
||||
// New workspace group, start tracking it.
|
||||
let mut data = ExtWorkspaceGroupData {
|
||||
instances: Vec::new(),
|
||||
};
|
||||
|
||||
// Create workspace group handle for each manager instance.
|
||||
for manager in protocol_state.instances.keys() {
|
||||
if let Some(client) = manager.client() {
|
||||
data.add_instance::<State>(&protocol_state.display, &client, manager, output);
|
||||
}
|
||||
}
|
||||
|
||||
// Send workspace_enter for all existing workspaces on this output.
|
||||
for group in &data.instances {
|
||||
let manager: &ExtWorkspaceManagerV1 = group.data().unwrap();
|
||||
for (_, ws) in protocol_state.workspaces.iter() {
|
||||
if ws.output.as_ref() != Some(output) {
|
||||
continue;
|
||||
}
|
||||
for workspace in &ws.instances {
|
||||
if workspace.data() == Some(manager) {
|
||||
group.workspace_enter(workspace);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol_state.workspace_groups.insert(output.clone(), data);
|
||||
true
|
||||
}
|
||||
|
||||
fn send_workspace_enter_leave(
|
||||
workspace_groups: &HashMap<Output, ExtWorkspaceGroupData>,
|
||||
data: &ExtWorkspaceData,
|
||||
enter: bool,
|
||||
) {
|
||||
if let Some(output) = &data.output {
|
||||
if let Some(group_data) = workspace_groups.get(output) {
|
||||
for group in &group_data.instances {
|
||||
let manager: &ExtWorkspaceManagerV1 = group.data().unwrap();
|
||||
for workspace in &data.instances {
|
||||
if workspace.data() == Some(manager) {
|
||||
if enter {
|
||||
group.workspace_enter(workspace);
|
||||
} else {
|
||||
group.workspace_leave(workspace);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_workspace_instances(
|
||||
workspace_groups: &HashMap<Output, ExtWorkspaceGroupData>,
|
||||
data: &ExtWorkspaceData,
|
||||
) {
|
||||
send_workspace_enter_leave(workspace_groups, data, false);
|
||||
|
||||
for workspace in &data.instances {
|
||||
workspace.removed();
|
||||
}
|
||||
}
|
||||
|
||||
fn build_name(ws: &Workspace<Mapped>, ws_idx: usize) -> String {
|
||||
ws.name().cloned().unwrap_or_else(|| {
|
||||
// Add 1 since this is a human-readable name, and our action indexing is 1-based.
|
||||
(ws_idx + 1).to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn refresh_workspace(
|
||||
protocol_state: &mut ExtWorkspaceManagerState,
|
||||
mon: Option<&Monitor<Mapped>>,
|
||||
ws_idx: usize,
|
||||
ws: &Workspace<Mapped>,
|
||||
) -> bool {
|
||||
let mut state = ext_workspace_handle_v1::State::empty();
|
||||
if mon.is_some_and(|mon| mon.active_workspace_idx() == ws_idx) {
|
||||
state |= ext_workspace_handle_v1::State::Active;
|
||||
}
|
||||
if ws.is_urgent() {
|
||||
state |= ext_workspace_handle_v1::State::Urgent;
|
||||
}
|
||||
|
||||
let output = mon.map(|mon| mon.output());
|
||||
|
||||
match protocol_state.workspaces.entry(ws.id()) {
|
||||
Entry::Occupied(entry) => {
|
||||
// Existing workspace, check if anything changed.
|
||||
let data = entry.into_mut();
|
||||
|
||||
let mut id_set = false;
|
||||
let mut recreate = false;
|
||||
let id = ws.name();
|
||||
if data.id.as_ref() != id {
|
||||
if data.id.is_some() {
|
||||
recreate = true;
|
||||
} else {
|
||||
id_set = true;
|
||||
}
|
||||
data.id = id.cloned();
|
||||
}
|
||||
|
||||
let mut coordinates_changed = false;
|
||||
if data.coordinates[1] != ws_idx as u32 {
|
||||
data.coordinates[1] = ws_idx as u32;
|
||||
coordinates_changed = true;
|
||||
}
|
||||
|
||||
let mut state_changed = false;
|
||||
if data.state != state {
|
||||
data.state = state;
|
||||
state_changed = true;
|
||||
}
|
||||
|
||||
// Recreate means name got changed or unset (meaning data.name is back to ws_idx).
|
||||
let check = recreate
|
||||
|| if data.id.is_some() {
|
||||
// True means workspace got named, going from ws_idx to name.
|
||||
id_set
|
||||
} else {
|
||||
// The workspace is unnamed, check if ws_idx changed.
|
||||
coordinates_changed
|
||||
};
|
||||
let mut name_changed = false;
|
||||
if check {
|
||||
let new_name = build_name(ws, ws_idx);
|
||||
// This will likely be true, except if the workspace got named its index.
|
||||
if data.name != new_name {
|
||||
data.name = new_name;
|
||||
name_changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
let mut output_changed = false;
|
||||
if data.output.as_ref() != output {
|
||||
send_workspace_enter_leave(&protocol_state.workspace_groups, data, false);
|
||||
data.output = output.cloned();
|
||||
output_changed = true;
|
||||
}
|
||||
|
||||
if recreate {
|
||||
remove_workspace_instances(&protocol_state.workspace_groups, data);
|
||||
data.instances.clear();
|
||||
|
||||
for manager in protocol_state.instances.keys() {
|
||||
if let Some(client) = manager.client() {
|
||||
data.add_instance::<State>(&protocol_state.display, &client, manager);
|
||||
}
|
||||
}
|
||||
|
||||
send_workspace_enter_leave(&protocol_state.workspace_groups, data, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
if output_changed {
|
||||
// Send workspace_enter to the new output's group. If the group doesn't exist yet
|
||||
// (new groups are created after refreshing workspaces), then workspace_enter() will
|
||||
// be sent when the group is created.
|
||||
send_workspace_enter_leave(&protocol_state.workspace_groups, data, true);
|
||||
}
|
||||
|
||||
let something_changed = id_set || name_changed || coordinates_changed || state_changed;
|
||||
if something_changed {
|
||||
for instance in &data.instances {
|
||||
if id_set {
|
||||
instance.id(data.id.clone().unwrap());
|
||||
}
|
||||
if name_changed {
|
||||
instance.name(data.name.clone());
|
||||
}
|
||||
if coordinates_changed {
|
||||
instance.coordinates(
|
||||
data.coordinates
|
||||
.iter()
|
||||
.flat_map(|x| x.to_ne_bytes())
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
if state_changed {
|
||||
instance.state(data.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output_changed || something_changed
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
// New workspace, start tracking it.
|
||||
let mut data = ExtWorkspaceData {
|
||||
id: ws.name().cloned(),
|
||||
name: build_name(ws, ws_idx),
|
||||
coordinates: ArrayVec::from([0, ws_idx as u32]),
|
||||
state,
|
||||
instances: Vec::new(),
|
||||
output: output.cloned(),
|
||||
};
|
||||
|
||||
for manager in protocol_state.instances.keys() {
|
||||
if let Some(client) = manager.client() {
|
||||
data.add_instance::<State>(&protocol_state.display, &client, manager);
|
||||
}
|
||||
}
|
||||
|
||||
send_workspace_enter_leave(&protocol_state.workspace_groups, &data, true);
|
||||
entry.insert(data);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtWorkspaceGroupData {
|
||||
fn add_instance<D>(
|
||||
&mut self,
|
||||
handle: &DisplayHandle,
|
||||
client: &Client,
|
||||
manager: &ExtWorkspaceManagerV1,
|
||||
output: &Output,
|
||||
) -> &ExtWorkspaceGroupHandleV1
|
||||
where
|
||||
D: Dispatch<ExtWorkspaceGroupHandleV1, ExtWorkspaceManagerV1>,
|
||||
D: 'static,
|
||||
{
|
||||
let group = client
|
||||
.create_resource::<ExtWorkspaceGroupHandleV1, _, D>(
|
||||
handle,
|
||||
manager.version(),
|
||||
manager.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
manager.workspace_group(&group);
|
||||
|
||||
group.capabilities(ext_workspace_group_handle_v1::GroupCapabilities::empty());
|
||||
|
||||
for wl_output in output.client_outputs(client) {
|
||||
group.output_enter(&wl_output);
|
||||
}
|
||||
|
||||
self.instances.push(group);
|
||||
self.instances.last().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtWorkspaceData {
|
||||
fn add_instance<D>(
|
||||
&mut self,
|
||||
handle: &DisplayHandle,
|
||||
client: &Client,
|
||||
manager: &ExtWorkspaceManagerV1,
|
||||
) -> &ExtWorkspaceHandleV1
|
||||
where
|
||||
D: Dispatch<ExtWorkspaceHandleV1, ExtWorkspaceManagerV1>,
|
||||
D: 'static,
|
||||
{
|
||||
let workspace = client
|
||||
.create_resource::<ExtWorkspaceHandleV1, _, D>(
|
||||
handle,
|
||||
manager.version(),
|
||||
manager.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
manager.workspace(&workspace);
|
||||
|
||||
if let Some(id) = self.id.clone() {
|
||||
workspace.id(id);
|
||||
}
|
||||
|
||||
workspace.name(self.name.clone());
|
||||
workspace.coordinates(
|
||||
self.coordinates
|
||||
.iter()
|
||||
.flat_map(|x| x.to_ne_bytes())
|
||||
.collect(),
|
||||
);
|
||||
workspace.state(self.state);
|
||||
workspace.capabilities(
|
||||
ext_workspace_handle_v1::WorkspaceCapabilities::Activate
|
||||
| ext_workspace_handle_v1::WorkspaceCapabilities::Assign,
|
||||
);
|
||||
|
||||
self.instances.push(workspace);
|
||||
self.instances.last().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtWorkspaceManagerState {
|
||||
pub fn new<D, F>(display: &DisplayHandle, filter: F) -> Self
|
||||
where
|
||||
D: GlobalDispatch<ExtWorkspaceManagerV1, ExtWorkspaceGlobalData>,
|
||||
D: Dispatch<ExtWorkspaceManagerV1, ()>,
|
||||
D: 'static,
|
||||
F: for<'c> Fn(&'c Client) -> bool + Send + Sync + 'static,
|
||||
{
|
||||
let global_data = ExtWorkspaceGlobalData {
|
||||
filter: Box::new(filter),
|
||||
};
|
||||
display.create_global::<D, ExtWorkspaceManagerV1, _>(VERSION, global_data);
|
||||
Self {
|
||||
display: display.clone(),
|
||||
instances: HashMap::new(),
|
||||
workspace_groups: HashMap::new(),
|
||||
workspaces: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> GlobalDispatch<ExtWorkspaceManagerV1, ExtWorkspaceGlobalData, D>
|
||||
for ExtWorkspaceManagerState
|
||||
where
|
||||
D: GlobalDispatch<ExtWorkspaceManagerV1, ExtWorkspaceGlobalData>,
|
||||
D: Dispatch<ExtWorkspaceManagerV1, ()>,
|
||||
D: Dispatch<ExtWorkspaceHandleV1, ExtWorkspaceManagerV1>,
|
||||
D: ExtWorkspaceHandler,
|
||||
{
|
||||
fn bind(
|
||||
state: &mut D,
|
||||
handle: &DisplayHandle,
|
||||
client: &Client,
|
||||
resource: New<ExtWorkspaceManagerV1>,
|
||||
_global_data: &ExtWorkspaceGlobalData,
|
||||
data_init: &mut DataInit<'_, D>,
|
||||
) {
|
||||
let manager = data_init.init(resource, ());
|
||||
|
||||
let state = state.ext_workspace_manager_state();
|
||||
|
||||
// Send existing workspaces to the new client.
|
||||
let mut new_workspaces: HashMap<_, Vec<_>> = HashMap::new();
|
||||
for data in state.workspaces.values_mut() {
|
||||
let output = data.output.clone();
|
||||
let workspace = data.add_instance::<State>(handle, client, &manager);
|
||||
|
||||
if let Some(output) = output {
|
||||
new_workspaces.entry(output).or_default().push(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
// Create workspace groups for all outputs.
|
||||
for (output, group_data) in &mut state.workspace_groups {
|
||||
let group = group_data.add_instance::<State>(handle, client, &manager, output);
|
||||
|
||||
for workspace in new_workspaces.get(output).into_iter().flatten() {
|
||||
group.workspace_enter(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
manager.done();
|
||||
state.instances.insert(manager, Vec::new());
|
||||
}
|
||||
|
||||
fn can_view(client: Client, global_data: &ExtWorkspaceGlobalData) -> bool {
|
||||
(global_data.filter)(&client)
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Dispatch<ExtWorkspaceManagerV1, (), D> for ExtWorkspaceManagerState
|
||||
where
|
||||
D: Dispatch<ExtWorkspaceManagerV1, ()>,
|
||||
D: ExtWorkspaceHandler,
|
||||
{
|
||||
fn request(
|
||||
state: &mut D,
|
||||
_client: &Client,
|
||||
resource: &ExtWorkspaceManagerV1,
|
||||
request: <ExtWorkspaceManagerV1 as Resource>::Request,
|
||||
_data: &(),
|
||||
_dhandle: &DisplayHandle,
|
||||
_data_init: &mut DataInit<'_, D>,
|
||||
) {
|
||||
match request {
|
||||
ext_workspace_manager_v1::Request::Commit => {
|
||||
let protocol_state = state.ext_workspace_manager_state();
|
||||
let actions = protocol_state.instances.get_mut(resource).unwrap();
|
||||
let mut actions = mem::take(actions);
|
||||
|
||||
actions.sort_by_key(Action::order);
|
||||
|
||||
for action in actions {
|
||||
match action {
|
||||
Action::Assign(ws_id, output) => {
|
||||
if let Some(output) = output.upgrade() {
|
||||
state.assign_workspace(ws_id, output);
|
||||
}
|
||||
}
|
||||
Action::Activate(id) => state.activate_workspace(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
ext_workspace_manager_v1::Request::Stop => {
|
||||
resource.finished();
|
||||
|
||||
let state = state.ext_workspace_manager_state();
|
||||
state.instances.retain(|x, _| x != resource);
|
||||
|
||||
for data in state.workspace_groups.values_mut() {
|
||||
data.instances
|
||||
.retain(|instance| instance.data() != Some(resource));
|
||||
}
|
||||
|
||||
for data in state.workspaces.values_mut() {
|
||||
data.instances
|
||||
.retain(|instance| instance.data() != Some(resource));
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn destroyed(state: &mut D, _client: ClientId, resource: &ExtWorkspaceManagerV1, _data: &()) {
|
||||
let state = state.ext_workspace_manager_state();
|
||||
state.instances.retain(|x, _| x != resource);
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Dispatch<ExtWorkspaceHandleV1, ExtWorkspaceManagerV1, D> for ExtWorkspaceManagerState
|
||||
where
|
||||
D: Dispatch<ExtWorkspaceHandleV1, ExtWorkspaceManagerV1>,
|
||||
D: ExtWorkspaceHandler,
|
||||
{
|
||||
fn request(
|
||||
state: &mut D,
|
||||
_client: &Client,
|
||||
resource: &ExtWorkspaceHandleV1,
|
||||
request: <ExtWorkspaceHandleV1 as Resource>::Request,
|
||||
data: &ExtWorkspaceManagerV1,
|
||||
_dhandle: &DisplayHandle,
|
||||
_data_init: &mut DataInit<'_, D>,
|
||||
) {
|
||||
let protocol_state = state.ext_workspace_manager_state();
|
||||
|
||||
let Some((workspace, _)) = protocol_state
|
||||
.workspaces
|
||||
.iter()
|
||||
.find(|(_, data)| data.instances.contains(resource))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let workspace = *workspace;
|
||||
|
||||
match request {
|
||||
ext_workspace_handle_v1::Request::Activate => {
|
||||
let actions = protocol_state.instances.get_mut(data).unwrap();
|
||||
actions.push(Action::Activate(workspace));
|
||||
}
|
||||
ext_workspace_handle_v1::Request::Deactivate => (),
|
||||
ext_workspace_handle_v1::Request::Assign { workspace_group } => {
|
||||
if let Some(output) = protocol_state
|
||||
.workspace_groups
|
||||
.iter()
|
||||
.find(|(_, data)| data.instances.contains(&workspace_group))
|
||||
.map(|(output, _)| output.clone())
|
||||
{
|
||||
let actions = protocol_state.instances.get_mut(data).unwrap();
|
||||
actions.push(Action::Assign(workspace, output.downgrade()));
|
||||
}
|
||||
}
|
||||
ext_workspace_handle_v1::Request::Remove => (),
|
||||
ext_workspace_handle_v1::Request::Destroy => (),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn destroyed(
|
||||
state: &mut D,
|
||||
_client: ClientId,
|
||||
resource: &ExtWorkspaceHandleV1,
|
||||
_data: &ExtWorkspaceManagerV1,
|
||||
) {
|
||||
let state = state.ext_workspace_manager_state();
|
||||
for data in state.workspaces.values_mut() {
|
||||
data.instances.retain(|instance| instance != resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Dispatch<ExtWorkspaceGroupHandleV1, ExtWorkspaceManagerV1, D> for ExtWorkspaceManagerState
|
||||
where
|
||||
D: Dispatch<ExtWorkspaceGroupHandleV1, ExtWorkspaceManagerV1>,
|
||||
D: ExtWorkspaceHandler,
|
||||
{
|
||||
fn request(
|
||||
_state: &mut D,
|
||||
_client: &Client,
|
||||
_resource: &ExtWorkspaceGroupHandleV1,
|
||||
request: <ExtWorkspaceGroupHandleV1 as Resource>::Request,
|
||||
_data: &ExtWorkspaceManagerV1,
|
||||
_dhandle: &DisplayHandle,
|
||||
_data_init: &mut DataInit<'_, D>,
|
||||
) {
|
||||
match request {
|
||||
ext_workspace_group_handle_v1::Request::CreateWorkspace { .. } => (),
|
||||
ext_workspace_group_handle_v1::Request::Destroy => (),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn destroyed(
|
||||
state: &mut D,
|
||||
_client: ClientId,
|
||||
resource: &ExtWorkspaceGroupHandleV1,
|
||||
_data: &ExtWorkspaceManagerV1,
|
||||
) {
|
||||
let state = state.ext_workspace_manager_state();
|
||||
for data in state.workspace_groups.values_mut() {
|
||||
data.instances.retain(|instance| instance != resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! delegate_ext_workspace {
|
||||
($(@<$( $lt:tt $( : $clt:tt $(+ $dlt:tt )* )? ),+>)? $ty: ty) => {
|
||||
smithay::reexports::wayland_server::delegate_global_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [
|
||||
smithay::reexports::wayland_protocols::ext::workspace::v1::server::ext_workspace_manager_v1::ExtWorkspaceManagerV1: $crate::protocols::ext_workspace::ExtWorkspaceGlobalData
|
||||
] => $crate::protocols::ext_workspace::ExtWorkspaceManagerState);
|
||||
smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [
|
||||
smithay::reexports::wayland_protocols::ext::workspace::v1::server::ext_workspace_manager_v1::ExtWorkspaceManagerV1: ()
|
||||
] => $crate::protocols::ext_workspace::ExtWorkspaceManagerState);
|
||||
smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [
|
||||
smithay::reexports::wayland_protocols::ext::workspace::v1::server::ext_workspace_handle_v1::ExtWorkspaceHandleV1: smithay::reexports::wayland_protocols::ext::workspace::v1::server::ext_workspace_manager_v1::ExtWorkspaceManagerV1
|
||||
] => $crate::protocols::ext_workspace::ExtWorkspaceManagerState);
|
||||
smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [
|
||||
smithay::reexports::wayland_protocols::ext::workspace::v1::server::ext_workspace_group_handle_v1::ExtWorkspaceGroupHandleV1: smithay::reexports::wayland_protocols::ext::workspace::v1::server::ext_workspace_manager_v1::ExtWorkspaceManagerV1
|
||||
] => $crate::protocols::ext_workspace::ExtWorkspaceManagerState);
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod ext_workspace;
|
||||
pub mod foreign_toplevel;
|
||||
pub mod gamma_control;
|
||||
pub mod mutter_x11_interop;
|
||||
|
||||
+552
-100
@@ -1,9 +1,10 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::io::{self, Cursor};
|
||||
use std::iter::zip;
|
||||
use std::mem;
|
||||
use std::os::fd::{AsFd, AsRawFd, BorrowedFd};
|
||||
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, IntoRawFd, OwnedFd, RawFd};
|
||||
use std::ptr::NonNull;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -28,6 +29,7 @@ use pipewire::spa::utils::{
|
||||
};
|
||||
use pipewire::spa::{self};
|
||||
use pipewire::stream::{Stream, StreamFlags, StreamListener, StreamState};
|
||||
use pipewire::sys::{pw_buffer, pw_stream_queue_buffer};
|
||||
use smithay::backend::allocator::dmabuf::{AsDmabuf, Dmabuf};
|
||||
use smithay::backend::allocator::format::FormatSet;
|
||||
use smithay::backend::allocator::gbm::{GbmBuffer, GbmBufferFlags, GbmDevice};
|
||||
@@ -36,9 +38,11 @@ use smithay::backend::drm::DrmDeviceFd;
|
||||
use smithay::backend::renderer::damage::OutputDamageTracker;
|
||||
use smithay::backend::renderer::element::RenderElement;
|
||||
use smithay::backend::renderer::gles::GlesRenderer;
|
||||
use smithay::backend::renderer::sync::SyncPoint;
|
||||
use smithay::output::{Output, OutputModeSource};
|
||||
use smithay::reexports::calloop::generic::Generic;
|
||||
use smithay::reexports::calloop::{Interest, LoopHandle, Mode, PostAction};
|
||||
use smithay::reexports::drm::control::{syncobj, Device as _};
|
||||
use smithay::reexports::gbm::Modifier;
|
||||
use smithay::utils::{Physical, Scale, Size, Transform};
|
||||
use zbus::object_server::SignalEmitter;
|
||||
@@ -51,6 +55,47 @@ use crate::utils::get_monotonic_time;
|
||||
// Give a 0.1 ms allowance for presentation time errors.
|
||||
const CAST_DELAY_ALLOWANCE: Duration = Duration::from_micros(100);
|
||||
|
||||
// Added in PipeWire 1.2.0.
|
||||
#[allow(non_upper_case_globals)]
|
||||
const SPA_META_SyncTimeline: spa_meta_type = 9;
|
||||
#[allow(non_upper_case_globals)]
|
||||
const SPA_PARAM_BUFFERS_metaType: spa_param_buffers = 7;
|
||||
#[allow(non_upper_case_globals)]
|
||||
const SPA_DATA_SyncObj: spa_data_type = 5;
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
struct spa_meta_sync_timeline {
|
||||
pub flags: u32,
|
||||
pub padding: u32,
|
||||
pub acquire_point: u64,
|
||||
pub release_point: u64,
|
||||
}
|
||||
|
||||
/// A map of syncobj fd => handle for proper Drop.
|
||||
struct SyncobjMap {
|
||||
gbm: GbmDevice<DrmDeviceFd>,
|
||||
map: HashMap<RawFd, syncobj::Handle>,
|
||||
}
|
||||
|
||||
impl Drop for SyncobjMap {
|
||||
fn drop(&mut self) {
|
||||
if !self.map.is_empty() {
|
||||
debug!("dropping syncobjs on an abruptly stopped cast");
|
||||
for (fd, syncobj) in self.map.drain() {
|
||||
unsafe {
|
||||
if let Err(err) = self.gbm.destroy_syncobj(syncobj) {
|
||||
warn!("error destroying syncobj: {err:?}");
|
||||
}
|
||||
|
||||
drop(OwnedFd::from_raw_fd(fd));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PipeWire {
|
||||
_context: Context,
|
||||
pub core: Core,
|
||||
@@ -80,9 +125,15 @@ pub struct Cast {
|
||||
pub last_frame_time: Duration,
|
||||
min_time_between_frames: Rc<Cell<Duration>>,
|
||||
dmabufs: Rc<RefCell<HashMap<i64, Dmabuf>>>,
|
||||
syncobjs: Rc<RefCell<SyncobjMap>>,
|
||||
// Buffers we dequeued from PipeWire that are waiting for their release sync point to be
|
||||
// signalled before we can use them.
|
||||
dequeued_buffers: Rc<RefCell<Vec<NonNull<pw_buffer>>>>,
|
||||
gbm: GbmDevice<DrmDeviceFd>,
|
||||
scheduled_redraw: Option<RegistrationToken>,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
pub enum CastState {
|
||||
ResizePending {
|
||||
@@ -218,6 +269,12 @@ impl PipeWire {
|
||||
let is_active = Rc::new(Cell::new(false));
|
||||
let min_time_between_frames = Rc::new(Cell::new(Duration::ZERO));
|
||||
let dmabufs = Rc::new(RefCell::new(HashMap::new()));
|
||||
let syncobjs = SyncobjMap {
|
||||
gbm: gbm.clone(),
|
||||
map: HashMap::new(),
|
||||
};
|
||||
let syncobjs = Rc::new(RefCell::new(syncobjs));
|
||||
let dequeued_buffers = Rc::new(RefCell::new(Vec::new()));
|
||||
let refresh = Rc::new(Cell::new(refresh));
|
||||
|
||||
let pending_size = Size::from((size.w as u32, size.h as u32));
|
||||
@@ -492,37 +549,20 @@ impl PipeWire {
|
||||
}
|
||||
};
|
||||
|
||||
// const BPP: u32 = 4;
|
||||
// let stride = format.size().width * BPP;
|
||||
// let size = stride * format.size().height;
|
||||
let o1 = make_buffers_params(plane_count, true);
|
||||
// Fallback without SyncTimeline.
|
||||
let o2 = make_buffers_params(plane_count, false);
|
||||
|
||||
let o1 = pod::object!(
|
||||
SpaTypes::ObjectParamBuffers,
|
||||
ParamType::Buffers,
|
||||
let o3 = pod::object!(
|
||||
SpaTypes::ObjectParamMeta,
|
||||
ParamType::Meta,
|
||||
Property::new(
|
||||
SPA_PARAM_BUFFERS_buffers,
|
||||
pod::Value::Choice(ChoiceValue::Int(Choice(
|
||||
ChoiceFlags::empty(),
|
||||
ChoiceEnum::Range {
|
||||
default: 16,
|
||||
min: 2,
|
||||
max: 16
|
||||
}
|
||||
))),
|
||||
SPA_PARAM_META_type,
|
||||
pod::Value::Id(spa::utils::Id(SPA_META_SyncTimeline))
|
||||
),
|
||||
Property::new(SPA_PARAM_BUFFERS_blocks, pod::Value::Int(plane_count)),
|
||||
// Property::new(SPA_PARAM_BUFFERS_size, pod::Value::Int(size as i32)),
|
||||
// Property::new(SPA_PARAM_BUFFERS_stride, pod::Value::Int(stride as i32)),
|
||||
// Property::new(SPA_PARAM_BUFFERS_align, pod::Value::Int(16)),
|
||||
Property::new(
|
||||
SPA_PARAM_BUFFERS_dataType,
|
||||
pod::Value::Choice(ChoiceValue::Int(Choice(
|
||||
ChoiceFlags::empty(),
|
||||
ChoiceEnum::Flags {
|
||||
default: 1 << DataType::DmaBuf.as_raw(),
|
||||
flags: vec![1 << DataType::DmaBuf.as_raw()],
|
||||
},
|
||||
))),
|
||||
SPA_PARAM_META_size,
|
||||
pod::Value::Int(size_of::<spa_meta_sync_timeline>() as i32)
|
||||
),
|
||||
);
|
||||
|
||||
@@ -538,10 +578,14 @@ impl PipeWire {
|
||||
// pod::Value::Int(size_of::<spa_meta_header>() as i32)
|
||||
// ),
|
||||
// );
|
||||
|
||||
let mut b1 = vec![];
|
||||
// let mut b2 = vec![];
|
||||
let mut b2 = vec![];
|
||||
let mut b3 = vec![];
|
||||
let mut params = [
|
||||
make_pod(&mut b1, o1), // make_pod(&mut b2, o2)
|
||||
make_pod(&mut b1, o1),
|
||||
make_pod(&mut b2, o2),
|
||||
make_pod(&mut b3, o3),
|
||||
];
|
||||
|
||||
if let Err(err) = stream.update_params(&mut params) {
|
||||
@@ -551,7 +595,9 @@ impl PipeWire {
|
||||
}
|
||||
})
|
||||
.add_buffer({
|
||||
let gbm = gbm.clone();
|
||||
let dmabufs = dmabufs.clone();
|
||||
let syncobjs = syncobjs.clone();
|
||||
let stop_cast = stop_cast.clone();
|
||||
let state = state.clone();
|
||||
move |stream, (), buffer| {
|
||||
@@ -591,14 +637,28 @@ impl PipeWire {
|
||||
}
|
||||
};
|
||||
|
||||
let plane_count = dmabuf.num_planes();
|
||||
assert_eq!((*spa_buffer).n_datas as usize, plane_count);
|
||||
let have_sync_timeline = !spa_buffer_find_meta_data(
|
||||
spa_buffer,
|
||||
SPA_META_SyncTimeline,
|
||||
mem::size_of::<spa_meta_sync_timeline>(),
|
||||
)
|
||||
.is_null();
|
||||
|
||||
let mut expected_n_datas = dmabuf.num_planes();
|
||||
if have_sync_timeline {
|
||||
expected_n_datas += 2;
|
||||
}
|
||||
assert_eq!((*spa_buffer).n_datas as usize, expected_n_datas);
|
||||
|
||||
for (i, fd) in dmabuf.handles().enumerate() {
|
||||
let spa_data = (*spa_buffer).datas.add(i);
|
||||
assert!((*spa_data).type_ & (1 << DataType::DmaBuf.as_raw()) > 0);
|
||||
|
||||
(*spa_data).type_ = DataType::DmaBuf.as_raw();
|
||||
// With DMA-BUFs, consumers should ignore the maxsize field, and
|
||||
// producers are allowed to set it to 0.
|
||||
//
|
||||
// https://docs.pipewire.org/page_dma_buf.html
|
||||
(*spa_data).maxsize = 1;
|
||||
(*spa_data).fd = fd.as_raw_fd() as i64;
|
||||
(*spa_data).flags = SPA_DATA_FLAG_READWRITE;
|
||||
@@ -606,6 +666,12 @@ impl PipeWire {
|
||||
|
||||
let fd = (*(*spa_buffer).datas).fd;
|
||||
assert!(dmabufs.borrow_mut().insert(fd, dmabuf).is_none());
|
||||
|
||||
let syncobjs = &mut *syncobjs.borrow_mut();
|
||||
if let Err(err) = maybe_create_syncobj(&gbm, spa_buffer, &mut syncobjs.map)
|
||||
{
|
||||
warn!("error filling syncobj buffer data: {err:?}");
|
||||
};
|
||||
}
|
||||
|
||||
// During size re-negotiation, the stream sometimes just keeps running, in
|
||||
@@ -617,6 +683,9 @@ impl PipeWire {
|
||||
})
|
||||
.remove_buffer({
|
||||
let dmabufs = dmabufs.clone();
|
||||
let syncobjs = syncobjs.clone();
|
||||
let dequeued_buffers = dequeued_buffers.clone();
|
||||
let gbm = gbm.clone();
|
||||
move |_stream, (), buffer| {
|
||||
trace!("pw stream: remove_buffer");
|
||||
|
||||
@@ -626,7 +695,29 @@ impl PipeWire {
|
||||
assert!((*spa_buffer).n_datas > 0);
|
||||
|
||||
let fd = (*spa_data).fd;
|
||||
dmabufs.borrow_mut().remove(&fd);
|
||||
if let Some(dmabuf) = dmabufs.borrow_mut().remove(&fd) {
|
||||
let have_sync_timeline = !spa_buffer_find_meta_data(
|
||||
spa_buffer,
|
||||
SPA_META_SyncTimeline,
|
||||
mem::size_of::<spa_meta_sync_timeline>(),
|
||||
)
|
||||
.is_null();
|
||||
|
||||
let mut expected_n_datas = dmabuf.num_planes();
|
||||
if have_sync_timeline {
|
||||
expected_n_datas += 2;
|
||||
}
|
||||
assert_eq!((*spa_buffer).n_datas as usize, expected_n_datas);
|
||||
|
||||
let syncobjs = &mut *syncobjs.borrow_mut();
|
||||
maybe_remove_syncobj(&gbm, spa_buffer, &mut syncobjs.map);
|
||||
|
||||
dequeued_buffers
|
||||
.borrow_mut()
|
||||
.retain(|buf: &NonNull<_>| buf.as_ptr() != buffer);
|
||||
} else {
|
||||
error!("missing dmabuf in remove_buffer()");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -662,6 +753,9 @@ impl PipeWire {
|
||||
last_frame_time: Duration::ZERO,
|
||||
min_time_between_frames,
|
||||
dmabufs,
|
||||
syncobjs,
|
||||
dequeued_buffers,
|
||||
gbm,
|
||||
scheduled_redraw: None,
|
||||
};
|
||||
Ok(cast)
|
||||
@@ -815,6 +909,33 @@ impl Cast {
|
||||
}
|
||||
}
|
||||
|
||||
fn dequeue_available_buffer(&mut self) -> Option<NonNull<pw_buffer>> {
|
||||
let mut syncobjs = self.syncobjs.borrow_mut();
|
||||
let syncobjs = &mut syncobjs.map;
|
||||
|
||||
unsafe {
|
||||
// Check if any already-dequeued buffers are ready.
|
||||
let mut dequeued_buffers = self.dequeued_buffers.borrow_mut();
|
||||
for (i, buffer) in dequeued_buffers.iter().enumerate() {
|
||||
if can_reuse_pw_buffer(&self.gbm, *buffer, syncobjs) {
|
||||
debug!("buffer is now ready, yielding");
|
||||
return Some(dequeued_buffers.remove(i));
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(buffer) = NonNull::new(self.stream.dequeue_raw_buffer()) {
|
||||
if can_reuse_pw_buffer(&self.gbm, buffer, syncobjs) {
|
||||
return Some(buffer);
|
||||
}
|
||||
|
||||
debug!("buffer isn't ready yet, storing");
|
||||
dequeued_buffers.push(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn dequeue_buffer_and_render(
|
||||
&mut self,
|
||||
renderer: &mut GlesRenderer,
|
||||
@@ -823,7 +944,8 @@ impl Cast {
|
||||
scale: Scale<f64>,
|
||||
wait_for_sync: bool,
|
||||
) -> bool {
|
||||
let CastState::Ready { damage_tracker, .. } = &mut *self.state.borrow_mut() else {
|
||||
let mut state = self.state.borrow_mut();
|
||||
let CastState::Ready { damage_tracker, .. } = &mut *state else {
|
||||
error!("cast must be in Ready state to render");
|
||||
return false;
|
||||
};
|
||||
@@ -843,50 +965,75 @@ impl Cast {
|
||||
trace!("no damage, skipping frame");
|
||||
return false;
|
||||
}
|
||||
drop(state);
|
||||
|
||||
let Some(mut buffer) = self.stream.dequeue_buffer() else {
|
||||
warn!("no available buffer in pw stream, skipping frame");
|
||||
return false;
|
||||
};
|
||||
unsafe {
|
||||
let Some(pw_buffer) = self.dequeue_available_buffer() else {
|
||||
warn!("no available buffer in pw stream, skipping frame");
|
||||
return false;
|
||||
};
|
||||
let pw_buffer = pw_buffer.as_ptr();
|
||||
|
||||
let fd = buffer.datas_mut()[0].as_raw().fd;
|
||||
let dmabuf = &self.dmabufs.borrow()[&fd];
|
||||
let spa_buffer = (*pw_buffer).buffer;
|
||||
let fd = (*(*spa_buffer).datas).fd;
|
||||
let dmabuf = &self.dmabufs.borrow()[&fd];
|
||||
|
||||
match render_to_dmabuf(
|
||||
renderer,
|
||||
dmabuf.clone(),
|
||||
size,
|
||||
scale,
|
||||
Transform::Normal,
|
||||
elements.iter().rev(),
|
||||
) {
|
||||
Ok(sync_point) => {
|
||||
// FIXME: implement PipeWire explicit sync, and at the very least async wait.
|
||||
if wait_for_sync {
|
||||
let _span = tracy_client::span!("wait for completion");
|
||||
if let Err(err) = sync_point.wait() {
|
||||
warn!("error waiting for pw frame completion: {err:?}");
|
||||
match render_to_dmabuf(
|
||||
renderer,
|
||||
dmabuf.clone(),
|
||||
size,
|
||||
scale,
|
||||
Transform::Normal,
|
||||
elements.iter().rev(),
|
||||
) {
|
||||
Ok(sync_point) => {
|
||||
// FIXME: implement PipeWire explicit sync, and at the very least async wait.
|
||||
if wait_for_sync {
|
||||
let _span = tracy_client::span!("wait for completion");
|
||||
if let Err(err) = sync_point.wait() {
|
||||
warn!("error waiting for pw frame completion: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
let syncobjs = &mut *self.syncobjs.borrow_mut();
|
||||
if let Err(err) =
|
||||
maybe_set_sync_points(&self.gbm, spa_buffer, &mut syncobjs.map, &sync_point)
|
||||
{
|
||||
warn!("error setting sync point: {err:?}");
|
||||
};
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("error rendering to dmabuf: {err:?}");
|
||||
return_unused_buffer(&self.stream, pw_buffer);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("error rendering to dmabuf: {err:?}");
|
||||
return false;
|
||||
|
||||
for (i, (stride, offset)) in zip(dmabuf.strides(), dmabuf.offsets()).enumerate() {
|
||||
let spa_data = (*spa_buffer).datas.add(i);
|
||||
let chunk = (*spa_data).chunk;
|
||||
|
||||
// With DMA-BUFs, consumers should ignore the size field, and producers are allowed
|
||||
// to set it to 0.
|
||||
//
|
||||
// https://docs.pipewire.org/page_dma_buf.html
|
||||
//
|
||||
// However, OBS checks for size != 0 as a workaround for old compositor versions,
|
||||
// so we set it to 1.
|
||||
(*chunk).size = 1;
|
||||
// Clear the corrupted flag we may have set before.
|
||||
(*chunk).flags = SPA_CHUNK_FLAG_NONE as i32;
|
||||
|
||||
(*chunk).stride = stride as i32;
|
||||
(*chunk).offset = offset;
|
||||
|
||||
trace!(
|
||||
"pw buffer: fd = {}, stride = {stride}, offset = {offset}",
|
||||
(*spa_data).fd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (data, (stride, offset)) in
|
||||
zip(buffer.datas_mut(), zip(dmabuf.strides(), dmabuf.offsets()))
|
||||
{
|
||||
let chunk = data.chunk_mut();
|
||||
*chunk.size_mut() = 1;
|
||||
*chunk.stride_mut() = stride as i32;
|
||||
*chunk.offset_mut() = offset;
|
||||
|
||||
trace!(
|
||||
"pw buffer: fd = {}, stride = {stride}, offset = {offset}",
|
||||
data.as_raw().fd
|
||||
);
|
||||
pw_stream_queue_buffer(self.stream.as_raw_ptr(), pw_buffer);
|
||||
}
|
||||
|
||||
true
|
||||
@@ -902,42 +1049,66 @@ impl Cast {
|
||||
*damage_tracker = None;
|
||||
};
|
||||
|
||||
let Some(mut buffer) = self.stream.dequeue_buffer() else {
|
||||
warn!("no available buffer in pw stream, skipping clear");
|
||||
return false;
|
||||
};
|
||||
unsafe {
|
||||
let Some(pw_buffer) = self.dequeue_available_buffer() else {
|
||||
warn!("no available buffer in pw stream, skipping clear");
|
||||
return false;
|
||||
};
|
||||
let pw_buffer = pw_buffer.as_ptr();
|
||||
|
||||
let fd = buffer.datas_mut()[0].as_raw().fd;
|
||||
let dmabuf = &self.dmabufs.borrow()[&fd];
|
||||
let spa_buffer = (*pw_buffer).buffer;
|
||||
let fd = (*(*spa_buffer).datas).fd;
|
||||
let dmabuf = &self.dmabufs.borrow()[&fd];
|
||||
|
||||
match clear_dmabuf(renderer, dmabuf.clone()) {
|
||||
Ok(sync_point) => {
|
||||
// FIXME: implement PipeWire explicit sync, and at the very least async wait.
|
||||
if wait_for_sync {
|
||||
let _span = tracy_client::span!("wait for completion");
|
||||
if let Err(err) = sync_point.wait() {
|
||||
warn!("error waiting for pw frame completion: {err:?}");
|
||||
match clear_dmabuf(renderer, dmabuf.clone()) {
|
||||
Ok(sync_point) => {
|
||||
// FIXME: implement PipeWire explicit sync, and at the very least async wait.
|
||||
if wait_for_sync {
|
||||
let _span = tracy_client::span!("wait for completion");
|
||||
if let Err(err) = sync_point.wait() {
|
||||
warn!("error waiting for pw frame completion: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
let syncobjs = &mut *self.syncobjs.borrow_mut();
|
||||
if let Err(err) =
|
||||
maybe_set_sync_points(&self.gbm, spa_buffer, &mut syncobjs.map, &sync_point)
|
||||
{
|
||||
warn!("error setting sync point: {err:?}");
|
||||
};
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("error clearing dmabuf: {err:?}");
|
||||
return_unused_buffer(&self.stream, pw_buffer);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("error clearing dmabuf: {err:?}");
|
||||
return false;
|
||||
|
||||
for (i, (stride, offset)) in zip(dmabuf.strides(), dmabuf.offsets()).enumerate() {
|
||||
let spa_data = (*spa_buffer).datas.add(i);
|
||||
let chunk = (*spa_data).chunk;
|
||||
|
||||
// With DMA-BUFs, consumers should ignore the size field, and producers are allowed
|
||||
// to set it to 0.
|
||||
//
|
||||
// https://docs.pipewire.org/page_dma_buf.html
|
||||
//
|
||||
// However, OBS checks for size != 0 as a workaround for old compositor versions,
|
||||
// so we set it to 1.
|
||||
(*chunk).size = 1;
|
||||
// Clear the corrupted flag we may have set before.
|
||||
(*chunk).flags = SPA_CHUNK_FLAG_NONE as i32;
|
||||
|
||||
(*chunk).stride = stride as i32;
|
||||
(*chunk).offset = offset;
|
||||
|
||||
trace!(
|
||||
"pw buffer: fd = {}, stride = {stride}, offset = {offset}",
|
||||
(*spa_data).fd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (data, (stride, offset)) in
|
||||
zip(buffer.datas_mut(), zip(dmabuf.strides(), dmabuf.offsets()))
|
||||
{
|
||||
let chunk = data.chunk_mut();
|
||||
*chunk.size_mut() = 1;
|
||||
*chunk.stride_mut() = stride as i32;
|
||||
*chunk.offset_mut() = offset;
|
||||
|
||||
trace!(
|
||||
"pw buffer: fd = {}, stride = {stride}, offset = {offset}",
|
||||
data.as_raw().fd
|
||||
);
|
||||
pw_stream_queue_buffer(self.stream.as_raw_ptr(), pw_buffer);
|
||||
}
|
||||
|
||||
true
|
||||
@@ -1041,6 +1212,52 @@ fn make_video_params(
|
||||
)
|
||||
}
|
||||
|
||||
fn make_buffers_params(mut plane_count: i32, sync_timeline: bool) -> pod::Object {
|
||||
if sync_timeline {
|
||||
// Two extra file descriptors for acquire and release.
|
||||
plane_count += 2;
|
||||
}
|
||||
|
||||
let mut object = pod::object!(
|
||||
SpaTypes::ObjectParamBuffers,
|
||||
ParamType::Buffers,
|
||||
Property::new(
|
||||
SPA_PARAM_BUFFERS_buffers,
|
||||
pod::Value::Choice(ChoiceValue::Int(Choice(
|
||||
ChoiceFlags::empty(),
|
||||
ChoiceEnum::Range {
|
||||
default: 16,
|
||||
min: 2,
|
||||
max: 16
|
||||
}
|
||||
))),
|
||||
),
|
||||
Property::new(SPA_PARAM_BUFFERS_blocks, pod::Value::Int(plane_count)),
|
||||
Property::new(
|
||||
SPA_PARAM_BUFFERS_dataType,
|
||||
pod::Value::Choice(ChoiceValue::Int(Choice(
|
||||
ChoiceFlags::empty(),
|
||||
ChoiceEnum::Flags {
|
||||
default: 1 << DataType::DmaBuf.as_raw(),
|
||||
flags: vec![1 << DataType::DmaBuf.as_raw()],
|
||||
},
|
||||
))),
|
||||
),
|
||||
);
|
||||
|
||||
if sync_timeline {
|
||||
// TODO: do we need to gate this behind runtime check for PW 1.2.0? What happens on older
|
||||
// PW?
|
||||
object.properties.push(Property {
|
||||
key: SPA_PARAM_BUFFERS_metaType,
|
||||
flags: PropertyFlags::MANDATORY,
|
||||
value: pod::Value::Int(1 << SPA_META_SyncTimeline),
|
||||
});
|
||||
}
|
||||
|
||||
object
|
||||
}
|
||||
|
||||
fn make_pod(buffer: &mut Vec<u8>, object: pod::Object) -> &Pod {
|
||||
PodSerializer::serialize(Cursor::new(&mut *buffer), &pod::Value::Object(object)).unwrap();
|
||||
Pod::from_bytes(buffer).unwrap()
|
||||
@@ -1110,3 +1327,238 @@ fn allocate_dmabuf(
|
||||
.context("error exporting GBM buffer object as dmabuf")?;
|
||||
Ok(dmabuf)
|
||||
}
|
||||
|
||||
unsafe fn maybe_create_syncobj(
|
||||
gbm: &GbmDevice<DrmDeviceFd>,
|
||||
spa_buffer: *mut spa_buffer,
|
||||
syncobjs: &mut HashMap<RawFd, syncobj::Handle>,
|
||||
) -> anyhow::Result<()> {
|
||||
unsafe {
|
||||
let sync_timeline: *mut spa_meta_sync_timeline = spa_buffer_find_meta_data(
|
||||
spa_buffer,
|
||||
SPA_META_SyncTimeline,
|
||||
mem::size_of::<spa_meta_sync_timeline>(),
|
||||
)
|
||||
.cast();
|
||||
|
||||
if sync_timeline.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let syncobj = gbm
|
||||
.create_syncobj(false)
|
||||
.context("error creating syncobj")?;
|
||||
let fd = match gbm.syncobj_to_fd(syncobj, false) {
|
||||
Ok(x) => x,
|
||||
Err(err) => {
|
||||
let _ = gbm.destroy_syncobj(syncobj);
|
||||
return Err(err).context("error exporting syncobj to fd");
|
||||
}
|
||||
};
|
||||
|
||||
debug!("filling syncobj fd={fd:?}");
|
||||
|
||||
let n_datas = (*spa_buffer).n_datas as usize;
|
||||
assert!(n_datas >= 2);
|
||||
|
||||
let acquire_data = (*spa_buffer).datas.add(n_datas - 2);
|
||||
(*acquire_data).type_ = SPA_DATA_SyncObj;
|
||||
(*acquire_data).flags = SPA_DATA_FLAG_READABLE;
|
||||
(*acquire_data).fd = i64::from(fd.as_raw_fd());
|
||||
|
||||
let release_data = (*spa_buffer).datas.add(n_datas - 1);
|
||||
(*release_data).type_ = SPA_DATA_SyncObj;
|
||||
(*release_data).flags = SPA_DATA_FLAG_READABLE;
|
||||
(*release_data).fd = i64::from(fd.as_raw_fd());
|
||||
|
||||
syncobjs.insert(fd.into_raw_fd(), syncobj);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn maybe_remove_syncobj(
|
||||
gbm: &GbmDevice<DrmDeviceFd>,
|
||||
spa_buffer: *mut spa_buffer,
|
||||
syncobjs: &mut HashMap<RawFd, syncobj::Handle>,
|
||||
) {
|
||||
unsafe {
|
||||
let sync_timeline: *mut spa_meta_sync_timeline = spa_buffer_find_meta_data(
|
||||
spa_buffer,
|
||||
SPA_META_SyncTimeline,
|
||||
mem::size_of::<spa_meta_sync_timeline>(),
|
||||
)
|
||||
.cast();
|
||||
|
||||
if sync_timeline.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
let n_datas = (*spa_buffer).n_datas as usize;
|
||||
assert!(n_datas >= 2);
|
||||
|
||||
let acquire_data = (*spa_buffer).datas.add(n_datas - 2);
|
||||
let fd = (*acquire_data).fd as RawFd;
|
||||
|
||||
debug!("removing syncobj fd={fd:?}");
|
||||
|
||||
let Some(syncobj) = syncobjs.remove(&fd) else {
|
||||
error!("missing syncobj in remove_buffer()");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = gbm.destroy_syncobj(syncobj) {
|
||||
warn!("error destroying syncobj: {err:?}");
|
||||
}
|
||||
|
||||
drop(OwnedFd::from_raw_fd(fd));
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn maybe_set_sync_points(
|
||||
gbm: &GbmDevice<DrmDeviceFd>,
|
||||
spa_buffer: *mut spa_buffer,
|
||||
syncobjs: &mut HashMap<RawFd, syncobj::Handle>,
|
||||
sync_point: &SyncPoint,
|
||||
) -> anyhow::Result<()> {
|
||||
unsafe {
|
||||
let sync_timeline: *mut spa_meta_sync_timeline = spa_buffer_find_meta_data(
|
||||
spa_buffer,
|
||||
SPA_META_SyncTimeline,
|
||||
mem::size_of::<spa_meta_sync_timeline>(),
|
||||
)
|
||||
.cast();
|
||||
|
||||
if sync_timeline.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// At this point, we must ensure that our syncobj contains a fence, since clients can do a
|
||||
// blocking wait until the fence is available (OBS does this).
|
||||
// TODO
|
||||
|
||||
let n_datas = (*spa_buffer).n_datas as usize;
|
||||
assert!(n_datas >= 2);
|
||||
|
||||
let acquire_data = (*spa_buffer).datas.add(n_datas - 2);
|
||||
let fd = (*acquire_data).fd as RawFd;
|
||||
|
||||
let Some(syncobj) = syncobjs.get(&fd) else {
|
||||
error!("missing syncobj in maybe_set_sync_points()");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(sync_fd) = sync_point.export() else {
|
||||
debug!("have sync_timeline but no sync_fd to export");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let acquire_point = (*sync_timeline).release_point + 1;
|
||||
|
||||
// Import sync_fd into our syncobj at the correct point.
|
||||
let tmp = gbm
|
||||
.create_syncobj(false)
|
||||
.context("error creating temp syncobj")?;
|
||||
let res = drm_import_sync_file(gbm, tmp, sync_fd.as_fd())
|
||||
.context("error importing sync_fd to temp syncobj");
|
||||
let res = if res.is_ok() {
|
||||
gbm.syncobj_timeline_transfer(tmp, *syncobj, 0, acquire_point)
|
||||
.context("error transferring sync point")
|
||||
} else {
|
||||
res
|
||||
};
|
||||
let _ = gbm.destroy_syncobj(tmp);
|
||||
let () = res?;
|
||||
|
||||
(*sync_timeline).acquire_point = acquire_point;
|
||||
(*sync_timeline).release_point = acquire_point + 1;
|
||||
|
||||
debug!("set sync timeline fd={fd:?} to acquire={acquire_point}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Our own version until drm-ffi is fixed:
|
||||
// https://github.com/Smithay/drm-rs/issues/224
|
||||
unsafe fn drm_import_sync_file(
|
||||
gbm: &GbmDevice<DrmDeviceFd>,
|
||||
syncobj: syncobj::Handle,
|
||||
sync_file: BorrowedFd,
|
||||
) -> io::Result<()> {
|
||||
use drm_ffi::drm_sys::*;
|
||||
use rustix::ioctl::{self, ioctl, Opcode, Updater};
|
||||
use smithay::reexports::rustix;
|
||||
|
||||
unsafe fn fd_to_handle(fd: BorrowedFd, data: &mut drm_syncobj_handle) -> io::Result<()> {
|
||||
const OPCODE: Opcode =
|
||||
ioctl::opcode::read_write::<drm_syncobj_handle>(DRM_IOCTL_BASE, 0xC2);
|
||||
Ok(ioctl(fd, Updater::<OPCODE, drm_syncobj_handle>::new(data))?)
|
||||
}
|
||||
|
||||
let mut args = drm_syncobj_handle {
|
||||
handle: u32::from(syncobj),
|
||||
flags: DRM_SYNCOBJ_FD_TO_HANDLE_FLAGS_IMPORT_SYNC_FILE,
|
||||
fd: sync_file.as_raw_fd(),
|
||||
pad: 0,
|
||||
};
|
||||
|
||||
unsafe { fd_to_handle(gbm.as_fd(), &mut args) }
|
||||
}
|
||||
|
||||
unsafe fn can_reuse_pw_buffer(
|
||||
gbm: &GbmDevice<DrmDeviceFd>,
|
||||
pw_buffer: NonNull<pw_buffer>,
|
||||
syncobjs: &mut HashMap<RawFd, syncobj::Handle>,
|
||||
) -> bool {
|
||||
unsafe {
|
||||
let spa_buffer = (*pw_buffer.as_ptr()).buffer;
|
||||
|
||||
let sync_timeline: *mut spa_meta_sync_timeline = spa_buffer_find_meta_data(
|
||||
spa_buffer,
|
||||
SPA_META_SyncTimeline,
|
||||
mem::size_of::<spa_meta_sync_timeline>(),
|
||||
)
|
||||
.cast();
|
||||
|
||||
if sync_timeline.is_null() {
|
||||
// No explicit sync, can always reuse.
|
||||
return true;
|
||||
}
|
||||
|
||||
let n_datas = (*spa_buffer).n_datas as usize;
|
||||
assert!(n_datas >= 2);
|
||||
|
||||
let release_data = (*spa_buffer).datas.add(n_datas - 1);
|
||||
let fd = (*release_data).fd as RawFd;
|
||||
|
||||
let Some(syncobj) = syncobjs.get(&fd) else {
|
||||
error!("missing syncobj in can_reuse_pw_buffer()");
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut points = [0];
|
||||
if let Err(err) = gbm.syncobj_timeline_query(&[*syncobj], &mut points, false) {
|
||||
warn!("error querying timeline signaled point: {err:?}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// For fresh buffers, this will return 0 and the condition will work out to true.
|
||||
let latest_signaled_point = points[0];
|
||||
debug!(
|
||||
"latest signaled point for fd={fd:?} is {latest_signaled_point}; release point is {}",
|
||||
(*sync_timeline).release_point
|
||||
);
|
||||
latest_signaled_point >= (*sync_timeline).release_point
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn return_unused_buffer(stream: &Stream, pw_buffer: *mut pw_buffer) {
|
||||
// pw_stream_return_buffer() requires too new PipeWire (1.4.0). So, mark as
|
||||
// corrupted and queue.
|
||||
let spa_buffer = (*pw_buffer).buffer;
|
||||
let chunk = (*(*spa_buffer).datas).chunk;
|
||||
(*chunk).size = 0;
|
||||
(*chunk).flags = SPA_CHUNK_FLAG_CORRUPTED as i32;
|
||||
pw_stream_queue_buffer(stream.as_raw_ptr(), pw_buffer);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use smithay::backend::renderer::damage::OutputDamageTracker;
|
||||
use smithay::backend::renderer::element::solid::SolidColorRenderElement;
|
||||
use smithay::backend::renderer::element::{Element, Id, Kind};
|
||||
use smithay::backend::renderer::utils::CommitCounter;
|
||||
use smithay::backend::renderer::Color32F;
|
||||
use smithay::utils::Scale;
|
||||
|
||||
use super::renderer::NiriRenderer;
|
||||
use super::solid_color::SolidColorRenderElement;
|
||||
use crate::niri::OutputRenderElements;
|
||||
|
||||
pub fn draw_opaque_regions<R: NiriRenderer>(
|
||||
@@ -35,9 +36,9 @@ pub fn draw_opaque_regions<R: NiriRenderer>(
|
||||
for rect in opaque {
|
||||
let color = SolidColorRenderElement::new(
|
||||
Id::new(),
|
||||
rect,
|
||||
rect.to_f64().to_logical(scale),
|
||||
CommitCounter::default(),
|
||||
[0., 0., 0.2, 0.2],
|
||||
Color32F::from([0., 0., 0.2, 0.2]),
|
||||
Kind::Unspecified,
|
||||
);
|
||||
elements.insert(i - 1, OutputRenderElements::SolidColor(color));
|
||||
@@ -47,9 +48,9 @@ pub fn draw_opaque_regions<R: NiriRenderer>(
|
||||
for rect in semitransparent {
|
||||
let color = SolidColorRenderElement::new(
|
||||
Id::new(),
|
||||
rect,
|
||||
rect.to_f64().to_logical(scale),
|
||||
CommitCounter::default(),
|
||||
[0.3, 0., 0., 0.3],
|
||||
Color32F::from([0.3, 0., 0., 0.3]),
|
||||
Kind::Unspecified,
|
||||
);
|
||||
elements.insert(i - 1, OutputRenderElements::SolidColor(color));
|
||||
@@ -64,6 +65,10 @@ pub fn draw_damage<R: NiriRenderer>(
|
||||
) {
|
||||
let _span = tracy_client::span!("draw_damage");
|
||||
|
||||
let Ok((_, scale, _)) = damage_tracker.mode().try_into() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((Some(damage), _)) = damage_tracker.damage_output(1, elements) else {
|
||||
return;
|
||||
};
|
||||
@@ -71,9 +76,9 @@ pub fn draw_damage<R: NiriRenderer>(
|
||||
for rect in damage {
|
||||
let color = SolidColorRenderElement::new(
|
||||
Id::new(),
|
||||
*rect,
|
||||
rect.to_f64().to_logical(scale),
|
||||
CommitCounter::default(),
|
||||
[0.3, 0., 0., 0.3],
|
||||
Color32F::from([0.3, 0., 0., 0.3]),
|
||||
Kind::Unspecified,
|
||||
);
|
||||
elements.insert(0, OutputRenderElements::SolidColor(color));
|
||||
|
||||
@@ -226,7 +226,7 @@ pub fn render_and_download(
|
||||
|
||||
let buffer_size = size.to_logical(1).to_buffer(1, Transform::Normal);
|
||||
// FIXME: would be nice to avoid binding the second time here (after render_to_texture()), but
|
||||
// borrowing makes this invonvenient.
|
||||
// borrowing makes this inconvenient.
|
||||
let target = renderer
|
||||
.bind(&mut texture)
|
||||
.context("error binding texture")?;
|
||||
|
||||
@@ -13,7 +13,7 @@ use smithay::backend::renderer::utils::{
|
||||
CommitCounter, DamageBag, DamageSet, DamageSnapshot, OpaqueRegions,
|
||||
};
|
||||
use smithay::backend::renderer::{
|
||||
Bind as _, Color32F, Frame as _, Offscreen as _, Renderer, Texture as _,
|
||||
Bind as _, Color32F, ContextId, Frame as _, Offscreen as _, Renderer, Texture as _,
|
||||
};
|
||||
use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Scale, Size, Transform};
|
||||
|
||||
@@ -36,8 +36,8 @@ pub struct OffscreenBuffer {
|
||||
struct Inner {
|
||||
/// The texture with offscreened contents.
|
||||
texture: GlesTexture,
|
||||
/// Id of the renderer that the texture comes from.
|
||||
renderer_id: usize,
|
||||
/// Id of the renderer context that the texture comes from.
|
||||
renderer_context_id: ContextId<GlesTexture>,
|
||||
/// Scale of the texture.
|
||||
scale: Scale<f64>,
|
||||
/// Damage tracker for drawing to the texture.
|
||||
@@ -50,7 +50,7 @@ struct Inner {
|
||||
pub struct OffscreenRenderElement {
|
||||
id: Id,
|
||||
texture: GlesTexture,
|
||||
renderer_id: usize,
|
||||
renderer_context_id: ContextId<GlesTexture>,
|
||||
scale: Scale<f64>,
|
||||
damage: DamageSnapshot<i32, Buffer>,
|
||||
offset: Point<f64, Logical>,
|
||||
@@ -92,7 +92,7 @@ impl OffscreenBuffer {
|
||||
let mut reason = "";
|
||||
if let Some(Inner {
|
||||
texture,
|
||||
renderer_id,
|
||||
renderer_context_id,
|
||||
..
|
||||
}) = inner.as_mut()
|
||||
{
|
||||
@@ -109,7 +109,7 @@ impl OffscreenBuffer {
|
||||
reason = "not unique";
|
||||
|
||||
*inner = None;
|
||||
} else if *renderer_id != renderer.id() {
|
||||
} else if *renderer_context_id != renderer.context_id() {
|
||||
reason = "renderer id changed";
|
||||
|
||||
*inner = None;
|
||||
@@ -134,7 +134,7 @@ impl OffscreenBuffer {
|
||||
|
||||
inner.insert(Inner {
|
||||
texture,
|
||||
renderer_id: renderer.id(),
|
||||
renderer_context_id: renderer.context_id(),
|
||||
scale,
|
||||
damage,
|
||||
outer_damage: DamageBag::default(),
|
||||
@@ -180,7 +180,7 @@ impl OffscreenBuffer {
|
||||
let elem = OffscreenRenderElement {
|
||||
id: self.id.clone(),
|
||||
texture: inner.texture.clone(),
|
||||
renderer_id: inner.renderer_id,
|
||||
renderer_context_id: inner.renderer_context_id.clone(),
|
||||
scale,
|
||||
damage: inner.outer_damage.snapshot(),
|
||||
offset,
|
||||
@@ -305,7 +305,7 @@ impl RenderElement<GlesRenderer> for OffscreenRenderElement {
|
||||
damage: &[Rectangle<i32, Physical>],
|
||||
opaque_regions: &[Rectangle<i32, Physical>],
|
||||
) -> Result<(), GlesError> {
|
||||
if frame.id() != self.renderer_id {
|
||||
if frame.context_id() != self.renderer_context_id {
|
||||
warn!("trying to render texture from different renderer");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ macro_rules! niri_render_elements {
|
||||
// in this line, we cannot condition based on $R like elsewhere, so we condition on duplicate
|
||||
// names instead. Like this: $($name_R<SomeRenderer>)? $($name_no_R)? so only one is chosen.
|
||||
(@impl $name:ident ($($name_no_R:ident)?) ($($name_R:ident<$R:ident>)?) => { $($variant:ident = $type:ty),+ }) => {
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
pub enum $name$(<$R: $crate::render_helpers::renderer::NiriRenderer>)? {
|
||||
$($variant($type)),+
|
||||
|
||||
@@ -70,9 +70,9 @@ unsafe fn compile_program(
|
||||
texture_uniforms: &[&str],
|
||||
// destruction_callback_sender: Sender<CleanupResource>,
|
||||
) -> Result<ShaderProgram, GlesError> {
|
||||
let shader = format!("#version 100\n{}", src);
|
||||
let shader = format!("#version 100\n{src}");
|
||||
let program = unsafe { link_program(gl, include_str!("shaders/texture.vert"), &shader)? };
|
||||
let debug_shader = format!("#version 100\n#define DEBUG_FLAGS\n{}", src);
|
||||
let debug_shader = format!("#version 100\n#define DEBUG_FLAGS\n{src}");
|
||||
let debug_program =
|
||||
unsafe { link_program(gl, include_str!("shaders/texture.vert"), &debug_shader)? };
|
||||
|
||||
@@ -245,6 +245,11 @@ impl ShaderRenderElement {
|
||||
self.area.loc = location;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_alpha(mut self, alpha: f32) -> Self {
|
||||
self.alpha = alpha;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for ShaderRenderElement {
|
||||
|
||||
@@ -175,6 +175,11 @@ impl ShadowRenderElement {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_alpha(mut self, alpha: f32) -> Self {
|
||||
self.inner = self.inner.with_alpha(alpha);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn has_shader(renderer: &mut impl NiriRenderer) -> bool {
|
||||
Shaders::get(renderer)
|
||||
.program(ProgramType::Shadow)
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn render_snapshot_from_surface_tree(
|
||||
}
|
||||
|
||||
let data = data.lock().unwrap();
|
||||
let Some(texture) = data.texture::<GlesRenderer>(renderer.id()) else {
|
||||
let Some(texture) = data.texture(renderer.context_id()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,17 +2,17 @@ use smithay::backend::allocator::Fourcc;
|
||||
use smithay::backend::renderer::element::{Element, Id, Kind, RenderElement, UnderlyingStorage};
|
||||
use smithay::backend::renderer::gles::GlesTexture;
|
||||
use smithay::backend::renderer::utils::{CommitCounter, OpaqueRegions};
|
||||
use smithay::backend::renderer::{Frame as _, ImportMem, Renderer, Texture};
|
||||
use smithay::backend::renderer::{ContextId, Frame as _, ImportMem, Renderer, Texture};
|
||||
use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Scale, Size, Transform};
|
||||
|
||||
use super::memory::MemoryBuffer;
|
||||
|
||||
/// Smithay's texture buffer, but with fractional scale.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextureBuffer<T> {
|
||||
pub struct TextureBuffer<T: Texture> {
|
||||
id: Id,
|
||||
commit_counter: CommitCounter,
|
||||
renderer_id: usize,
|
||||
renderer_context_id: ContextId<T>,
|
||||
texture: T,
|
||||
scale: Scale<f64>,
|
||||
transform: Transform,
|
||||
@@ -21,7 +21,7 @@ pub struct TextureBuffer<T> {
|
||||
|
||||
/// Render element for a [`TextureBuffer`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextureRenderElement<T> {
|
||||
pub struct TextureRenderElement<T: Texture> {
|
||||
buffer: TextureBuffer<T>,
|
||||
location: Point<f64, Logical>,
|
||||
alpha: f32,
|
||||
@@ -30,7 +30,7 @@ pub struct TextureRenderElement<T> {
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
impl<T> TextureBuffer<T> {
|
||||
impl<T: Texture> TextureBuffer<T> {
|
||||
pub fn from_texture<R: Renderer<TextureId = T>>(
|
||||
renderer: &R,
|
||||
texture: T,
|
||||
@@ -41,7 +41,7 @@ impl<T> TextureBuffer<T> {
|
||||
TextureBuffer {
|
||||
id: Id::new(),
|
||||
commit_counter: CommitCounter::default(),
|
||||
renderer_id: renderer.id(),
|
||||
renderer_context_id: renderer.context_id(),
|
||||
texture,
|
||||
scale: scale.into(),
|
||||
transform,
|
||||
@@ -122,7 +122,7 @@ impl TextureBuffer<GlesTexture> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> TextureRenderElement<T> {
|
||||
impl<T: Texture> TextureRenderElement<T> {
|
||||
pub fn from_texture_buffer(
|
||||
buffer: TextureBuffer<T>,
|
||||
location: impl Into<Point<f64, Logical>>,
|
||||
@@ -226,7 +226,7 @@ where
|
||||
damage: &[Rectangle<i32, Physical>],
|
||||
opaque_regions: &[Rectangle<i32, Physical>],
|
||||
) -> Result<(), R::Error> {
|
||||
if frame.id() != self.buffer.renderer_id {
|
||||
if frame.context_id() != self.buffer.renderer_context_id {
|
||||
warn!("trying to render texture from different renderer");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ use smithay::reexports::wayland_protocols::wp::viewporter::client::wp_viewporter
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::client::xdg_surface::{self, XdgSurface};
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::client::xdg_toplevel::{self, XdgToplevel};
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::client::xdg_wm_base::{self, XdgWmBase};
|
||||
use smithay::reexports::wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::{
|
||||
self, ZwlrLayerShellV1,
|
||||
};
|
||||
use smithay::reexports::wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1::{
|
||||
self, ZwlrLayerSurfaceV1,
|
||||
};
|
||||
use wayland_backend::client::Backend;
|
||||
use wayland_client::globals::Global;
|
||||
use wayland_client::protocol::wl_buffer::{self, WlBuffer};
|
||||
@@ -46,10 +52,12 @@ pub struct State {
|
||||
|
||||
pub compositor: Option<WlCompositor>,
|
||||
pub xdg_wm_base: Option<XdgWmBase>,
|
||||
pub layer_shell: Option<ZwlrLayerShellV1>,
|
||||
pub spbm: Option<WpSinglePixelBufferManagerV1>,
|
||||
pub viewporter: Option<WpViewporter>,
|
||||
|
||||
pub windows: Vec<Window>,
|
||||
pub layers: Vec<LayerSurface>,
|
||||
}
|
||||
|
||||
pub struct Window {
|
||||
@@ -67,6 +75,19 @@ pub struct Window {
|
||||
pub configures_looked_at: usize,
|
||||
}
|
||||
|
||||
pub struct LayerSurface {
|
||||
pub qh: QueueHandle<State>,
|
||||
pub spbm: WpSinglePixelBufferManagerV1,
|
||||
|
||||
pub surface: WlSurface,
|
||||
pub layer_surface: ZwlrLayerSurfaceV1,
|
||||
pub viewport: WpViewport,
|
||||
pub configures_received: Vec<(u32, LayerConfigure)>,
|
||||
pub close_requested: bool,
|
||||
|
||||
pub configures_looked_at: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Configure {
|
||||
pub size: (i32, i32),
|
||||
@@ -74,6 +95,30 @@ pub struct Configure {
|
||||
pub states: Vec<xdg_toplevel::State>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LayerConfigure {
|
||||
pub size: (u32, u32),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct LayerMargin {
|
||||
pub top: i32,
|
||||
pub right: i32,
|
||||
pub bottom: i32,
|
||||
pub left: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct LayerConfigureProps {
|
||||
pub size: Option<(u32, u32)>,
|
||||
pub anchor: Option<zwlr_layer_surface_v1::Anchor>,
|
||||
pub exclusive_zone: Option<i32>,
|
||||
pub margin: Option<LayerMargin>,
|
||||
pub kb_interactivity: Option<zwlr_layer_surface_v1::KeyboardInteractivity>,
|
||||
pub layer: Option<zwlr_layer_shell_v1::Layer>,
|
||||
pub exclusive_edge: Option<zwlr_layer_surface_v1::Anchor>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SyncData {
|
||||
pub done: AtomicBool,
|
||||
@@ -103,6 +148,13 @@ impl fmt::Display for Configure {
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for LayerConfigure {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "size: {} × {}", self.size.0, self.size.1)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new(stream: UnixStream) -> Self {
|
||||
let id = ClientId::next();
|
||||
@@ -126,9 +178,11 @@ impl Client {
|
||||
outputs: HashMap::new(),
|
||||
compositor: None,
|
||||
xdg_wm_base: None,
|
||||
layer_shell: None,
|
||||
spbm: None,
|
||||
viewporter: None,
|
||||
windows: Vec::new(),
|
||||
layers: Vec::new(),
|
||||
};
|
||||
|
||||
Self {
|
||||
@@ -162,6 +216,19 @@ impl Client {
|
||||
self.state.window(surface)
|
||||
}
|
||||
|
||||
pub fn create_layer(
|
||||
&mut self,
|
||||
output: Option<&WlOutput>,
|
||||
layer: zwlr_layer_shell_v1::Layer,
|
||||
namespace: &str,
|
||||
) -> &mut LayerSurface {
|
||||
self.state.create_layer(output, layer, namespace.to_owned())
|
||||
}
|
||||
|
||||
pub fn layer(&mut self, surface: &WlSurface) -> &mut LayerSurface {
|
||||
self.state.layer(surface)
|
||||
}
|
||||
|
||||
pub fn output(&mut self, name: &str) -> WlOutput {
|
||||
self.state
|
||||
.outputs
|
||||
@@ -209,6 +276,45 @@ impl State {
|
||||
.find(|w| w.surface == *surface)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn create_layer(
|
||||
&mut self,
|
||||
output: Option<&WlOutput>,
|
||||
layer: zwlr_layer_shell_v1::Layer,
|
||||
namespace: String,
|
||||
) -> &mut LayerSurface {
|
||||
let compositor = self.compositor.as_ref().unwrap();
|
||||
let layer_shell = self.layer_shell.as_ref().unwrap();
|
||||
let viewporter = self.viewporter.as_ref().unwrap();
|
||||
|
||||
let surface = compositor.create_surface(&self.qh, ());
|
||||
let layer_surface =
|
||||
layer_shell.get_layer_surface(&surface, output, layer, namespace, &self.qh, ());
|
||||
let viewport = viewporter.get_viewport(&surface, &self.qh, ());
|
||||
|
||||
let layer_surface = LayerSurface {
|
||||
qh: self.qh.clone(),
|
||||
spbm: self.spbm.clone().unwrap(),
|
||||
|
||||
surface,
|
||||
layer_surface,
|
||||
viewport,
|
||||
configures_received: Vec::new(),
|
||||
close_requested: false,
|
||||
|
||||
configures_looked_at: 0,
|
||||
};
|
||||
|
||||
self.layers.push(layer_surface);
|
||||
self.layers.last_mut().unwrap()
|
||||
}
|
||||
|
||||
pub fn layer(&mut self, surface: &WlSurface) -> &mut LayerSurface {
|
||||
self.layers
|
||||
.iter_mut()
|
||||
.find(|w| w.surface == *surface)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Window {
|
||||
@@ -269,6 +375,83 @@ impl Window {
|
||||
}
|
||||
}
|
||||
|
||||
impl LayerSurface {
|
||||
pub fn commit(&self) {
|
||||
self.surface.commit();
|
||||
}
|
||||
|
||||
pub fn ack_last(&self) {
|
||||
let serial = self.configures_received.last().unwrap().0;
|
||||
self.layer_surface.ack_configure(serial);
|
||||
}
|
||||
|
||||
pub fn ack_last_and_commit(&self) {
|
||||
self.ack_last();
|
||||
self.commit();
|
||||
}
|
||||
|
||||
pub fn set_configure_props(&self, props: LayerConfigureProps) {
|
||||
let LayerConfigureProps {
|
||||
size,
|
||||
anchor,
|
||||
exclusive_zone,
|
||||
margin,
|
||||
kb_interactivity,
|
||||
layer,
|
||||
exclusive_edge,
|
||||
} = props;
|
||||
|
||||
if let Some(x) = size {
|
||||
self.layer_surface.set_size(x.0, x.1);
|
||||
}
|
||||
if let Some(x) = anchor {
|
||||
self.layer_surface.set_anchor(x);
|
||||
}
|
||||
if let Some(x) = exclusive_zone {
|
||||
self.layer_surface.set_exclusive_zone(x);
|
||||
}
|
||||
if let Some(x) = margin {
|
||||
self.layer_surface
|
||||
.set_margin(x.top, x.right, x.bottom, x.left);
|
||||
}
|
||||
if let Some(x) = kb_interactivity {
|
||||
self.layer_surface.set_keyboard_interactivity(x);
|
||||
}
|
||||
if let Some(x) = layer {
|
||||
self.layer_surface.set_layer(x);
|
||||
}
|
||||
if let Some(x) = exclusive_edge {
|
||||
self.layer_surface.set_exclusive_edge(x);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn attach_new_buffer(&self) {
|
||||
let buffer = self.spbm.create_u32_rgba_buffer(0, 0, 0, 0, &self.qh, ());
|
||||
self.surface.attach(Some(&buffer), 0, 0);
|
||||
}
|
||||
|
||||
pub fn set_size(&self, w: u16, h: u16) {
|
||||
self.viewport.set_destination(i32::from(w), i32::from(h));
|
||||
}
|
||||
|
||||
pub fn recent_configures(&mut self) -> impl Iterator<Item = &LayerConfigure> {
|
||||
let start = self.configures_looked_at;
|
||||
self.configures_looked_at = self.configures_received.len();
|
||||
self.configures_received[start..].iter().map(|(_, c)| c)
|
||||
}
|
||||
|
||||
pub fn format_recent_configures(&mut self) -> String {
|
||||
let mut buf = String::new();
|
||||
for configure in self.recent_configures() {
|
||||
if !buf.is_empty() {
|
||||
buf.push('\n');
|
||||
}
|
||||
write!(buf, "{configure}").unwrap();
|
||||
}
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlCallback, Arc<SyncData>> for State {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
@@ -306,6 +489,9 @@ impl Dispatch<WlRegistry, ()> for State {
|
||||
} else if interface == XdgWmBase::interface().name {
|
||||
let version = min(version, XdgWmBase::interface().version);
|
||||
state.xdg_wm_base = Some(registry.bind(name, version, qh, ()));
|
||||
} else if interface == ZwlrLayerShellV1::interface().name {
|
||||
let version = min(version, ZwlrLayerShellV1::interface().version);
|
||||
state.layer_shell = Some(registry.bind(name, version, qh, ()));
|
||||
} else if interface == WpSinglePixelBufferManagerV1::interface().name {
|
||||
let version = min(version, WpSinglePixelBufferManagerV1::interface().version);
|
||||
state.spbm = Some(registry.bind(name, version, qh, ()));
|
||||
@@ -385,6 +571,19 @@ impl Dispatch<XdgWmBase, ()> for State {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZwlrLayerShellV1, ()> for State {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &ZwlrLayerShellV1,
|
||||
_event: <ZwlrLayerShellV1 as wayland_client::Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qhandle: &QueueHandle<Self>,
|
||||
) {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlSurface, ()> for State {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
@@ -470,6 +669,38 @@ impl Dispatch<XdgToplevel, ()> for State {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZwlrLayerSurfaceV1, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
layer_surface: &ZwlrLayerSurfaceV1,
|
||||
event: <ZwlrLayerSurfaceV1 as wayland_client::Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qhandle: &QueueHandle<Self>,
|
||||
) {
|
||||
let layer_surface = state
|
||||
.layers
|
||||
.iter_mut()
|
||||
.find(|w| w.layer_surface == *layer_surface)
|
||||
.unwrap();
|
||||
|
||||
match event {
|
||||
zwlr_layer_surface_v1::Event::Configure {
|
||||
serial,
|
||||
width,
|
||||
height,
|
||||
} => {
|
||||
let configure = LayerConfigure {
|
||||
size: (width, height),
|
||||
};
|
||||
layer_surface.configures_received.push((serial, configure));
|
||||
}
|
||||
zwlr_layer_surface_v1::Event::Closed => layer_surface.close_requested = true,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlBuffer, ()> for State {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
|
||||
@@ -72,7 +72,7 @@ fn windowed_fullscreen() {
|
||||
);
|
||||
|
||||
let mapped = f.niri().layout.windows().next().unwrap().1;
|
||||
// Not commited yet.
|
||||
// Not committed yet.
|
||||
assert!(mapped.is_windowed_fullscreen());
|
||||
|
||||
// Commit in response.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
use insta::assert_snapshot;
|
||||
use smithay::reexports::wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::Layer;
|
||||
use smithay::reexports::wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1::Anchor;
|
||||
|
||||
use super::*;
|
||||
use crate::tests::client::{LayerConfigureProps, LayerMargin};
|
||||
|
||||
#[test]
|
||||
fn simple_top_anchor() {
|
||||
let mut f = Fixture::new();
|
||||
f.add_output(1, (1920, 1080));
|
||||
let id = f.add_client();
|
||||
|
||||
let layer = f.client(id).create_layer(None, Layer::Top, "");
|
||||
let surface = layer.surface.clone();
|
||||
layer.set_configure_props(LayerConfigureProps {
|
||||
anchor: Some(Anchor::Left | Anchor::Right | Anchor::Top),
|
||||
size: Some((0, 50)),
|
||||
..Default::default()
|
||||
});
|
||||
layer.commit();
|
||||
f.roundtrip(id);
|
||||
|
||||
let layer = f.client(id).layer(&surface);
|
||||
layer.attach_new_buffer();
|
||||
layer.set_size(100, 100);
|
||||
layer.ack_last_and_commit();
|
||||
f.double_roundtrip(id);
|
||||
|
||||
let layer = f.client(id).layer(&surface);
|
||||
assert_snapshot!(layer.format_recent_configures(), @"size: 1920 × 50");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margin_overflow() {
|
||||
let mut f = Fixture::new();
|
||||
f.add_output(1, (1920, 1080));
|
||||
let id = f.add_client();
|
||||
|
||||
let layer = f.client(id).create_layer(None, Layer::Top, "");
|
||||
let surface = layer.surface.clone();
|
||||
layer.set_configure_props(LayerConfigureProps {
|
||||
anchor: Some(Anchor::Left | Anchor::Right | Anchor::Top | Anchor::Bottom),
|
||||
margin: Some(LayerMargin {
|
||||
top: i32::MAX,
|
||||
right: i32::MAX,
|
||||
bottom: i32::MAX,
|
||||
left: i32::MAX,
|
||||
}),
|
||||
exclusive_zone: Some(i32::MAX),
|
||||
..Default::default()
|
||||
});
|
||||
layer.commit();
|
||||
f.roundtrip(id);
|
||||
|
||||
let layer = f.client(id).layer(&surface);
|
||||
layer.attach_new_buffer();
|
||||
layer.set_size(100, 100);
|
||||
layer.ack_last_and_commit();
|
||||
f.double_roundtrip(id);
|
||||
|
||||
let layer = f.client(id).layer(&surface);
|
||||
assert_snapshot!(layer.format_recent_configures(), @"size: 0 × 0");
|
||||
|
||||
// Add a second one for good measure.
|
||||
let layer = f.client(id).create_layer(None, Layer::Top, "");
|
||||
let surface = layer.surface.clone();
|
||||
layer.set_configure_props(LayerConfigureProps {
|
||||
anchor: Some(Anchor::Left | Anchor::Right | Anchor::Top | Anchor::Bottom),
|
||||
margin: Some(LayerMargin {
|
||||
top: i32::MAX,
|
||||
right: i32::MAX,
|
||||
bottom: i32::MAX,
|
||||
left: i32::MAX,
|
||||
}),
|
||||
exclusive_zone: Some(i32::MAX),
|
||||
..Default::default()
|
||||
});
|
||||
layer.commit();
|
||||
f.roundtrip(id);
|
||||
|
||||
let layer = f.client(id).layer(&surface);
|
||||
layer.attach_new_buffer();
|
||||
layer.set_size(100, 100);
|
||||
layer.ack_last_and_commit();
|
||||
f.double_roundtrip(id);
|
||||
|
||||
let layer = f.client(id).layer(&surface);
|
||||
assert_snapshot!(layer.format_recent_configures(), @"size: 0 × 0");
|
||||
}
|
||||
@@ -6,4 +6,6 @@ mod server;
|
||||
|
||||
mod floating;
|
||||
mod fullscreen;
|
||||
mod layer_shell;
|
||||
mod transactions;
|
||||
mod window_opening;
|
||||
|
||||
@@ -23,6 +23,7 @@ impl Server {
|
||||
display,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use insta::assert_snapshot;
|
||||
use niri_ipc::SizeChange;
|
||||
use wayland_client::protocol::wl_surface::WlSurface;
|
||||
|
||||
use super::client::ClientId;
|
||||
use super::*;
|
||||
use crate::layout::LayoutElement;
|
||||
use crate::niri::Niri;
|
||||
|
||||
fn format_window_sizes(niri: &Niri) -> String {
|
||||
let mut buf = String::new();
|
||||
for (_out, mapped) in niri.layout.windows() {
|
||||
let size = mapped.size();
|
||||
writeln!(&mut buf, "{} × {}", size.w, size.h).unwrap();
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
fn create_window(f: &mut Fixture, id: ClientId, w: u16, h: u16) -> WlSurface {
|
||||
let window = f.client(id).create_window();
|
||||
let surface = window.surface.clone();
|
||||
window.commit();
|
||||
f.roundtrip(id);
|
||||
|
||||
let window = f.client(id).window(&surface);
|
||||
window.attach_new_buffer();
|
||||
window.set_size(w, h);
|
||||
window.ack_last_and_commit();
|
||||
f.roundtrip(id);
|
||||
|
||||
surface
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_resize_waits_for_both_windows() {
|
||||
let mut f = Fixture::new();
|
||||
f.add_output(1, (1920, 1080));
|
||||
let id = f.add_client();
|
||||
|
||||
let surface1 = create_window(&mut f, id, 100, 100);
|
||||
let surface2 = create_window(&mut f, id, 200, 200);
|
||||
f.double_roundtrip(id);
|
||||
|
||||
let _ = f.client(id).window(&surface1).recent_configures();
|
||||
let _ = f.client(id).window(&surface2).recent_configures();
|
||||
|
||||
// Consume into one column.
|
||||
f.niri().layout.consume_or_expel_window_left(None);
|
||||
f.double_roundtrip(id);
|
||||
|
||||
// Commit for the column consume.
|
||||
let window = f.client(id).window(&surface1);
|
||||
assert_snapshot!(
|
||||
window.format_recent_configures(),
|
||||
@"size: 936 × 516, bounds: 1888 × 1048, states: []"
|
||||
);
|
||||
window.ack_last_and_commit();
|
||||
|
||||
let window = f.client(id).window(&surface2);
|
||||
assert_snapshot!(
|
||||
window.format_recent_configures(),
|
||||
@"size: 936 × 516, bounds: 1888 × 1048, states: [Activated]"
|
||||
);
|
||||
window.ack_last_and_commit();
|
||||
|
||||
f.double_roundtrip(id);
|
||||
|
||||
// This should say 100 × 100 and 200 × 200.
|
||||
assert_snapshot!(format_window_sizes(f.niri()), @r"
|
||||
100 × 100
|
||||
200 × 200
|
||||
");
|
||||
|
||||
// Issue a resize.
|
||||
f.niri()
|
||||
.layout
|
||||
.set_column_width(SizeChange::AdjustFixed(10));
|
||||
f.double_roundtrip(id);
|
||||
|
||||
// Commit window 1 in response to resize.
|
||||
let window = f.client(id).window(&surface1);
|
||||
window.set_size(300, 300);
|
||||
window.ack_last_and_commit();
|
||||
f.double_roundtrip(id);
|
||||
|
||||
// This should still say 100 × 100 as we're waiting in a transaction for the second window.
|
||||
assert_snapshot!(format_window_sizes(f.niri()), @r"
|
||||
100 × 100
|
||||
200 × 200
|
||||
");
|
||||
|
||||
// Commit window 2 in response to resize.
|
||||
let window = f.client(id).window(&surface2);
|
||||
window.set_size(400, 400);
|
||||
window.ack_last_and_commit();
|
||||
f.double_roundtrip(id);
|
||||
|
||||
// This should say 300 × 300 and 400 × 400 as the transaction completed.
|
||||
assert_snapshot!(format_window_sizes(f.niri()), @r"
|
||||
300 × 300
|
||||
400 × 400
|
||||
");
|
||||
}
|
||||
@@ -187,8 +187,7 @@ fn render(
|
||||
if let Some(path) = created_path {
|
||||
text = format!(
|
||||
"Created a default config file at \
|
||||
<span face='monospace' bgcolor='#000000'>{:?}</span>",
|
||||
path
|
||||
<span face='monospace' bgcolor='#000000'>{path:?}</span>",
|
||||
);
|
||||
border_color = (0.5, 1., 0.5);
|
||||
};
|
||||
|
||||
+19
-12
@@ -211,33 +211,33 @@ fn render(
|
||||
]);
|
||||
|
||||
// Prefer move-column-to-workspace-down, but fall back to move-window-to-workspace-down.
|
||||
if binds
|
||||
if let Some(bind) = binds
|
||||
.iter()
|
||||
.any(|bind| bind.action == Action::MoveColumnToWorkspaceDown)
|
||||
.find(|bind| matches!(bind.action, Action::MoveColumnToWorkspaceDown(_)))
|
||||
{
|
||||
actions.push(&Action::MoveColumnToWorkspaceDown);
|
||||
actions.push(&bind.action);
|
||||
} else if binds
|
||||
.iter()
|
||||
.any(|bind| bind.action == Action::MoveWindowToWorkspaceDown)
|
||||
.any(|bind| matches!(bind.action, Action::MoveWindowToWorkspaceDown))
|
||||
{
|
||||
actions.push(&Action::MoveWindowToWorkspaceDown);
|
||||
} else {
|
||||
actions.push(&Action::MoveColumnToWorkspaceDown);
|
||||
actions.push(&Action::MoveColumnToWorkspaceDown(true));
|
||||
}
|
||||
|
||||
// Same for -up.
|
||||
if binds
|
||||
if let Some(bind) = binds
|
||||
.iter()
|
||||
.any(|bind| bind.action == Action::MoveColumnToWorkspaceUp)
|
||||
.find(|bind| matches!(bind.action, Action::MoveColumnToWorkspaceUp(_)))
|
||||
{
|
||||
actions.push(&Action::MoveColumnToWorkspaceUp);
|
||||
actions.push(&bind.action);
|
||||
} else if binds
|
||||
.iter()
|
||||
.any(|bind| bind.action == Action::MoveWindowToWorkspaceUp)
|
||||
.any(|bind| matches!(bind.action, Action::MoveWindowToWorkspaceUp))
|
||||
{
|
||||
actions.push(&Action::MoveWindowToWorkspaceUp);
|
||||
} else {
|
||||
actions.push(&Action::MoveColumnToWorkspaceUp);
|
||||
actions.push(&Action::MoveColumnToWorkspaceUp(true));
|
||||
}
|
||||
|
||||
actions.extend(&[
|
||||
@@ -247,6 +247,7 @@ fn render(
|
||||
&Action::ConsumeOrExpelWindowRight,
|
||||
&Action::ToggleWindowFloating,
|
||||
&Action::SwitchFocusBetweenFloatingAndTiling,
|
||||
&Action::ToggleOverview,
|
||||
]);
|
||||
|
||||
// Screenshot is not as important, can omit if not bound.
|
||||
@@ -284,6 +285,11 @@ fn render(
|
||||
}
|
||||
}
|
||||
|
||||
if config.hotkey_overlay.hide_not_bound {
|
||||
// Only keep actions that have been bound
|
||||
actions.retain(|&action| binds.iter().any(|bind| bind.action == *action))
|
||||
}
|
||||
|
||||
let strings = actions
|
||||
.into_iter()
|
||||
.filter_map(|action| format_bind(binds, mod_key, action))
|
||||
@@ -423,8 +429,8 @@ fn action_name(action: &Action) -> String {
|
||||
Action::MoveColumnRight => String::from("Move Column Right"),
|
||||
Action::FocusWorkspaceDown => String::from("Switch Workspace Down"),
|
||||
Action::FocusWorkspaceUp => String::from("Switch Workspace Up"),
|
||||
Action::MoveColumnToWorkspaceDown => String::from("Move Column to Workspace Down"),
|
||||
Action::MoveColumnToWorkspaceUp => String::from("Move Column to Workspace Up"),
|
||||
Action::MoveColumnToWorkspaceDown(_) => String::from("Move Column to Workspace Down"),
|
||||
Action::MoveColumnToWorkspaceUp(_) => String::from("Move Column to Workspace Up"),
|
||||
Action::MoveWindowToWorkspaceDown => String::from("Move Window to Workspace Down"),
|
||||
Action::MoveWindowToWorkspaceUp => String::from("Move Window to Workspace Up"),
|
||||
Action::SwitchPresetColumnWidth => String::from("Switch Preset Column Widths"),
|
||||
@@ -435,6 +441,7 @@ fn action_name(action: &Action) -> String {
|
||||
Action::SwitchFocusBetweenFloatingAndTiling => {
|
||||
String::from("Switch Focus Between Floating and Tiling")
|
||||
}
|
||||
Action::ToggleOverview => String::from("Open the Overview"),
|
||||
Action::Screenshot(_) => String::from("Take a Screenshot"),
|
||||
Action::Spawn(args) => format!(
|
||||
"Spawn <span face='monospace' bgcolor='#000000'>{}</span>",
|
||||
|
||||
+375
-59
@@ -1,6 +1,7 @@
|
||||
use std::cell::RefCell;
|
||||
use std::cmp::{max, min};
|
||||
use std::collections::HashMap;
|
||||
use std::f64::consts::TAU;
|
||||
use std::iter::zip;
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -11,14 +12,14 @@ use niri_ipc::SizeChange;
|
||||
use pango::{Alignment, FontDescription};
|
||||
use pangocairo::cairo::{self, ImageSurface};
|
||||
use smithay::backend::allocator::Fourcc;
|
||||
use smithay::backend::input::{ButtonState, MouseButton};
|
||||
use smithay::backend::input::TouchSlot;
|
||||
use smithay::backend::renderer::element::utils::{Relocate, RelocateRenderElement};
|
||||
use smithay::backend::renderer::element::Kind;
|
||||
use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture};
|
||||
use smithay::backend::renderer::{ExportMem, Texture as _};
|
||||
use smithay::input::keyboard::{Keysym, ModifiersState};
|
||||
use smithay::output::{Output, WeakOutput};
|
||||
use smithay::utils::{Physical, Point, Rectangle, Scale, Size, Transform};
|
||||
use smithay::utils::{Buffer, Physical, Point, Rectangle, Scale, Size, Transform};
|
||||
|
||||
use crate::animation::{Animation, Clock};
|
||||
use crate::layout::floating::DIRECTIONAL_MOVE_PX;
|
||||
@@ -32,6 +33,7 @@ use crate::utils::to_physical_precise_round;
|
||||
const SELECTION_BORDER: i32 = 2;
|
||||
|
||||
const PADDING: i32 = 8;
|
||||
const RADIUS: i32 = 16;
|
||||
const FONT: &str = "sans 14px";
|
||||
const BORDER: i32 = 4;
|
||||
const TEXT_HIDE_P: &str =
|
||||
@@ -56,7 +58,7 @@ pub enum ScreenshotUi {
|
||||
Open {
|
||||
selection: (Output, Point<i32, Physical>, Point<i32, Physical>),
|
||||
output_data: HashMap<Output, OutputData>,
|
||||
mouse_down: bool,
|
||||
button: Button,
|
||||
show_pointer: bool,
|
||||
open_anim: Animation,
|
||||
clock: Clock,
|
||||
@@ -64,6 +66,25 @@ pub enum ScreenshotUi {
|
||||
},
|
||||
}
|
||||
|
||||
/// State for moving the selection (as opposed to just drawing).
|
||||
pub struct MoveState {
|
||||
// Cursor offset from selection.1 when starting the move.
|
||||
pointer_offset: Point<i32, Physical>,
|
||||
// If the move is initiated by a touch, this is the slot. If `None`, the move was initiated by
|
||||
// holding Space.
|
||||
touch_slot: Option<TouchSlot>,
|
||||
}
|
||||
|
||||
pub enum Button {
|
||||
Up,
|
||||
Down {
|
||||
touch_slot: Option<TouchSlot>,
|
||||
on_capture_button: bool,
|
||||
last_pos: (Output, Point<i32, Physical>),
|
||||
move_state: Option<MoveState>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct OutputData {
|
||||
size: Size<i32, Physical>,
|
||||
scale: f64,
|
||||
@@ -88,6 +109,22 @@ niri_render_elements! {
|
||||
}
|
||||
}
|
||||
|
||||
impl Button {
|
||||
fn is_down(&self) -> bool {
|
||||
matches!(self, Self::Down { .. })
|
||||
}
|
||||
|
||||
fn is_dragging_selection(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Down {
|
||||
on_capture_button: false,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ScreenshotUi {
|
||||
pub fn new(clock: Clock, config: Rc<RefCell<Config>>) -> Self {
|
||||
Self::Closed {
|
||||
@@ -193,7 +230,7 @@ impl ScreenshotUi {
|
||||
*self = Self::Open {
|
||||
selection,
|
||||
output_data,
|
||||
mouse_down: false,
|
||||
button: Button::Up,
|
||||
show_pointer,
|
||||
open_anim,
|
||||
clock: clock.clone(),
|
||||
@@ -240,6 +277,37 @@ impl ScreenshotUi {
|
||||
matches!(self, ScreenshotUi::Open { .. })
|
||||
}
|
||||
|
||||
pub fn set_space_down(&mut self, down: bool) {
|
||||
if let Self::Open {
|
||||
selection,
|
||||
button:
|
||||
Button::Down {
|
||||
move_state,
|
||||
last_pos,
|
||||
..
|
||||
},
|
||||
..
|
||||
} = self
|
||||
{
|
||||
if down {
|
||||
if move_state.is_none() {
|
||||
*move_state = Some(MoveState {
|
||||
pointer_offset: last_pos.1 - selection.1,
|
||||
touch_slot: None,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Only clear if moving with Space.
|
||||
if let Some(MoveState {
|
||||
touch_slot: None, ..
|
||||
}) = move_state
|
||||
{
|
||||
*move_state = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_left(&mut self) {
|
||||
let Self::Open {
|
||||
selection: (output, a, b),
|
||||
@@ -320,6 +388,75 @@ impl ScreenshotUi {
|
||||
self.update_buffers();
|
||||
}
|
||||
|
||||
/// Moves the screenshot selection to a different output.
|
||||
///
|
||||
/// This preserves the relative position while keeping logical size. It is (intentionally) very
|
||||
/// similar to how floating windows move across monitors, but with one difference: floating
|
||||
/// windows can go partially outside the view, while the screenshot selection cannot. So, we
|
||||
/// clamp it to new output bounds, trying to preserve the size if possible.
|
||||
pub fn move_to_output(&mut self, new_output: Output) {
|
||||
let Self::Open {
|
||||
selection,
|
||||
output_data,
|
||||
..
|
||||
} = self
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (current_output, current_a, current_b) = selection;
|
||||
|
||||
if current_output == &new_output {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(target_data) = output_data.get(&new_output) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let current_data = &output_data[current_output];
|
||||
|
||||
let current_rect: Rectangle<_, Physical> = Rectangle::new(
|
||||
Point::from((current_a.x.min(current_b.x), current_a.y.min(current_b.y))),
|
||||
Size::from((
|
||||
(current_a.x.max(current_b.x) - current_a.x.min(current_b.x) + 1),
|
||||
(current_a.y.max(current_b.y) - current_a.y.min(current_b.y) + 1),
|
||||
)),
|
||||
);
|
||||
let current_rect = current_rect.to_f64();
|
||||
|
||||
let rel_x = current_rect.loc.x / current_data.size.w as f64;
|
||||
let rel_y = current_rect.loc.y / current_data.size.h as f64;
|
||||
|
||||
let factor = target_data.scale / current_data.scale;
|
||||
let mut new_width = (current_rect.size.w * factor).round() as i32;
|
||||
let mut new_height = (current_rect.size.h * factor).round() as i32;
|
||||
|
||||
new_width = new_width.clamp(1, target_data.size.w);
|
||||
new_height = new_height.clamp(1, target_data.size.h);
|
||||
|
||||
let new_x = (rel_x * target_data.size.w as f64).round() as i32;
|
||||
let new_y = (rel_y * target_data.size.h as f64).round() as i32;
|
||||
|
||||
let max_x = target_data.size.w - new_width;
|
||||
let max_y = target_data.size.h - new_height;
|
||||
let new_x = new_x.clamp(0, max_x);
|
||||
let new_y = new_y.clamp(0, max_y);
|
||||
|
||||
let new_rect = Rectangle::new(
|
||||
Point::from((new_x, new_y)),
|
||||
Size::from((new_width, new_height)),
|
||||
);
|
||||
|
||||
*selection = (
|
||||
new_output,
|
||||
new_rect.loc,
|
||||
new_rect.loc + new_rect.size - Size::from((1, 1)),
|
||||
);
|
||||
|
||||
self.update_buffers();
|
||||
}
|
||||
|
||||
pub fn set_width(&mut self, change: SizeChange) {
|
||||
let Self::Open {
|
||||
selection: (output, a, b),
|
||||
@@ -489,7 +626,7 @@ impl ScreenshotUi {
|
||||
let Self::Open {
|
||||
output_data,
|
||||
show_pointer,
|
||||
mouse_down,
|
||||
button,
|
||||
open_anim,
|
||||
..
|
||||
} = self
|
||||
@@ -509,17 +646,15 @@ impl ScreenshotUi {
|
||||
// The help panel goes on top.
|
||||
if let Some((show, hide)) = &output_data.panel {
|
||||
let buffer = if *show_pointer { hide } else { show };
|
||||
|
||||
let size = buffer.texture().size();
|
||||
let padding: i32 = to_physical_precise_round(scale, PADDING);
|
||||
let x = max(0, (output_data.size.w - size.w) / 2);
|
||||
let y = max(0, output_data.size.h - size.h - padding * 2);
|
||||
let location = Point::<_, Physical>::from((x, y))
|
||||
let alpha = if button.is_dragging_selection() {
|
||||
0.3
|
||||
} else {
|
||||
0.9
|
||||
};
|
||||
let location = panel_location(output_data, buffer.texture().size())
|
||||
.to_f64()
|
||||
.to_logical(scale);
|
||||
|
||||
let alpha = if *mouse_down { 0.3 } else { 0.9 };
|
||||
|
||||
let elem = PrimaryGpuTextureRenderElement(TextureRenderElement::from_texture_buffer(
|
||||
buffer.clone(),
|
||||
location,
|
||||
@@ -631,7 +766,12 @@ impl ScreenshotUi {
|
||||
}
|
||||
|
||||
pub fn action(&self, raw: Keysym, mods: ModifiersState) -> Option<Action> {
|
||||
if !matches!(self, Self::Open { .. }) {
|
||||
let Self::Open { button, .. } = self else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Pressing Space while the button is down goes into origin moving rather than capture.
|
||||
if matches!(button, Button::Down { .. }) && raw == Keysym::space {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -660,76 +800,201 @@ impl ScreenshotUi {
|
||||
}
|
||||
|
||||
/// The pointer has moved to `point` relative to the current selection output.
|
||||
pub fn pointer_motion(&mut self, point: Point<i32, Physical>) {
|
||||
pub fn pointer_motion(&mut self, point: Point<i32, Physical>, slot: Option<TouchSlot>) {
|
||||
let Self::Open {
|
||||
selection,
|
||||
mouse_down: true,
|
||||
output_data,
|
||||
button:
|
||||
Button::Down {
|
||||
touch_slot,
|
||||
on_capture_button,
|
||||
last_pos,
|
||||
move_state,
|
||||
},
|
||||
..
|
||||
} = self
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
selection.2 = point;
|
||||
if *touch_slot != slot {
|
||||
return;
|
||||
}
|
||||
|
||||
last_pos.1 = point;
|
||||
|
||||
if *on_capture_button {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(move_state) = move_state {
|
||||
// The cursor offset is relative to selection.1.
|
||||
let delta = point - (selection.1 + move_state.pointer_offset);
|
||||
|
||||
let desired = rect_from_corner_points(selection.1 + delta, selection.2 + delta);
|
||||
let bounds = Rectangle::from_size(output_data[&selection.0].size - desired.size);
|
||||
let clamped_loc = desired.loc.constrain(bounds);
|
||||
|
||||
let delta = clamped_loc - rect_from_corner_points(selection.1, selection.2).loc;
|
||||
selection.1 += delta;
|
||||
selection.2 += delta;
|
||||
} else {
|
||||
selection.2 = point;
|
||||
}
|
||||
|
||||
self.update_buffers();
|
||||
}
|
||||
|
||||
pub fn pointer_button(
|
||||
pub fn pointer_down(
|
||||
&mut self,
|
||||
output: Output,
|
||||
point: Point<i32, Physical>,
|
||||
button: MouseButton,
|
||||
state: ButtonState,
|
||||
slot: Option<TouchSlot>,
|
||||
) -> bool {
|
||||
let Self::Open {
|
||||
selection,
|
||||
output_data,
|
||||
mouse_down,
|
||||
show_pointer,
|
||||
button,
|
||||
..
|
||||
} = self
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if button != MouseButton::Left {
|
||||
return false;
|
||||
}
|
||||
|
||||
let down = state == ButtonState::Pressed;
|
||||
if *mouse_down == down {
|
||||
return false;
|
||||
}
|
||||
|
||||
if down && !output_data.contains_key(&output) {
|
||||
return false;
|
||||
}
|
||||
|
||||
*mouse_down = down;
|
||||
|
||||
if down {
|
||||
*selection = (output, point, point);
|
||||
} else {
|
||||
// Check if the resulting selection is zero-sized, and try to come up with a small
|
||||
// default rectangle.
|
||||
let (output, a, b) = selection;
|
||||
let mut rect = rect_from_corner_points(*a, *b);
|
||||
if rect.size.is_empty() || rect.size == Size::from((1, 1)) {
|
||||
let data = &output_data[output];
|
||||
rect = Rectangle::new(
|
||||
Point::from((rect.loc.x - 16, rect.loc.y - 16)),
|
||||
Size::from((32, 32)),
|
||||
)
|
||||
.intersection(Rectangle::from_size(data.size))
|
||||
.unwrap_or_default();
|
||||
*a = rect.loc;
|
||||
*b = rect.loc + rect.size - Size::from((1, 1));
|
||||
// Check if this is a second touch (different slot) while already dragging.
|
||||
if let Some(new_slot) = slot {
|
||||
if let Button::Down {
|
||||
on_capture_button: false,
|
||||
move_state,
|
||||
last_pos,
|
||||
..
|
||||
} = button
|
||||
{
|
||||
if move_state.is_none() {
|
||||
*move_state = Some(MoveState {
|
||||
pointer_offset: last_pos.1 - selection.1,
|
||||
touch_slot: Some(new_slot),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if button.is_down() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(output_data) = output_data.get(&output) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Some((show, hide)) = &output_data.panel {
|
||||
let buffer = if *show_pointer { hide } else { show };
|
||||
let panel_size = buffer.texture().size();
|
||||
let location = panel_location(output_data, panel_size);
|
||||
|
||||
if is_within_capture_button(output_data.scale, panel_size, point - location) {
|
||||
*button = Button::Down {
|
||||
touch_slot: slot,
|
||||
on_capture_button: true,
|
||||
last_pos: (output, point),
|
||||
move_state: None,
|
||||
};
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
*button = Button::Down {
|
||||
touch_slot: slot,
|
||||
on_capture_button: false,
|
||||
last_pos: (output.clone(), point),
|
||||
move_state: None,
|
||||
};
|
||||
*selection = (output, point, point);
|
||||
|
||||
self.update_buffers();
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn pointer_up(&mut self, slot: Option<TouchSlot>) -> Option<bool> {
|
||||
let Self::Open {
|
||||
selection,
|
||||
output_data,
|
||||
button,
|
||||
show_pointer,
|
||||
..
|
||||
} = self
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let Button::Down {
|
||||
touch_slot,
|
||||
on_capture_button,
|
||||
ref last_pos,
|
||||
ref mut move_state,
|
||||
..
|
||||
} = *button
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Check if this is a move touch and if so, stop the move.
|
||||
if let Some(state) = move_state {
|
||||
if state.touch_slot.is_some_and(|m_slot| Some(m_slot) == slot) {
|
||||
*move_state = None;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if touch_slot != slot {
|
||||
return None;
|
||||
}
|
||||
|
||||
let last_pos = last_pos.clone();
|
||||
*button = Button::Up;
|
||||
|
||||
// Check if we released still on the capture button.
|
||||
if on_capture_button {
|
||||
let (output, point) = last_pos;
|
||||
|
||||
#[allow(clippy::question_mark)]
|
||||
let Some(output_data) = output_data.get(&output) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if let Some((show, hide)) = &output_data.panel {
|
||||
let buffer = if *show_pointer { hide } else { show };
|
||||
let panel_size = buffer.texture().size();
|
||||
let location = panel_location(output_data, panel_size);
|
||||
|
||||
if is_within_capture_button(output_data.scale, panel_size, point - location) {
|
||||
return Some(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the resulting selection is zero-sized, and try to come up with a small
|
||||
// default rectangle.
|
||||
let (output, a, b) = selection;
|
||||
let mut rect = rect_from_corner_points(*a, *b);
|
||||
if rect.size.is_empty() || rect.size == Size::from((1, 1)) {
|
||||
let data = &output_data[output];
|
||||
rect = Rectangle::new(
|
||||
Point::from((rect.loc.x - 16, rect.loc.y - 16)),
|
||||
Size::from((32, 32)),
|
||||
)
|
||||
.intersection(Rectangle::from_size(data.size))
|
||||
.unwrap_or_default();
|
||||
*a = rect.loc;
|
||||
*b = rect.loc + rect.size - Size::from((1, 1));
|
||||
}
|
||||
|
||||
self.update_buffers();
|
||||
|
||||
Some(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl OutputScreenshot {
|
||||
@@ -819,6 +1084,29 @@ pub fn rect_from_corner_points(
|
||||
Rectangle::from_extremities((x1, y1), (x2 + 1, y2 + 1))
|
||||
}
|
||||
|
||||
fn panel_location(output_data: &OutputData, panel_size: Size<i32, Buffer>) -> Point<i32, Physical> {
|
||||
let scale = output_data.scale;
|
||||
let padding: i32 = to_physical_precise_round(scale, PADDING);
|
||||
let x = max(0, (output_data.size.w - panel_size.w) / 2);
|
||||
let y = max(0, output_data.size.h - panel_size.h - padding * 2);
|
||||
Point::from((x, y))
|
||||
}
|
||||
|
||||
fn is_within_capture_button(
|
||||
scale: f64,
|
||||
panel_size: Size<i32, Buffer>,
|
||||
pos_within_panel: Point<i32, Physical>,
|
||||
) -> bool {
|
||||
let padding: i32 = to_physical_precise_round(scale, PADDING);
|
||||
let radius = to_physical_precise_round::<i32>(scale, RADIUS) - 2;
|
||||
|
||||
let xc = padding + radius;
|
||||
let yc = panel_size.h / 2;
|
||||
let pos = pos_within_panel;
|
||||
|
||||
(pos.x - xc) * (pos.x - xc) + (pos.y - yc) * (pos.y - yc) <= radius * radius
|
||||
}
|
||||
|
||||
fn render_panel(
|
||||
renderer: &mut GlesRenderer,
|
||||
scale: f64,
|
||||
@@ -827,6 +1115,11 @@ fn render_panel(
|
||||
let _span = tracy_client::span!("screenshot_ui::render_panel");
|
||||
|
||||
let padding: i32 = to_physical_precise_round(scale, PADDING);
|
||||
// Keep the border width even to avoid blurry edges.
|
||||
let border_width = (f64::from(BORDER) / 2. * scale).round() * 2.;
|
||||
let half_border_width = (border_width / 2.) as i32;
|
||||
let radius: i32 = to_physical_precise_round(scale, RADIUS);
|
||||
let circle_stroke: f64 = to_physical_precise_round(scale, 2.);
|
||||
|
||||
// Add 2 px of spacing to separate the backgrounds of the "Space" and "P" keys.
|
||||
let spacing = to_physical_precise_round::<i32>(scale, 2) * 1024;
|
||||
@@ -839,12 +1132,14 @@ fn render_panel(
|
||||
let layout = pangocairo::functions::create_layout(&cr);
|
||||
layout.context().set_round_glyph_positions(false);
|
||||
layout.set_font_description(Some(&font));
|
||||
layout.set_alignment(Alignment::Center);
|
||||
layout.set_alignment(Alignment::Left);
|
||||
layout.set_markup(text);
|
||||
layout.set_spacing(spacing);
|
||||
|
||||
let (mut width, mut height) = layout.pixel_size();
|
||||
width += padding * 2;
|
||||
|
||||
width += padding + radius * 2 + padding - half_border_width + padding;
|
||||
height = max(height, radius * 2);
|
||||
height += padding * 2;
|
||||
|
||||
let surface = ImageSurface::create(cairo::Format::ARgb32, width, height)?;
|
||||
@@ -852,11 +1147,33 @@ fn render_panel(
|
||||
cr.set_source_rgb(0.1, 0.1, 0.1);
|
||||
cr.paint()?;
|
||||
|
||||
cr.move_to(padding.into(), padding.into());
|
||||
let padding = f64::from(padding);
|
||||
let half_border_width = f64::from(half_border_width);
|
||||
let r = f64::from(radius);
|
||||
|
||||
let yc = f64::from(height / 2);
|
||||
|
||||
cr.new_sub_path();
|
||||
cr.arc(padding + r, yc, r, 0., TAU);
|
||||
cr.set_source_rgb(1., 1., 1.);
|
||||
cr.fill()?;
|
||||
|
||||
cr.new_sub_path();
|
||||
cr.arc(padding + r, yc, r - circle_stroke, 0., TAU);
|
||||
cr.set_source_rgb(0.1, 0.1, 0.1);
|
||||
cr.fill()?;
|
||||
|
||||
cr.new_sub_path();
|
||||
cr.arc(padding + r, yc, r - circle_stroke * 2., 0., TAU);
|
||||
cr.set_source_rgb(1., 1., 1.);
|
||||
cr.fill()?;
|
||||
|
||||
cr.move_to(padding + r * 2. + padding - half_border_width, padding);
|
||||
|
||||
let layout = pangocairo::functions::create_layout(&cr);
|
||||
layout.context().set_round_glyph_positions(false);
|
||||
layout.set_font_description(Some(&font));
|
||||
layout.set_alignment(Alignment::Center);
|
||||
layout.set_alignment(Alignment::Left);
|
||||
layout.set_markup(text);
|
||||
layout.set_spacing(spacing);
|
||||
|
||||
@@ -869,8 +1186,7 @@ fn render_panel(
|
||||
cr.line_to(0., height.into());
|
||||
cr.line_to(0., 0.);
|
||||
cr.set_source_rgb(0.3, 0.3, 0.3);
|
||||
// Keep the border width even to avoid blurry edges.
|
||||
cr.set_line_width((f64::from(BORDER) / 2. * scale).round() * 2.);
|
||||
cr.set_line_width(border_width);
|
||||
cr.stroke()?;
|
||||
drop(cr);
|
||||
|
||||
|
||||
+10
-1
@@ -1,4 +1,5 @@
|
||||
use std::cmp::{max, min};
|
||||
use std::f64;
|
||||
use std::ffi::{CString, OsStr};
|
||||
use std::io::Write;
|
||||
use std::os::unix::prelude::OsStrExt;
|
||||
@@ -33,9 +34,11 @@ use crate::niri::ClientState;
|
||||
|
||||
pub mod id;
|
||||
pub mod scale;
|
||||
pub mod signals;
|
||||
pub mod spawning;
|
||||
pub mod transaction;
|
||||
pub mod watcher;
|
||||
pub mod xwayland;
|
||||
|
||||
pub static IS_SYSTEMD_SERVICE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -292,7 +295,7 @@ pub fn update_tiled_state(
|
||||
// global and never reset to None).
|
||||
//
|
||||
// If the client bound a decoration global, use the mode that we negotiated. This way,
|
||||
// changing the decoration mode on the client at runtime will synchonize with the
|
||||
// changing the decoration mode on the client at runtime will synchronize with the
|
||||
// default tiled state.
|
||||
if let Some(mode) = toplevel.with_pending_state(|state| state.decoration_mode) {
|
||||
mode == zxdg_toplevel_decoration_v1::Mode::ServerSide
|
||||
@@ -396,6 +399,12 @@ pub fn center_preferring_top_left_in_area(
|
||||
area.loc + offset
|
||||
}
|
||||
|
||||
pub fn baba_is_float_offset(now: Duration, view_height: f64) -> f64 {
|
||||
let now = now.as_secs_f64();
|
||||
let amplitude = view_height / 96.;
|
||||
amplitude * ((f64::consts::TAU * now / 3.6).sin() - 1.)
|
||||
}
|
||||
|
||||
#[cfg(feature = "dbus")]
|
||||
pub fn show_screenshot_notification(image_path: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! We set a signal handler with `calloop::signals::Signals::new`.
|
||||
//! This does two things:
|
||||
//! 1. It blocks the thread from receiving these signals normally (pthread_sigmask)
|
||||
//! 2. It creates a signalfd to read them in the event loop.
|
||||
//!
|
||||
//! When spawning children, calloop already deals with the signalfd.
|
||||
//! `Signals::new` creates it with CLOEXEC, so it will not be inherited by children.
|
||||
//!
|
||||
//! But, the sigmask is always inherited, so we want to clear it before spawning children.
|
||||
//! That way, we don't affect their normal signal handling.
|
||||
//!
|
||||
//! In particular, if a child doesn't care about signals, we must not block it from receiving them.
|
||||
//!
|
||||
//! This module provides functions to clear the sigmask. Call them before spawning children.
|
||||
//!
|
||||
//! Technically, a "more correct" solution would be to remember the original sigmask and restore it
|
||||
//! after the child exits, but that's painful *and* likely to cause issues, because the user almost
|
||||
//! never intended to spawn niri with a nonempty sigmask. It indicates a bug in whoever spawned us,
|
||||
//! so we may as well clean up after them (which is easier than not doing so).
|
||||
|
||||
use std::{io, mem};
|
||||
|
||||
use calloop::signals::{Signal, Signals};
|
||||
|
||||
pub fn listen(handle: &calloop::LoopHandle<crate::niri::State>) {
|
||||
handle
|
||||
.insert_source(
|
||||
Signals::new(&[Signal::SIGINT, Signal::SIGTERM, Signal::SIGHUP]).unwrap(),
|
||||
|event, _, state| {
|
||||
info!("quitting due to receiving signal {:?}", event.signal());
|
||||
state.niri.stop_signal.stop();
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// We block the signals early, so that they apply to all threads.
|
||||
// They are then blocked *again* by the `Signals` source. That's fine.
|
||||
pub fn block_early() -> io::Result<()> {
|
||||
set_sigmask(&preferred_sigset()?)
|
||||
}
|
||||
|
||||
pub fn unblock_all() -> io::Result<()> {
|
||||
set_sigmask(&empty_sigset()?)
|
||||
}
|
||||
|
||||
pub fn empty_sigset() -> io::Result<libc::sigset_t> {
|
||||
let mut sigset = mem::MaybeUninit::uninit();
|
||||
if unsafe { libc::sigemptyset(sigset.as_mut_ptr()) } == 0 {
|
||||
Ok(unsafe { sigset.assume_init() })
|
||||
} else {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preferred_sigset() -> io::Result<libc::sigset_t> {
|
||||
let mut set = empty_sigset()?;
|
||||
unsafe {
|
||||
add_signal(&mut set, libc::SIGINT)?;
|
||||
add_signal(&mut set, libc::SIGTERM)?;
|
||||
add_signal(&mut set, libc::SIGHUP)?;
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
// SAFETY: `signum` must be a valid signal number.
|
||||
unsafe fn add_signal(set: &mut libc::sigset_t, signum: libc::c_int) -> io::Result<()> {
|
||||
if unsafe { libc::sigaddset(set, signum) } == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_sigmask(set: &libc::sigset_t) -> io::Result<()> {
|
||||
let oldset = std::ptr::null_mut(); // ignore old mask
|
||||
if unsafe { libc::pthread_sigmask(libc::SIG_SETMASK, set, oldset) } == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use crate::utils::expand_home;
|
||||
pub static REMOVE_ENV_RUST_BACKTRACE: AtomicBool = AtomicBool::new(false);
|
||||
pub static REMOVE_ENV_RUST_LIB_BACKTRACE: AtomicBool = AtomicBool::new(false);
|
||||
pub static CHILD_ENV: RwLock<Environment> = RwLock::new(Environment(Vec::new()));
|
||||
pub static CHILD_DISPLAY: RwLock<Option<String>> = RwLock::new(None);
|
||||
|
||||
static ORIGINAL_NOFILE_RLIMIT_CUR: Atomic<rlim_t> = Atomic::new(0);
|
||||
static ORIGINAL_NOFILE_RLIMIT_MAX: Atomic<rlim_t> = Atomic::new(0);
|
||||
@@ -116,6 +117,14 @@ fn spawn_sync(
|
||||
process.env_remove("RUST_LIB_BACKTRACE");
|
||||
}
|
||||
|
||||
// Set DISPLAY if needed.
|
||||
let display = CHILD_DISPLAY.read().unwrap();
|
||||
if let Some(display) = &*display {
|
||||
process.env("DISPLAY", display);
|
||||
} else {
|
||||
process.env_remove("DISPLAY");
|
||||
}
|
||||
|
||||
// Set configured environment.
|
||||
let env = CHILD_ENV.read().unwrap();
|
||||
for var in &env.0 {
|
||||
@@ -132,6 +141,8 @@ fn spawn_sync(
|
||||
process.env("DESKTOP_STARTUP_ID", token.as_str());
|
||||
}
|
||||
|
||||
unsafe { process.pre_exec(crate::utils::signals::unblock_all) };
|
||||
|
||||
let Some(mut child) = do_spawn(command, process) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -93,6 +93,8 @@ impl Transaction {
|
||||
let _span = trace_span!("deadline timer", transaction = ?Weak::as_ptr(&inner))
|
||||
.entered();
|
||||
|
||||
// FIXME: come up with some way to control the deadline timer from tests.
|
||||
#[cfg(not(test))]
|
||||
if let Some(inner) = inner.upgrade() {
|
||||
trace!("deadline reached, completing transaction");
|
||||
inner.complete();
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::os::linux::net::SocketAddrExt;
|
||||
use std::os::unix::net::{SocketAddr, UnixListener};
|
||||
|
||||
use anyhow::{anyhow, ensure, Context as _};
|
||||
use rustix::fs::{lstat, mkdir};
|
||||
use rustix::io::Errno;
|
||||
use rustix::process::getuid;
|
||||
use smithay::reexports::rustix::fs::{unlink, OFlags};
|
||||
use smithay::reexports::rustix::process::getpid;
|
||||
use smithay::reexports::rustix::{self};
|
||||
|
||||
pub mod satellite;
|
||||
|
||||
const TMP_UNIX_DIR: &str = "/tmp";
|
||||
const X11_TMP_UNIX_DIR: &str = "/tmp/.X11-unix";
|
||||
|
||||
struct X11Connection {
|
||||
display_name: String,
|
||||
abstract_fd: OwnedFd,
|
||||
unix_fd: OwnedFd,
|
||||
_unix_guard: Unlink,
|
||||
_lock_guard: Unlink,
|
||||
}
|
||||
|
||||
struct Unlink(String);
|
||||
impl Drop for Unlink {
|
||||
fn drop(&mut self) {
|
||||
let _ = unlink(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Adapted from Mutter code:
|
||||
// https://gitlab.gnome.org/GNOME/mutter/-/blob/48.3.1/src/wayland/meta-xwayland.c?ref_type=tags#L513
|
||||
fn ensure_x11_unix_dir() -> anyhow::Result<()> {
|
||||
match mkdir(X11_TMP_UNIX_DIR, 0o1777.into()) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(Errno::EXIST) => {
|
||||
ensure_x11_unix_perms().context("wrong X11 directory permissions")?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err).context("error creating X11 directory"),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_x11_unix_perms() -> anyhow::Result<()> {
|
||||
let x11_tmp = lstat(X11_TMP_UNIX_DIR).context("error checking X11 directory permissions")?;
|
||||
let tmp = lstat(TMP_UNIX_DIR).context("error checking /tmp directory permissions")?;
|
||||
|
||||
ensure!(
|
||||
x11_tmp.st_uid == tmp.st_uid || x11_tmp.st_uid == getuid().as_raw(),
|
||||
"wrong ownership for X11 directory"
|
||||
);
|
||||
ensure!(
|
||||
(x11_tmp.st_mode & 0o022) == 0o022,
|
||||
"X11 directory is not writable"
|
||||
);
|
||||
ensure!(
|
||||
(x11_tmp.st_mode & 0o1000) == 0o1000,
|
||||
"X11 directory is missing the sticky bit"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pick_x11_display(start: u32) -> anyhow::Result<(u32, OwnedFd, Unlink)> {
|
||||
for n in start..start + 50 {
|
||||
let lock_path = format!("/tmp/.X{n}-lock");
|
||||
let flags = OFlags::WRONLY | OFlags::CLOEXEC | OFlags::CREATE | OFlags::EXCL;
|
||||
let Ok(lock_fd) = rustix::fs::open(&lock_path, flags, 0o444.into()) else {
|
||||
// FIXME: check if the target process is dead and reuse the lock.
|
||||
continue;
|
||||
};
|
||||
return Ok((n, lock_fd, Unlink(lock_path)));
|
||||
}
|
||||
|
||||
Err(anyhow!("no free X11 display found after 50 attempts"))
|
||||
}
|
||||
|
||||
fn bind_to_socket(addr: &SocketAddr) -> anyhow::Result<UnixListener> {
|
||||
let listener = UnixListener::bind_addr(addr).context("error binding socket")?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
fn bind_to_abstract_socket(display: u32) -> anyhow::Result<UnixListener> {
|
||||
let name = format!("/tmp/.X11-unix/X{display}");
|
||||
let addr = SocketAddr::from_abstract_name(name).unwrap();
|
||||
bind_to_socket(&addr)
|
||||
}
|
||||
|
||||
fn bind_to_unix_socket(display: u32) -> anyhow::Result<(UnixListener, Unlink)> {
|
||||
let name = format!("/tmp/.X11-unix/X{display}");
|
||||
let addr = SocketAddr::from_pathname(&name).unwrap();
|
||||
// Unlink old leftover socket if any.
|
||||
let _ = unlink(&name);
|
||||
let guard = Unlink(name);
|
||||
bind_to_socket(&addr).map(|listener| (listener, guard))
|
||||
}
|
||||
|
||||
fn open_display_sockets(display: u32) -> anyhow::Result<(UnixListener, UnixListener, Unlink)> {
|
||||
let a = bind_to_abstract_socket(display).context("error binding to abstract socket")?;
|
||||
let (u, g) = bind_to_unix_socket(display).context("error binding to unix socket")?;
|
||||
Ok((a, u, g))
|
||||
}
|
||||
|
||||
fn setup_connection() -> anyhow::Result<X11Connection> {
|
||||
let _span = tracy_client::span!("open_x11_sockets");
|
||||
|
||||
ensure_x11_unix_dir()?;
|
||||
|
||||
let mut n = 0;
|
||||
let mut attempt = 0;
|
||||
let (display, lock_guard, a, u, unix_guard) = loop {
|
||||
let (display, lock_fd, lock_guard) = pick_x11_display(n)?;
|
||||
|
||||
// Write our PID into the lock file.
|
||||
let pid_string = format!("{:>10}\n", getpid().as_raw_nonzero());
|
||||
if let Err(err) = rustix::io::write(&lock_fd, pid_string.as_bytes()) {
|
||||
return Err(err).context("error writing PID to X11 lock file");
|
||||
}
|
||||
drop(lock_fd);
|
||||
|
||||
match open_display_sockets(display) {
|
||||
Ok((a, u, g)) => {
|
||||
break (display, lock_guard, a, u, g);
|
||||
}
|
||||
Err(err) => {
|
||||
if attempt == 50 {
|
||||
return Err(err)
|
||||
.context("error opening X11 sockets after creating a lock file");
|
||||
}
|
||||
|
||||
n = display + 1;
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let display_name = format!(":{display}");
|
||||
let abstract_fd = OwnedFd::from(a);
|
||||
let unix_fd = OwnedFd::from(u);
|
||||
|
||||
Ok(X11Connection {
|
||||
display_name,
|
||||
abstract_fd,
|
||||
unix_fd,
|
||||
_unix_guard: unix_guard,
|
||||
_lock_guard: lock_guard,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
use std::os::fd::{AsRawFd as _, BorrowedFd, OwnedFd};
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::os::unix::process::CommandExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
|
||||
use calloop::channel::Sender;
|
||||
use calloop::generic::Generic;
|
||||
use calloop::{Interest, Mode, PostAction, RegistrationToken};
|
||||
use smithay::reexports::rustix::io::{fcntl_setfd, FdFlags};
|
||||
|
||||
use crate::niri::State;
|
||||
use crate::utils::expand_home;
|
||||
use crate::utils::xwayland::X11Connection;
|
||||
|
||||
pub struct Satellite {
|
||||
x11: X11Connection,
|
||||
abstract_token: Option<RegistrationToken>,
|
||||
unix_token: Option<RegistrationToken>,
|
||||
to_main: Sender<ToMain>,
|
||||
}
|
||||
|
||||
enum ToMain {
|
||||
SetupWatch,
|
||||
}
|
||||
|
||||
impl Satellite {
|
||||
pub fn display_name(&self) -> &str {
|
||||
&self.x11.display_name
|
||||
}
|
||||
}
|
||||
|
||||
pub fn setup(state: &mut State) {
|
||||
if state.niri.satellite.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let config = state.niri.config.borrow();
|
||||
let xwls_config = &config.xwayland_satellite;
|
||||
if xwls_config.off {
|
||||
return;
|
||||
}
|
||||
|
||||
if !test_ondemand(&xwls_config.path) {
|
||||
return;
|
||||
}
|
||||
drop(config);
|
||||
|
||||
let x11 = match super::setup_connection() {
|
||||
Ok(x11) => x11,
|
||||
Err(err) => {
|
||||
warn!("error opening X11 sockets, disabling xwayland-satellite integration: {err:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let event_loop = &state.niri.event_loop;
|
||||
let (to_main, rx) = calloop::channel::channel();
|
||||
event_loop
|
||||
.insert_source(rx, move |event, _, state| match event {
|
||||
calloop::channel::Event::Msg(msg) => match msg {
|
||||
ToMain::SetupWatch => setup_watch(state),
|
||||
},
|
||||
calloop::channel::Event::Closed => (),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
state.niri.satellite = Some(Satellite {
|
||||
x11,
|
||||
abstract_token: None,
|
||||
unix_token: None,
|
||||
to_main,
|
||||
});
|
||||
|
||||
setup_watch(state);
|
||||
}
|
||||
|
||||
fn test_ondemand(path: &str) -> bool {
|
||||
let _span = tracy_client::span!("satellite::test_ondemand");
|
||||
|
||||
// Expand `~` at the start.
|
||||
let mut path = Path::new(path);
|
||||
let expanded = expand_home(path);
|
||||
match &expanded {
|
||||
Ok(Some(expanded)) => path = expanded.as_ref(),
|
||||
Ok(None) => (),
|
||||
Err(err) => {
|
||||
warn!("error expanding ~: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
let mut process = Command::new(path);
|
||||
process
|
||||
.args([":0", "--test-listenfd-support"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("RUST_BACKTRACE")
|
||||
.env_remove("RUST_LIB_BACKTRACE");
|
||||
|
||||
let mut child = match process.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
info!("error spawning xwayland-satellite at {path:?}, disabling integration: {err}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let status = match child.wait() {
|
||||
Ok(status) => status,
|
||||
Err(err) => {
|
||||
info!("error waiting for xwayland-satellite, disabling integration: {err}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if !status.success() {
|
||||
info!("xwayland-satellite doesn't support on-demand activation, disabling integration");
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
// When xwayland-satellite fails to start and accept a connection on the socket, the socket will
|
||||
// keep triggering our event source, even after the X11 client quits, resulting in a busyloop of
|
||||
// trying to start xwayland-satellite. This function will clear out (accept and drop) all pending
|
||||
// connections on the socket before registering a new event source, working around this problem.
|
||||
// When the problem happens, it's very likely that xwayland-satellite won't be able to accept the
|
||||
// pending client (since it had just failed to do so), so it's fine to drop the connections.
|
||||
fn clear_out_pending_connections(fd: OwnedFd) -> OwnedFd {
|
||||
let listener = UnixListener::from(fd);
|
||||
|
||||
if let Err(err) = listener.set_nonblocking(true) {
|
||||
warn!("error setting X11 socket to nonblocking: {err:?}");
|
||||
return OwnedFd::from(listener);
|
||||
}
|
||||
|
||||
while listener.accept().is_ok() {}
|
||||
|
||||
if let Err(err) = listener.set_nonblocking(false) {
|
||||
warn!("error setting X11 socket to blocking: {err:?}");
|
||||
}
|
||||
|
||||
OwnedFd::from(listener)
|
||||
}
|
||||
|
||||
fn setup_watch(state: &mut State) {
|
||||
let Some(satellite) = state.niri.satellite.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let event_loop = &state.niri.event_loop;
|
||||
|
||||
if let Some(token) = satellite.abstract_token.take() {
|
||||
error!("abstract_token must be None in setup_watch()");
|
||||
event_loop.remove(token);
|
||||
}
|
||||
if let Some(token) = satellite.unix_token.take() {
|
||||
error!("unix_token must be None in setup_watch()");
|
||||
event_loop.remove(token);
|
||||
}
|
||||
|
||||
let fd = satellite.x11.abstract_fd.try_clone().unwrap();
|
||||
let fd = clear_out_pending_connections(fd);
|
||||
let source = Generic::new(fd, Interest::READ, Mode::Level);
|
||||
let token = event_loop
|
||||
.insert_source(source, move |_, _, state| {
|
||||
if let Some(satellite) = &mut state.niri.satellite {
|
||||
// Remove the other source.
|
||||
if let Some(token) = satellite.unix_token.take() {
|
||||
state.niri.event_loop.remove(token);
|
||||
}
|
||||
// Clear this source.
|
||||
satellite.abstract_token = None;
|
||||
|
||||
debug!("connection to X11 abstract socket; spawning xwayland-satellite");
|
||||
let path = state.niri.config.borrow().xwayland_satellite.path.clone();
|
||||
spawn(path, satellite);
|
||||
}
|
||||
Ok(PostAction::Remove)
|
||||
})
|
||||
.unwrap();
|
||||
satellite.abstract_token = Some(token);
|
||||
|
||||
let fd = satellite.x11.unix_fd.try_clone().unwrap();
|
||||
let fd = clear_out_pending_connections(fd);
|
||||
let source = Generic::new(fd, Interest::READ, Mode::Level);
|
||||
let token = event_loop
|
||||
.insert_source(source, move |_, _, state| {
|
||||
if let Some(satellite) = &mut state.niri.satellite {
|
||||
// Remove the other source.
|
||||
if let Some(token) = satellite.abstract_token.take() {
|
||||
state.niri.event_loop.remove(token);
|
||||
}
|
||||
// Clear this source.
|
||||
satellite.unix_token = None;
|
||||
|
||||
debug!("connection to X11 unix socket; spawning xwayland-satellite");
|
||||
let path = state.niri.config.borrow().xwayland_satellite.path.clone();
|
||||
spawn(path, satellite);
|
||||
}
|
||||
Ok(PostAction::Remove)
|
||||
})
|
||||
.unwrap();
|
||||
satellite.unix_token = Some(token);
|
||||
}
|
||||
|
||||
fn spawn(path: String, xwl: &Satellite) {
|
||||
let _span = tracy_client::span!("satellite::spawn");
|
||||
|
||||
let abstract_fd = xwl.x11.abstract_fd.try_clone().unwrap();
|
||||
let unix_fd = xwl.x11.unix_fd.try_clone().unwrap();
|
||||
let to_main = xwl.to_main.clone();
|
||||
|
||||
// Expand `~` at the start.
|
||||
let mut path = PathBuf::from(path);
|
||||
let expanded = expand_home(&path);
|
||||
match expanded {
|
||||
Ok(Some(expanded)) => path = expanded,
|
||||
Ok(None) => (),
|
||||
Err(err) => {
|
||||
warn!("error expanding ~: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
let mut process = Command::new(&path);
|
||||
process.arg(&xwl.x11.display_name).env_remove("DISPLAY");
|
||||
|
||||
// We don't want it spamming the niri output.
|
||||
process
|
||||
.env_remove("RUST_BACKTRACE")
|
||||
.env_remove("RUST_LIB_BACKTRACE");
|
||||
process
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
unsafe { process.pre_exec(crate::utils::signals::unblock_all) };
|
||||
|
||||
// Spawning and waiting takes some milliseconds, so do it in a thread.
|
||||
let res = thread::Builder::new()
|
||||
.name("Xwl-s Spawner".to_owned())
|
||||
.spawn(move || {
|
||||
spawn_and_wait(&path, process, abstract_fd, unix_fd);
|
||||
|
||||
// Once xwayland-satellite crashes or fails to spawn, re-establish our X11 socket watch
|
||||
// to try again next time.
|
||||
let _ = to_main.send(ToMain::SetupWatch);
|
||||
});
|
||||
|
||||
if let Err(err) = res {
|
||||
warn!("error spawning a thread to spawn xwayland-satellite: {err:?}");
|
||||
let _ = xwl.to_main.send(ToMain::SetupWatch);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_and_wait(path: &Path, mut process: Command, abstract_fd: OwnedFd, unix_fd: OwnedFd) {
|
||||
let abstract_raw = abstract_fd.as_raw_fd();
|
||||
let unix_raw = unix_fd.as_raw_fd();
|
||||
|
||||
process
|
||||
.arg("-listenfd")
|
||||
.arg(abstract_raw.to_string())
|
||||
.arg("-listenfd")
|
||||
.arg(unix_raw.to_string());
|
||||
|
||||
unsafe {
|
||||
process.pre_exec(move || {
|
||||
// We're about to exec xwl-s; perfect time to clear CLOEXEC on the file descriptors
|
||||
// that we want to pass it.
|
||||
|
||||
// We're not dropping these until after spawn().
|
||||
let abstract_fd = BorrowedFd::borrow_raw(abstract_raw);
|
||||
let unix_fd = BorrowedFd::borrow_raw(unix_raw);
|
||||
|
||||
fcntl_setfd(abstract_fd, FdFlags::empty())?;
|
||||
fcntl_setfd(unix_fd, FdFlags::empty())?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
};
|
||||
|
||||
let mut child = {
|
||||
let _span = tracy_client::span!();
|
||||
match process.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
warn!("error spawning {path:?}: {err:?}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The process spawned, we can drop our fds.
|
||||
drop(abstract_fd);
|
||||
drop(unix_fd);
|
||||
|
||||
let status = match child.wait() {
|
||||
Ok(status) => status,
|
||||
Err(err) => {
|
||||
warn!("error waiting for xwayland-satellite: {err:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// This is most likely a crash, hence warn!().
|
||||
warn!("xwayland-satellite exited with: {status}");
|
||||
}
|
||||
+35
-11
@@ -77,6 +77,9 @@ pub struct Mapped {
|
||||
/// If `None`, then the window is not offscreened.
|
||||
offscreen_data: RefCell<Option<OffscreenData>>,
|
||||
|
||||
/// Whether this has an urgent indicator.
|
||||
is_urgent: bool,
|
||||
|
||||
/// Whether this window has the keyboard focus.
|
||||
is_focused: bool,
|
||||
|
||||
@@ -144,18 +147,19 @@ pub struct Mapped {
|
||||
/// fullscreen state, and keep the size (since it matches), resulting in no configure.
|
||||
///
|
||||
/// So we work around this by emulating a configure-ack/commit cycle through
|
||||
/// is_pending_windowed_fullscreen and uncommited_windowed_fullscreen. We ensure we send actual
|
||||
/// configures in all cases through needs_configure. This can result in unnecessary configures
|
||||
/// (like in the example above), but in most cases there will be a configure anyway to change
|
||||
/// the Fullscreen state and/or the size. What this gives us is being able to synchronize our
|
||||
/// windowed fullscreen state to the real window updates to avoid any flickering.
|
||||
/// is_pending_windowed_fullscreen and uncommitted_windowed_fullscreen. We ensure we send
|
||||
/// actual configures in all cases through needs_configure. This can result in unnecessary
|
||||
/// configures (like in the example above), but in most cases there will be a configure
|
||||
/// anyway to change the Fullscreen state and/or the size. What this gives us is being able
|
||||
/// to synchronize our windowed fullscreen state to the real window updates to avoid any
|
||||
/// flickering.
|
||||
is_pending_windowed_fullscreen: bool,
|
||||
|
||||
/// Pending windowed fullscreen updates.
|
||||
///
|
||||
/// These have been "sent" to the window in form of configures, but the window hadn't committed
|
||||
/// in response yet.
|
||||
uncommited_windowed_fullscreen: Vec<(Serial, bool)>,
|
||||
uncommitted_windowed_fullscreen: Vec<(Serial, bool)>,
|
||||
}
|
||||
|
||||
niri_render_elements! {
|
||||
@@ -231,6 +235,7 @@ impl Mapped {
|
||||
needs_configure: false,
|
||||
needs_frame_callback: false,
|
||||
offscreen_data: RefCell::new(None),
|
||||
is_urgent: false,
|
||||
is_focused: false,
|
||||
is_active_in_column: true,
|
||||
is_floating: false,
|
||||
@@ -247,7 +252,7 @@ impl Mapped {
|
||||
last_interactive_resize_start: Cell::new(None),
|
||||
is_windowed_fullscreen: false,
|
||||
is_pending_windowed_fullscreen: false,
|
||||
uncommited_windowed_fullscreen: Vec::new(),
|
||||
uncommitted_windowed_fullscreen: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +333,7 @@ impl Mapped {
|
||||
}
|
||||
|
||||
self.is_focused = is_focused;
|
||||
self.is_urgent = false;
|
||||
self.need_to_recompute_rules = true;
|
||||
}
|
||||
|
||||
@@ -510,6 +516,20 @@ impl Mapped {
|
||||
pub fn is_windowed_fullscreen(&self) -> bool {
|
||||
self.is_windowed_fullscreen
|
||||
}
|
||||
|
||||
pub fn set_urgent(&mut self, urgent: bool) {
|
||||
if self.is_focused && urgent {
|
||||
return;
|
||||
}
|
||||
|
||||
let changed = self.is_urgent != urgent;
|
||||
self.is_urgent = urgent;
|
||||
self.need_to_recompute_rules |= changed;
|
||||
}
|
||||
|
||||
pub fn is_urgent(&self) -> bool {
|
||||
self.is_urgent
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Mapped {
|
||||
@@ -830,6 +850,10 @@ impl LayoutElement for Mapped {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_urgent(&self) -> bool {
|
||||
self.is_urgent
|
||||
}
|
||||
|
||||
fn set_activated(&mut self, active: bool) {
|
||||
let changed = self.toplevel().with_pending_state(|state| {
|
||||
if active {
|
||||
@@ -981,12 +1005,12 @@ impl LayoutElement for Mapped {
|
||||
// If is_pending_windowed_fullscreen changed compared to the last value that we "sent"
|
||||
// to the window, store the configure serial.
|
||||
let last_sent_windowed_fullscreen = self
|
||||
.uncommited_windowed_fullscreen
|
||||
.uncommitted_windowed_fullscreen
|
||||
.last()
|
||||
.map(|(_, value)| *value)
|
||||
.unwrap_or(self.is_windowed_fullscreen);
|
||||
if last_sent_windowed_fullscreen != self.is_pending_windowed_fullscreen {
|
||||
self.uncommited_windowed_fullscreen
|
||||
self.uncommitted_windowed_fullscreen
|
||||
.push((serial, self.is_pending_windowed_fullscreen));
|
||||
}
|
||||
} else {
|
||||
@@ -1134,7 +1158,7 @@ impl LayoutElement for Mapped {
|
||||
}
|
||||
});
|
||||
|
||||
// Make sure we recieve a commit later to update self.is_windowed_fullscreen.
|
||||
// Make sure we receive a commit later to update self.is_windowed_fullscreen.
|
||||
self.needs_configure = true;
|
||||
}
|
||||
|
||||
@@ -1204,7 +1228,7 @@ impl LayoutElement for Mapped {
|
||||
}
|
||||
|
||||
// "Commit" our "acked" pending windowed fullscreen state.
|
||||
self.uncommited_windowed_fullscreen
|
||||
self.uncommitted_windowed_fullscreen
|
||||
.retain_mut(|(serial, value)| {
|
||||
if commit_serial.is_no_older_than(serial) {
|
||||
self.is_windowed_fullscreen = *value;
|
||||
|
||||
+20
-1
@@ -100,7 +100,7 @@ pub struct ResolvedWindowRules {
|
||||
/// Whether to clip this window to its geometry, including the corner radius.
|
||||
pub clip_to_geometry: Option<bool>,
|
||||
|
||||
/// Whether bob this window up and down.
|
||||
/// Whether to bob this window up and down.
|
||||
pub baba_is_float: Option<bool>,
|
||||
|
||||
/// Whether to block out this window from certain render targets.
|
||||
@@ -131,6 +131,13 @@ impl<'a> WindowRef<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_urgent(self) -> bool {
|
||||
match self {
|
||||
WindowRef::Unmapped(_) => false,
|
||||
WindowRef::Mapped(mapped) => mapped.is_urgent(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active_in_column(self) -> bool {
|
||||
match self {
|
||||
WindowRef::Unmapped(_) => true,
|
||||
@@ -184,8 +191,10 @@ impl ResolvedWindowRules {
|
||||
width: None,
|
||||
active_color: None,
|
||||
inactive_color: None,
|
||||
urgent_color: None,
|
||||
active_gradient: None,
|
||||
inactive_gradient: None,
|
||||
urgent_gradient: None,
|
||||
},
|
||||
border: BorderRule {
|
||||
off: false,
|
||||
@@ -193,8 +202,10 @@ impl ResolvedWindowRules {
|
||||
width: None,
|
||||
active_color: None,
|
||||
inactive_color: None,
|
||||
urgent_color: None,
|
||||
active_gradient: None,
|
||||
inactive_gradient: None,
|
||||
urgent_gradient: None,
|
||||
},
|
||||
shadow: ShadowRule {
|
||||
off: false,
|
||||
@@ -209,8 +220,10 @@ impl ResolvedWindowRules {
|
||||
tab_indicator: TabIndicatorRule {
|
||||
active_color: None,
|
||||
inactive_color: None,
|
||||
urgent_color: None,
|
||||
active_gradient: None,
|
||||
inactive_gradient: None,
|
||||
urgent_gradient: None,
|
||||
},
|
||||
draw_border_with_background: None,
|
||||
opacity: None,
|
||||
@@ -427,6 +440,12 @@ fn window_matches(window: WindowRef, role: &XdgToplevelSurfaceRoleAttributes, m:
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(is_urgent) = m.is_urgent {
|
||||
if window.is_urgent() != is_urgent {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(is_active) = m.is_active {
|
||||
// Our "is-active" definition corresponds to the window having a pending Activated state.
|
||||
let pending_activated = server_pending
|
||||
|
||||
@@ -77,6 +77,8 @@ On some systems, Steam will show a fully black window.
|
||||
To fix this, navigate to Settings -> Interface (via Steam's tray icon, or by blindly finding the Steam menu at the top left of the window), then **disable** GPU accelerated rendering in web views.
|
||||
Restart Steam and it should now work fine.
|
||||
|
||||
If you do not want to disable GPU accelerated rendering you can instead try to pass the launch argument `-system-composer` instead.
|
||||
|
||||
Steam notifications don't run through the standard notification daemon and show up as floating windows in the center of the screen.
|
||||
You can move them to a more convenient location by adding a window rule in your niri config:
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ animations {
|
||||
duration-ms 200
|
||||
curve "ease-out-quad"
|
||||
}
|
||||
|
||||
overview-open-close {
|
||||
spring damping-ratio=1.0 stiffness=800 epsilon=0.0001
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -159,7 +163,7 @@ animations {
|
||||
|
||||
##### `custom-shader`
|
||||
|
||||
<sup>Since: 0.1.6, experimental</sup>
|
||||
<sup>Since: 0.1.6</sup>
|
||||
|
||||
You can write a custom shader for drawing the window during an open animation.
|
||||
|
||||
@@ -219,7 +223,7 @@ animations {
|
||||
|
||||
##### `custom-shader`
|
||||
|
||||
<sup>Since: 0.1.6, experimental</sup>
|
||||
<sup>Since: 0.1.6</sup>
|
||||
|
||||
You can write a custom shader for drawing the window during a close animation.
|
||||
|
||||
@@ -315,7 +319,7 @@ animations {
|
||||
|
||||
##### `custom-shader`
|
||||
|
||||
<sup>Since: 0.1.6, experimental</sup>
|
||||
<sup>Since: 0.1.6</sup>
|
||||
|
||||
You can write a custom shader for drawing the window during a resize animation.
|
||||
|
||||
@@ -374,6 +378,20 @@ animations {
|
||||
}
|
||||
```
|
||||
|
||||
#### `overview-open-close`
|
||||
|
||||
<sup>Since: 25.05</sup>
|
||||
|
||||
The open/close zoom animation of the [Overview](./Overview.md).
|
||||
|
||||
```kdl
|
||||
animations {
|
||||
overview-open-close {
|
||||
spring damping-ratio=1.0 stiffness=800 epsilon=0.0001
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Synchronized Animations
|
||||
|
||||
<sup>Since: 0.1.5</sup>
|
||||
|
||||
@@ -4,7 +4,7 @@ Niri has several options that are only useful for debugging, or are experimental
|
||||
They are not meant for normal use.
|
||||
|
||||
> [!CAUTION]
|
||||
> These options are **not** covered by the [config breaking change policy](./Configuration:-Overview.md#breaking-change-policy).
|
||||
> These options are **not** covered by the [config breaking change policy](./Configuration:-Introduction.md#breaking-change-policy).
|
||||
> They can change or stop working at any point with little notice.
|
||||
|
||||
Here are all the options at a glance:
|
||||
@@ -28,7 +28,9 @@ debug {
|
||||
keep-laptop-panel-on-when-lid-is-closed
|
||||
disable-monitor-names
|
||||
strict-new-window-focus-policy
|
||||
honor-xdg-activation-with-invalid-serial
|
||||
honor-xdg-activation-with-invalid-serial
|
||||
skip-cursor-only-updates-during-vrr
|
||||
deactivate-unfocused-windows
|
||||
}
|
||||
|
||||
binds {
|
||||
@@ -155,7 +157,7 @@ debug {
|
||||
|
||||
### `wait-for-frame-completion-in-pipewire`
|
||||
|
||||
<sup>Since: next release</sup>
|
||||
<sup>Since: 25.05</sup>
|
||||
|
||||
Wait until every screencast frame is done rendering before handing it over to PipeWire.
|
||||
|
||||
@@ -258,7 +260,7 @@ debug {
|
||||
|
||||
### `honor-xdg-activation-with-invalid-serial`
|
||||
|
||||
<sup>Since: next release</sup>
|
||||
<sup>Since: 25.05</sup>
|
||||
|
||||
Widely-used clients such as Discord and Telegram make fresh xdg-activation tokens upon clicking on their tray icon or on their notification.
|
||||
Most of the time, these fresh tokens will have invalid serials, because the app needs to be focused to get a valid serial, and if the user clicks on a tray icon or a notification, it is usually because the app *isn't* focused, and the user wants to focus it.
|
||||
@@ -275,6 +277,38 @@ debug {
|
||||
}
|
||||
```
|
||||
|
||||
### `skip-cursor-only-updates-during-vrr`
|
||||
|
||||
<sup>Since: next release</sup>
|
||||
|
||||
Skips redrawing the screen from cursor input while variable refresh rate is active.
|
||||
|
||||
Useful for games where the cursor isn't drawn internally to prevent erratic VRR shifts in response to cursor movement.
|
||||
|
||||
Note that the current implementation has some issues, for example when there's nothing redrawing the screen (like a game), the rendering will appear to completely freeze (since cursor movements won't cause redraws).
|
||||
|
||||
```kdl
|
||||
debug {
|
||||
skip-cursor-only-updates-during-vrr
|
||||
}
|
||||
```
|
||||
|
||||
### `deactivate-unfocused-windows`
|
||||
|
||||
<sup>Since: next release</sup>
|
||||
|
||||
Some clients (notably, Chromium- and Electron-based, like Teams or Slack) erroneously use the Activated xdg window state instead of keyboard focus for things like deciding whether to send notifications for new messages, or for picking where to show an IME popup.
|
||||
Niri keeps the Activated state on unfocused workspaces and invisible tabbed windows (to reduce unwanted animations), surfacing bugs in these applications.
|
||||
|
||||
Set this debug flag to work around these problems.
|
||||
It will cause niri to drop the Activated state for all unfocused windows.
|
||||
|
||||
```kdl
|
||||
debug {
|
||||
deactivate-unfocused-windows
|
||||
}
|
||||
```
|
||||
|
||||
### Key Bindings
|
||||
|
||||
These are not debug options, but rather key bindings.
|
||||
|
||||
@@ -14,6 +14,16 @@ gestures {
|
||||
delay-ms 100
|
||||
max-speed 1500
|
||||
}
|
||||
|
||||
dnd-edge-workspace-switch {
|
||||
trigger-height 50
|
||||
delay-ms 100
|
||||
max-speed 1500
|
||||
}
|
||||
|
||||
hot-corners {
|
||||
// off
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,3 +51,46 @@ gestures {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `dnd-edge-workspace-switch`
|
||||
|
||||
<sup>Since: 25.05</sup>
|
||||
|
||||
Scroll the workspaces up/down when moving the mouse cursor against a monitor edge during drag-and-drop (DnD) while in the overview.
|
||||
Also works on a touchscreen.
|
||||
|
||||
The options are:
|
||||
|
||||
- `trigger-height`: size of the area near the monitor edge that will trigger the scrolling, in logical pixels.
|
||||
- `delay-ms`: delay in milliseconds before the scrolling starts.
|
||||
Avoids unwanted scrolling when dragging things across monitors.
|
||||
- `max-speed`: maximum scrolling speed; 1500 corresponds to one screen height per second.
|
||||
The scrolling speed increases linearly as you move your mouse cursor from `trigger-width` to the very edge of the monitor.
|
||||
|
||||
```kdl
|
||||
gestures {
|
||||
// Increase the trigger area and maximum speed.
|
||||
dnd-edge-workspace-switch {
|
||||
trigger-height 100
|
||||
max-speed 3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `hot-corners`
|
||||
|
||||
<sup>Since: 25.05</sup>
|
||||
|
||||
Put your mouse at the very top-left corner of a monitor to toggle the overview.
|
||||
Also works during drag-and-dropping something.
|
||||
|
||||
`off` disables the hot corners.
|
||||
|
||||
```kdl
|
||||
// Disable the hot corners.
|
||||
gestures {
|
||||
hot-corners {
|
||||
off
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -23,6 +23,7 @@ input {
|
||||
// repeat-delay 600
|
||||
// repeat-rate 25
|
||||
// track-layout "global"
|
||||
numlock
|
||||
}
|
||||
|
||||
touchpad {
|
||||
@@ -38,6 +39,7 @@ input {
|
||||
// scroll-factor 1.0
|
||||
// scroll-method "two-finger"
|
||||
// scroll-button 273
|
||||
// scroll-button-lock
|
||||
// tap-button-map "left-middle-right"
|
||||
// click-method "clickfinger"
|
||||
// left-handed
|
||||
@@ -53,6 +55,7 @@ input {
|
||||
// scroll-factor 1.0
|
||||
// scroll-method "no-scroll"
|
||||
// scroll-button 273
|
||||
// scroll-button-lock
|
||||
// left-handed
|
||||
// middle-emulation
|
||||
}
|
||||
@@ -64,6 +67,7 @@ input {
|
||||
// accel-profile "flat"
|
||||
// scroll-method "on-button-down"
|
||||
// scroll-button 273
|
||||
// scroll-button-lock
|
||||
// left-handed
|
||||
// middle-emulation
|
||||
}
|
||||
@@ -75,6 +79,7 @@ input {
|
||||
// accel-profile "flat"
|
||||
// scroll-method "on-button-down"
|
||||
// scroll-button 273
|
||||
// scroll-button-lock
|
||||
// left-handed
|
||||
// middle-emulation
|
||||
}
|
||||
@@ -138,6 +143,34 @@ input {
|
||||
> }
|
||||
> ```
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> <sup>Since: next release</sup>
|
||||
>
|
||||
> If the `xkb` section is empty (like it is by default), niri will fetch xkb settings from systemd-localed at `org.freedesktop.locale1` over D-Bus.
|
||||
> This way, for example, system installers can dynamically set the niri keyboard layout.
|
||||
> You can see this layout in `localectl` and change it with `localectl set-x11-keymap`, for example:
|
||||
>
|
||||
> ```sh
|
||||
> $ localectl set-x11-keymap "us" "" "colemak_dh_ortho" "compose:ralt,ctrl:nocaps"
|
||||
> $ localectl
|
||||
> System Locale: LANG=en_US.UTF-8
|
||||
> LC_NUMERIC=ru_RU.UTF-8
|
||||
> LC_TIME=ru_RU.UTF-8
|
||||
> LC_MONETARY=ru_RU.UTF-8
|
||||
> LC_PAPER=ru_RU.UTF-8
|
||||
> LC_MEASUREMENT=ru_RU.UTF-8
|
||||
> VC Keymap: us-colemak_dh_ortho
|
||||
> X11 Layout: us
|
||||
> X11 Variant: colemak_dh_ortho
|
||||
> X11 Options: compose:ralt,ctrl:nocaps
|
||||
> ```
|
||||
>
|
||||
> By default, `localectl` will set the TTY keymap to the closest match of the XKB keymap.
|
||||
> You can prevent that with a `--no-convert` flag, for example: `localectl set-x11-keymap --no-convert "us,ru"`.
|
||||
>
|
||||
> These settings are picked up by some other programs too, like GDM.
|
||||
|
||||
When using multiple layouts, niri can remember the current layout globally (the default) or per-window.
|
||||
You can control this with the `track-layout` option.
|
||||
|
||||
@@ -166,6 +199,22 @@ input {
|
||||
}
|
||||
```
|
||||
|
||||
#### Num Lock
|
||||
|
||||
<sup>Since: 25.05</sup>
|
||||
|
||||
Set the `numlock` flag to turn on Num Lock automatically at startup.
|
||||
|
||||
You might want to disable (comment out) `numlock` if you're using a laptop with a keyboard that overlays Num Lock keys on top of regular keys.
|
||||
|
||||
```kdl
|
||||
input {
|
||||
keyboard {
|
||||
numlock
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pointing Devices
|
||||
|
||||
Most settings for the pointing devices are passed directly to libinput.
|
||||
@@ -184,6 +233,7 @@ A few settings are common between `touchpad`, `mouse`, `trackpoint`, and `trackb
|
||||
- `scroll-method`: when to generate scroll events instead of pointer motion events, can be `no-scroll`, `two-finger`, `edge`, or `on-button-down`.
|
||||
The default and supported methods vary depending on the device type.
|
||||
- `scroll-button`: <sup>Since: 0.1.10</sup> the button code used for the `on-button-down` scroll method. You can find it in `libinput debug-events`.
|
||||
- `scroll-button-lock`: <sup>Since: next release</sup> when enabled, the button does not need to be held down. Pressing once engages scrolling, pressing a second time disengages it, and double click acts as single click of the the underlying button.
|
||||
- `left-handed`: if set, changes the device to left-handed mode.
|
||||
- `middle-emulation`: emulate a middle mouse click by pressing left and right mouse buttons at once.
|
||||
|
||||
@@ -192,7 +242,7 @@ Settings specific to `touchpad`s:
|
||||
- `tap`: tap-to-click.
|
||||
- `dwt`: disable-when-typing.
|
||||
- `dwtp`: disable-when-trackpointing.
|
||||
- `drag`: can be `true` or `false`, controls if tap-and-drag is enabled.
|
||||
- `drag`: <sup>Since: 25.05</sup> can be `true` or `false`, controls if tap-and-drag is enabled.
|
||||
- `drag-lock`: <sup>Since: 25.02</sup> if set, lifting the finger off for a short time while dragging will not drop the dragged item. See the [libinput documentation](https://wayland.freedesktop.org/libinput/doc/latest/tapping.html#tap-and-drag).
|
||||
- `tap-button-map`: can be `left-right-middle` or `left-middle-right`, controls which button corresponds to a two-finger tap and a three-finger tap.
|
||||
- `click-method`: can be `button-areas` or `clickfinger`, changes the [click method](https://wayland.freedesktop.org/libinput/doc/latest/clickpad-softbuttons.html).
|
||||
@@ -254,7 +304,7 @@ input {
|
||||
By default, the cursor warps *separately* horizontally and vertically.
|
||||
I.e. if moving the mouse only horizontally is enough to put it inside the newly focused window, then the mouse will move only horizontally, and not vertically.
|
||||
|
||||
<sup>Since: next release</sup> You can customize this with the `mode` property.
|
||||
<sup>Since: 25.05</sup> You can customize this with the `mode` property.
|
||||
|
||||
- `mode="center-xy"`: warps by both X and Y coordinates together.
|
||||
So if the mouse was anywhere outside the newly focused window, it will warp to the center of the window.
|
||||
@@ -309,7 +359,7 @@ input {
|
||||
|
||||
#### `mod-key`, `mod-key-nested`
|
||||
|
||||
<sup>Since: next release</sup>
|
||||
<sup>Since: 25.05</sup>
|
||||
|
||||
Customize the `Mod` key for [key bindings](./Configuration:-Key-Bindings.md).
|
||||
Only valid modifiers are allowed, e.g. `Super`, `Alt`, `Mod3`, `Mod5`, `Ctrl`, `Shift`.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
### Per-Section Documentation
|
||||
|
||||
You can find documentation for various sections of the config on these wiki pages:
|
||||
|
||||
* [`input {}`](./Configuration:-Input.md)
|
||||
* [`output "eDP-1" {}`](./Configuration:-Outputs.md)
|
||||
* [`binds {}`](./Configuration:-Key-Bindings.md)
|
||||
* [`switch-events {}`](./Configuration:-Switch-Events.md)
|
||||
* [`layout {}`](./Configuration:-Layout.md)
|
||||
* [top-level options](./Configuration:-Miscellaneous.md)
|
||||
* [`window-rule {}`](./Configuration:-Window-Rules.md)
|
||||
* [`layer-rule {}`](./Configuration:-Layer-Rules.md)
|
||||
* [`animations {}`](./Configuration:-Animations.md)
|
||||
* [`gestures {}`](./Configuration:-Gestures.md)
|
||||
* [`debug {}`](./Configuration:-Debug-Options.md)
|
||||
|
||||
### Loading
|
||||
|
||||
Niri will load configuration from `$XDG_CONFIG_HOME/niri/config.kdl` or `~/.config/niri/config.kdl`, falling back to `/etc/niri/config.kdl`.
|
||||
If both of these files are missing, niri will create `$XDG_CONFIG_HOME/niri/config.kdl` with the contents of [the default configuration file](https://github.com/YaLTeR/niri/blob/main/resources/default-config.kdl), which are embedded into the niri binary at build time.
|
||||
Please use the default configuration file as the starting point for your custom configuration.
|
||||
|
||||
The configuration is live-reloaded.
|
||||
Simply edit and save the config file, and your changes will be applied.
|
||||
This includes key bindings, output settings like mode, window rules, and everything else.
|
||||
|
||||
You can run `niri validate` to parse the config and see any errors.
|
||||
|
||||
To use a different config file path, pass it in the `--config` or `-c` argument to `niri`.
|
||||
|
||||
You can also set `$NIRI_CONFIG` to the path of the config file.
|
||||
`--config` always takes precedence.
|
||||
If `--config` or `$NIRI_CONFIG` doesn't point to a real file, the config will not be loaded.
|
||||
If `$NIRI_CONFIG` is set to an empty string, it is ignored and the default config location is used instead.
|
||||
|
||||
### Syntax
|
||||
|
||||
The config is written in [KDL].
|
||||
|
||||
#### Comments
|
||||
|
||||
Lines starting with `//` are comments; they are ignored.
|
||||
|
||||
Also, you can put `/-` in front of a section to comment out the entire section:
|
||||
|
||||
```kdl
|
||||
/-output "eDP-1" {
|
||||
// Everything inside here is ignored.
|
||||
// The display won't be turned off
|
||||
// as the whole section is commented out.
|
||||
off
|
||||
}
|
||||
```
|
||||
|
||||
#### Flags
|
||||
|
||||
Toggle options in niri are commonly represented as flags.
|
||||
Writing out the flag enables it, and omitting it or commenting it out disables it.
|
||||
For example:
|
||||
|
||||
```kdl
|
||||
// "Focus follows mouse" is enabled.
|
||||
input {
|
||||
focus-follows-mouse
|
||||
|
||||
// Other settings...
|
||||
}
|
||||
```
|
||||
|
||||
```kdl
|
||||
// "Focus follows mouse" is disabled.
|
||||
input {
|
||||
// focus-follows-mouse
|
||||
|
||||
// Other settings...
|
||||
}
|
||||
```
|
||||
|
||||
#### Sections
|
||||
|
||||
Most sections cannot be repeated. For example:
|
||||
|
||||
```kdl
|
||||
// This is valid: every section appears once.
|
||||
input {
|
||||
keyboard {
|
||||
// ...
|
||||
}
|
||||
|
||||
touchpad {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```kdl,must-fail
|
||||
// This is NOT valid: input section appears twice.
|
||||
input {
|
||||
keyboard {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
touchpad {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Exceptions are, for example, sections that configure different devices by name:
|
||||
|
||||
<!-- NOTE: this may break in the future -->
|
||||
```kdl
|
||||
output "eDP-1" {
|
||||
// ...
|
||||
}
|
||||
|
||||
// This is valid: this section configures a different output.
|
||||
output "HDMI-A-1" {
|
||||
// ...
|
||||
}
|
||||
|
||||
// This is NOT valid: "eDP-1" already appeared above.
|
||||
// It will either throw a config parsing error, or otherwise not work.
|
||||
output "eDP-1" {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Defaults
|
||||
|
||||
Omitting most of the sections of the config file will leave you with the default values for that section.
|
||||
A notable exception is [`binds {}`](./Configuration:-Key-Bindings.md): they do not get filled with defaults, so make sure you do not erase this section.
|
||||
|
||||
### Breaking Change Policy
|
||||
|
||||
As a rule, niri updates should not break existing config files.
|
||||
(For example, the default config from niri v0.1.0 still parses fine on v25.02 as I'm writing this.)
|
||||
|
||||
Exceptions can be made for parsing bugs.
|
||||
For example, niri used to accept multiple binds to the same key, but this was not intended and did not do anything (the first bind was always used).
|
||||
A patch release changed niri from silently accepting this to causing a parsing failure.
|
||||
This is not a blanket rule, I will consider the potential impact of every breaking change like this before deciding to carry on with it.
|
||||
|
||||
Keep in mind that the breaking change policy applies only to niri releases.
|
||||
Commits between releases can and do occasionally break the config as new features are ironed out.
|
||||
However, I do try to limit these, since several people are running git builds.
|
||||
|
||||
[KDL]: https://kdl.dev/
|
||||
@@ -31,7 +31,7 @@ Valid modifiers are:
|
||||
This way, you can test niri in a window without causing too many conflicts with the host compositor's key bindings.
|
||||
For this reason, most of the default keys use the `Mod` modifier.
|
||||
|
||||
<sup>Since: next release</sup> You can customize the `Mod` key [in the `input` section of the config](./Configuration:-Input.md#mod-key-mod-key-nested).
|
||||
<sup>Since: 25.05</sup> You can customize the `Mod` key [in the `input` section of the config](./Configuration:-Input.md#mod-key-mod-key-nested).
|
||||
|
||||
> [!TIP]
|
||||
> To find an XKB name for a particular key, you may use a program like [`wev`](https://git.sr.ht/~sircmpwn/wev).
|
||||
@@ -52,6 +52,15 @@ For this reason, most of the default keys use the `Mod` modifier.
|
||||
>
|
||||
> Here, look at `sym: Left` and `sym: Right`: these are the key names.
|
||||
> I was pressing the left and the right arrow in this example.
|
||||
>
|
||||
> Keep in mind that binding shifted keys requires spelling out Shift and the unshifted version of the key, according to your XKB layout.
|
||||
> For example, on the US QWERTY layout, <kbd><</kbd> is on <kbd>Shift</kbd> + <kbd>,</kbd>, so to bind it, you spell out something like `Mod+Shift+Comma`.
|
||||
>
|
||||
> As another example, if you've configured the French [BÉPO](https://en.wikipedia.org/wiki/B%C3%89PO) XKB layout, your <kbd><</kbd> is on <kbd>AltGr</kbd> + <kbd>«</kbd>.
|
||||
> <kbd>AltGr</kbd> is `ISO_Level3_Shift`, or equivalently `Mod5`, so to bind it, you spell out something like `Mod+Mod5+guillemotleft`.
|
||||
>
|
||||
> When resolving latin keys, niri will search for the *first* configured XKB layout that has the latin key.
|
||||
> So for example with US QWERTY and RU layouts configured, US QWERTY will be used for latin binds.
|
||||
|
||||
<sup>Since: 0.1.8</sup> Binds will repeat by default (i.e. holding down a bind will make it trigger repeatedly).
|
||||
You can disable that for specific binds with `repeat=false`:
|
||||
@@ -328,7 +337,7 @@ binds {
|
||||
|
||||
In the interactive screenshot UI, pressing <kbd>Ctrl</kbd><kbd>C</kbd> will copy the screenshot to the clipboard without writing it to disk.
|
||||
|
||||
<sup>Since: next release</sup> You can hide the mouse pointer in screenshots with the `show-pointer=false` property:
|
||||
<sup>Since: 25.05</sup> You can hide the mouse pointer in screenshots with the `show-pointer=false` property:
|
||||
|
||||
```kdl
|
||||
binds {
|
||||
|
||||
@@ -32,6 +32,8 @@ layer-rule {
|
||||
}
|
||||
|
||||
geometry-corner-radius 12
|
||||
place-within-backdrop true
|
||||
baba-is-float true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -129,7 +131,7 @@ That is, enabling shadows in the layout config section won't automatically enabl
|
||||
// Add a shadow for fuzzel.
|
||||
layer-rule {
|
||||
match namespace="^launcher$"
|
||||
|
||||
|
||||
shadow {
|
||||
on
|
||||
}
|
||||
@@ -149,6 +151,43 @@ This setting will only affect the shadow—it will round its corners to match th
|
||||
|
||||
```kdl
|
||||
layer-rule {
|
||||
match namespace="^launcher$"
|
||||
|
||||
geometry-corner-radius 12
|
||||
}
|
||||
```
|
||||
|
||||
#### `place-within-backdrop`
|
||||
|
||||
<sup>Since: 25.05</sup>
|
||||
|
||||
Set to `true` to place the surface into the backdrop visible in the [Overview](./Overview.md) and between workspaces.
|
||||
|
||||
This will only work for *background* layer surfaces that ignore exclusive zones (typical for wallpaper tools).
|
||||
Layers within the backdrop will ignore all input.
|
||||
|
||||
```kdl
|
||||
// Put swaybg inside the overview backdrop.
|
||||
layer-rule {
|
||||
match namespace="^wallpaper$"
|
||||
|
||||
place-within-backdrop true
|
||||
}
|
||||
```
|
||||
|
||||
#### `baba-is-float`
|
||||
|
||||
<sup>Since: 25.05</sup>
|
||||
|
||||
Make your layer surfaces FLOAT up and down.
|
||||
|
||||
This is a natural extension of the [April Fools' 2025 feature](./Configuration:-Window-Rules.md#baba-is-float).
|
||||
|
||||
```kdl
|
||||
// Make fuzzel FLOAT.
|
||||
layer-rule {
|
||||
match namespace="^launcher$"
|
||||
|
||||
baba-is-float true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -11,6 +11,7 @@ layout {
|
||||
always-center-single-column
|
||||
empty-workspace-above-first
|
||||
default-column-display "tabbed"
|
||||
background-color "#003300"
|
||||
|
||||
preset-column-widths {
|
||||
proportion 0.33333
|
||||
@@ -31,8 +32,10 @@ layout {
|
||||
width 4
|
||||
active-color "#7fc8ff"
|
||||
inactive-color "#505050"
|
||||
urgent-color "#9b0000"
|
||||
// active-gradient from="#80c8ff" to="#bbddff" angle=45
|
||||
// inactive-gradient from="#505050" to="#808080" angle=45 relative-to="workspace-view"
|
||||
// urgent-gradient from="#800" to="#a33" angle=45
|
||||
}
|
||||
|
||||
border {
|
||||
@@ -40,8 +43,10 @@ layout {
|
||||
width 4
|
||||
active-color "#ffc87f"
|
||||
inactive-color "#505050"
|
||||
urgent-color "#9b0000"
|
||||
// active-gradient from="#ffbb66" to="#ffc880" angle=45 relative-to="workspace-view"
|
||||
// inactive-gradient from="#505050" to="#808080" angle=45 relative-to="workspace-view" in="srgb-linear"
|
||||
// urgent-gradient from="#800" to="#a33" angle=45
|
||||
}
|
||||
|
||||
shadow {
|
||||
@@ -66,8 +71,10 @@ layout {
|
||||
corner-radius 8
|
||||
active-color "red"
|
||||
inactive-color "gray"
|
||||
urgent-color "blue"
|
||||
// active-gradient from="#80c8ff" to="#bbddff" angle=45
|
||||
// inactive-gradient from="#505050" to="#808080" angle=45 relative-to="workspace-view"
|
||||
// urgent-gradient from="#800" to="#a33" angle=45
|
||||
}
|
||||
|
||||
insert-hint {
|
||||
@@ -269,6 +276,9 @@ layout {
|
||||
active-color "#ffc87f"
|
||||
inactive-color "#505050"
|
||||
|
||||
// Color of the border around windows that request your attention.
|
||||
urgent-color "#9b0000"
|
||||
|
||||
// active-gradient from="#ffbb66" to="#ffc880" angle=45 relative-to="workspace-view"
|
||||
// inactive-gradient from="#505050" to="#808080" angle=45 relative-to="workspace-view" in="srgb-linear"
|
||||
}
|
||||
@@ -372,7 +382,7 @@ Set `on` to enable the shadow.
|
||||
Setting `softness 0` will give you hard shadows.
|
||||
|
||||
`spread` is the distance to expand the window rectangle in logical pixels, same as CSS box-shadow spread.
|
||||
<sup>Since: next release</sup> Spread can be negative.
|
||||
<sup>Since: 25.05</sup> Spread can be negative.
|
||||
|
||||
`offset` moves the shadow relative to the window in logical pixels, same as CSS box-shadow offset.
|
||||
For example, `offset x=2 y=2` will move the shadow 2 logical pixels downwards and to the right.
|
||||
@@ -440,7 +450,7 @@ It can be `left`, `right`, `top`, or `bottom`.
|
||||
`corner-radius` sets the rounded corner radius for tabs in the indicator in logical pixels.
|
||||
When `gaps-between-tabs` is zero, only the first and the last tabs have rounded corners, otherwise all tabs do.
|
||||
|
||||
`active-color`, `inactive-color`, `active-gradient`, `inactive-gradient` let you override the colors for the tabs.
|
||||
`active-color`, `inactive-color`, `urgent-color`, `active-gradient`, `inactive-gradient`, `urgent-gradient` let you override the colors for the tabs.
|
||||
They have the same semantics as the border and focus ring colors and gradients.
|
||||
|
||||
Tab colors are picked in this order:
|
||||
@@ -526,3 +536,18 @@ layout {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `background-color`
|
||||
|
||||
<sup>Since: 25.05</sup>
|
||||
|
||||
Set the default background color that niri draws for workspaces.
|
||||
This is visible when you're not using any background tools like swaybg.
|
||||
|
||||
```kdl
|
||||
layout {
|
||||
background-color "#003300"
|
||||
}
|
||||
```
|
||||
|
||||
You can also set the color per-output [in the output config](./Configuration:-Outputs.md#background-color).
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
### Overview
|
||||
|
||||
This page documents all top-level options that don't otherwise have dedicated pages.
|
||||
|
||||
Here are all of these options at a glance:
|
||||
@@ -25,12 +23,31 @@ cursor {
|
||||
hide-after-inactive-ms 1000
|
||||
}
|
||||
|
||||
overview {
|
||||
zoom 0.5
|
||||
backdrop-color "#262626"
|
||||
|
||||
workspace-shadow {
|
||||
// off
|
||||
softness 40
|
||||
spread 10
|
||||
offset x=0 y=10
|
||||
color "#00000050"
|
||||
}
|
||||
}
|
||||
|
||||
xwayland-satellite {
|
||||
// off
|
||||
path "xwayland-satellite"
|
||||
}
|
||||
|
||||
clipboard {
|
||||
disable-primary
|
||||
}
|
||||
|
||||
hotkey-overlay {
|
||||
skip-at-startup
|
||||
hide-not-bound
|
||||
}
|
||||
```
|
||||
|
||||
@@ -143,6 +160,80 @@ cursor {
|
||||
}
|
||||
```
|
||||
|
||||
### `overview`
|
||||
|
||||
<sup>Since: 25.05</sup>
|
||||
|
||||
Settings for the [Overview](./Overview.md).
|
||||
|
||||
#### `zoom`
|
||||
|
||||
Control how much the workspaces zoom out in the overview.
|
||||
`zoom` ranges from 0 to 0.75 where lower values make everything smaller.
|
||||
|
||||
```kdl
|
||||
// Make workspaces four times smaller than normal in the overview.
|
||||
overview {
|
||||
zoom 0.25
|
||||
}
|
||||
```
|
||||
|
||||
#### `backdrop-color`
|
||||
|
||||
Set the backdrop color behind workspaces in the overview.
|
||||
The backdrop is also visible between workspaces when switching.
|
||||
|
||||
The alpha channel for this color will be ignored.
|
||||
|
||||
```kdl
|
||||
// Make the backdrop light.
|
||||
overview {
|
||||
backdrop-color "#777777"
|
||||
}
|
||||
```
|
||||
|
||||
You can also set the color per-output [in the output config](./Configuration:-Outputs.md#backdrop-color).
|
||||
|
||||
#### `workspace-shadow`
|
||||
|
||||
Control the shadow behind workspaces visible in the overview.
|
||||
|
||||
Settings here mirror the normal [`shadow` config in the layout section](./Configuration:-Layout.md#shadow), so check the documentation there.
|
||||
|
||||
Workspace shadows are configured for a workspace size normalized to 1080 pixels tall, then zoomed out together with the workspace.
|
||||
Practically, this means that you'll want bigger spread, offset, and softness compared to window shadows.
|
||||
|
||||
```kdl
|
||||
// Disable workspace shadows in the overview.
|
||||
overview {
|
||||
workspace-shadow {
|
||||
off
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `xwayland-satellite`
|
||||
|
||||
<sup>Since: next release</sup>
|
||||
|
||||
Settings for integration with [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite).
|
||||
|
||||
When a recent enough xwayland-satellite is detected, niri will create the X11 sockets and set `DISPLAY`, then automatically spawn `xwayland-satellite` when an X11 client tries to connect.
|
||||
If Xwayland dies, niri will keep watching the X11 socket and restart `xwayland-satellite` as needed.
|
||||
This is very similar to how built-in Xwayland works in other compositors.
|
||||
|
||||
`off` disables the integration: niri won't create an X11 socket and won't set the `DISPLAY` environment variable.
|
||||
|
||||
`path` sets the path to the `xwayland-satellite` binary.
|
||||
By default, it's just `xwayland-satellite`, so it's looked up like any other non-absolute program name.
|
||||
|
||||
```kdl
|
||||
// Use a custom build of xwayland-satellite.
|
||||
xwayland-satellite {
|
||||
path "~/source/rs/xwayland-satellite/target/release/xwayland-satellite"
|
||||
}
|
||||
```
|
||||
|
||||
### `clipboard`
|
||||
|
||||
<sup>Since: 25.02</sup>
|
||||
@@ -162,6 +253,8 @@ clipboard {
|
||||
|
||||
Settings for the "Important Hotkeys" overlay.
|
||||
|
||||
#### `skip-at-startup`
|
||||
|
||||
Set the `skip-at-startup` flag if you don't want to see the hotkey help at niri startup.
|
||||
|
||||
```kdl
|
||||
@@ -170,4 +263,17 @@ hotkey-overlay {
|
||||
}
|
||||
```
|
||||
|
||||
#### `hide-not-bound`
|
||||
|
||||
<sup>Since: next release</sup>
|
||||
|
||||
By default, niri will show the most important actions even if they aren't bound to any key, to prevent confusion.
|
||||
Set the `hide-not-bound` flag if you want to hide all actions not bound to any key.
|
||||
|
||||
```kdl
|
||||
hotkey-overlay {
|
||||
hide-not-bound
|
||||
}
|
||||
```
|
||||
|
||||
You can customize which binds the hotkey overlay shows using the [`hotkey-overlay-title` property](./Configuration:-Key-Bindings.md#custom-hotkey-overlay-titles).
|
||||
|
||||
@@ -15,6 +15,7 @@ output "eDP-1" {
|
||||
variable-refresh-rate // on-demand=true
|
||||
focus-at-startup
|
||||
background-color "#003300"
|
||||
backdrop-color "#001100"
|
||||
}
|
||||
|
||||
output "HDMI-A-1" {
|
||||
@@ -167,7 +168,7 @@ output "HDMI-A-1" {
|
||||
|
||||
### `focus-at-startup`
|
||||
|
||||
<sup>Since: next release</sup>
|
||||
<sup>Since: 25.05</sup>
|
||||
|
||||
Focus this output by default when niri starts.
|
||||
|
||||
@@ -191,13 +192,28 @@ output "DP-2" {
|
||||
|
||||
<sup>Since: 0.1.8</sup>
|
||||
|
||||
Set the background color that niri draws for this output.
|
||||
Set the background color that niri draws for workspaces on this output.
|
||||
This is visible when you're not using any background tools like swaybg.
|
||||
|
||||
The alpha channel for this color will be ignored.
|
||||
<sup>Until: 25.05</sup> The alpha channel for this color will be ignored.
|
||||
|
||||
```kdl
|
||||
output "HDMI-A-1" {
|
||||
background-color "#003300"
|
||||
}
|
||||
```
|
||||
|
||||
### `backdrop-color`
|
||||
|
||||
<sup>Since: 25.05</sup>
|
||||
|
||||
Set the backdrop color that niri draws for this output.
|
||||
This is visible between workspaces or in the overview.
|
||||
|
||||
The alpha channel for this color will be ignored.
|
||||
|
||||
```kdl
|
||||
output "HDMI-A-1" {
|
||||
backdrop-color "#001100"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,150 +1 @@
|
||||
### Per-Section Documentation
|
||||
|
||||
You can find documentation for various sections of the config on these wiki pages:
|
||||
|
||||
* [`input {}`](./Configuration:-Input.md)
|
||||
* [`output "eDP-1" {}`](./Configuration:-Outputs.md)
|
||||
* [`binds {}`](./Configuration:-Key-Bindings.md)
|
||||
* [`switch-events {}`](./Configuration:-Switch-Events.md)
|
||||
* [`layout {}`](./Configuration:-Layout.md)
|
||||
* [top-level options](./Configuration:-Miscellaneous.md)
|
||||
* [`window-rule {}`](./Configuration:-Window-Rules.md)
|
||||
* [`layer-rule {}`](./Configuration:-Layer-Rules.md)
|
||||
* [`animations {}`](./Configuration:-Animations.md)
|
||||
* [`gestures {}`](./Configuration:-Gestures.md)
|
||||
* [`debug {}`](./Configuration:-Debug-Options.md)
|
||||
|
||||
### Loading
|
||||
|
||||
Niri will load configuration from `$XDG_CONFIG_HOME/niri/config.kdl` or `~/.config/niri/config.kdl`, falling back to `/etc/niri/config.kdl`.
|
||||
If both of these files are missing, niri will create `$XDG_CONFIG_HOME/niri/config.kdl` with the contents of [the default configuration file](https://github.com/YaLTeR/niri/blob/main/resources/default-config.kdl), which are embedded into the niri binary at build time.
|
||||
Please use the default configuration file as the starting point for your custom configuration.
|
||||
|
||||
The configuration is live-reloaded.
|
||||
Simply edit and save the config file, and your changes will be applied.
|
||||
This includes key bindings, output settings like mode, window rules, and everything else.
|
||||
|
||||
You can run `niri validate` to parse the config and see any errors.
|
||||
|
||||
To use a different config file path, pass it in the `--config` or `-c` argument to `niri`.
|
||||
|
||||
You can also set `$NIRI_CONFIG` to the path of the config file.
|
||||
`--config` always takes precedence.
|
||||
If `--config` or `$NIRI_CONFIG` doesn't point to a real file, the config will not be loaded.
|
||||
If `$NIRI_CONFIG` is set to an empty string, it is ignored and the default config location is used instead.
|
||||
|
||||
### Syntax
|
||||
|
||||
The config is written in [KDL].
|
||||
|
||||
#### Comments
|
||||
|
||||
Lines starting with `//` are comments; they are ignored.
|
||||
|
||||
Also, you can put `/-` in front of a section to comment out the entire section:
|
||||
|
||||
```kdl
|
||||
/-output "eDP-1" {
|
||||
// Everything inside here is ignored.
|
||||
// The display won't be turned off
|
||||
// as the whole section is commented out.
|
||||
off
|
||||
}
|
||||
```
|
||||
|
||||
#### Flags
|
||||
|
||||
Toggle options in niri are commonly represented as flags.
|
||||
Writing out the flag enables it, and omitting it or commenting it out disables it.
|
||||
For example:
|
||||
|
||||
```kdl
|
||||
// "Focus follows mouse" is enabled.
|
||||
input {
|
||||
focus-follows-mouse
|
||||
|
||||
// Other settings...
|
||||
}
|
||||
```
|
||||
|
||||
```kdl
|
||||
// "Focus follows mouse" is disabled.
|
||||
input {
|
||||
// focus-follows-mouse
|
||||
|
||||
// Other settings...
|
||||
}
|
||||
```
|
||||
|
||||
#### Sections
|
||||
|
||||
Most sections cannot be repeated. For example:
|
||||
|
||||
```kdl
|
||||
// This is valid: every section appears once.
|
||||
input {
|
||||
keyboard {
|
||||
// ...
|
||||
}
|
||||
|
||||
touchpad {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```kdl,must-fail
|
||||
// This is NOT valid: input section appears twice.
|
||||
input {
|
||||
keyboard {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
touchpad {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Exceptions are, for example, sections that configure different devices by name:
|
||||
|
||||
<!-- NOTE: this may break in the future -->
|
||||
```kdl
|
||||
output "eDP-1" {
|
||||
// ...
|
||||
}
|
||||
|
||||
// This is valid: this section configures a different output.
|
||||
output "HDMI-A-1" {
|
||||
// ...
|
||||
}
|
||||
|
||||
// This is NOT valid: "eDP-1" already appeared above.
|
||||
// It will either throw a config parsing error, or otherwise not work.
|
||||
output "eDP-1" {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Defaults
|
||||
|
||||
Omitting most of the sections of the config file will leave you with the default values for that section.
|
||||
A notable exception is [`binds {}`](./Configuration:-Key-Bindings.md): they do not get filled with defaults, so make sure you do not erase this section.
|
||||
|
||||
### Breaking Change Policy
|
||||
|
||||
As a rule, niri updates should not break existing config files.
|
||||
(For example, the default config from niri v0.1.0 still parses fine on v25.02 as I'm writing this.)
|
||||
|
||||
Exceptions can be made for parsing bugs.
|
||||
For example, niri used to accept multiple binds to the same key, but this was not intended and did not do anything (the first bind was always used).
|
||||
A patch release changed niri from silently accepting this to causing a parsing failure.
|
||||
This is not a blanket rule, I will consider the potential impact of every breaking change like this before deciding to carry on with it.
|
||||
|
||||
Keep in mind that the breaking change policy applies only to niri releases.
|
||||
Commits between releases can and do occasionally break the config as new features are ironed out.
|
||||
However, I do try to limit these, since several people are running git builds.
|
||||
|
||||
[KDL]: https://kdl.dev/
|
||||
This wiki page has moved to: [Introduction](./Configuration:-Introduction.md).
|
||||
|
||||
@@ -35,6 +35,7 @@ window-rule {
|
||||
match is-active-in-column=true
|
||||
match is-floating=true
|
||||
match is-window-cast-target=true
|
||||
match is-urgent=true
|
||||
match at-startup=true
|
||||
|
||||
// Properties that apply once upon window opening.
|
||||
@@ -63,8 +64,10 @@ window-rule {
|
||||
width 4
|
||||
active-color "#7fc8ff"
|
||||
inactive-color "#505050"
|
||||
urgent-color "#9b0000"
|
||||
// active-gradient from="#80c8ff" to="#bbddff" angle=45
|
||||
// inactive-gradient from="#505050" to="#808080" angle=45 relative-to="workspace-view"
|
||||
// urgent-gradient from="#800" to="#a33" angle=45
|
||||
}
|
||||
|
||||
border {
|
||||
@@ -85,8 +88,10 @@ window-rule {
|
||||
tab-indicator {
|
||||
active-color "red"
|
||||
inactive-color "gray"
|
||||
urgent-color "blue"
|
||||
// active-gradient from="#80c8ff" to="#bbddff" angle=45
|
||||
// inactive-gradient from="#505050" to="#808080" angle=45 relative-to="workspace-view"
|
||||
// urgent-gradient from="#800" to="#a33" angle=45
|
||||
}
|
||||
|
||||
geometry-corner-radius 12
|
||||
@@ -282,6 +287,19 @@ Example:
|
||||
|
||||

|
||||
|
||||
#### `is-urgent`
|
||||
|
||||
<sup>Since: 25.05</sup>
|
||||
|
||||
Can be `true` or `false`.
|
||||
Matches windows that request the user's attention.
|
||||
|
||||
```kdl
|
||||
window-rule {
|
||||
match is-urgent=true
|
||||
}
|
||||
```
|
||||
|
||||
#### `at-startup`
|
||||
|
||||
<sup>Since: 0.1.6</sup>
|
||||
@@ -829,7 +847,7 @@ window-rule {
|
||||
|
||||
#### `tiled-state`
|
||||
|
||||
<sup>Since: next release</sup>
|
||||
<sup>Since: 25.05</sup>
|
||||
|
||||
Informs the window that it is tiled.
|
||||
Usually, windows will react by becoming rectangular and hiding their client-side shadows.
|
||||
|
||||
@@ -2,24 +2,21 @@ When starting niri from a display manager like GDM, or otherwise through the `ni
|
||||
This provides the necessary systemd integration to run programs like `mako` and services like `xdg-desktop-portal` bound to the graphical session.
|
||||
|
||||
Here's an example on how you might set up [`mako`](https://github.com/emersion/mako), [`waybar`](https://github.com/Alexays/Waybar), [`swaybg`](https://github.com/swaywm/swaybg) and [`swayidle`](https://github.com/swaywm/swayidle) to run as systemd services with niri.
|
||||
In contrast to [`spawn-at-startup`](./Configuration:-Miscellaneous.md#spawn-at-startup), this lets you easily monitor their status and output, and restart or reload them.
|
||||
Unlike [`spawn-at-startup`](./Configuration:-Miscellaneous.md#spawn-at-startup), this lets you easily monitor their status and output, and restart or reload them.
|
||||
|
||||
1. Install them, i.e. `sudo dnf install mako waybar swaybg swayidle`
|
||||
2. Create a `niri.service.wants` folder: `mkdir -p ~/.config/systemd/user/niri.service.wants`
|
||||
|
||||
This is a special systemd folder.
|
||||
Any services linked there will be started together with `niri.service` (which is a systemd unit used by niri when running as a session).
|
||||
|
||||
3. `mako` and `waybar` provide systemd units out of the box, so you can simply symlink them into the `niri.service.wants` folder:
|
||||
2. `mako` and `waybar` provide systemd units out of the box, so you can simply add them to the niri session:
|
||||
|
||||
```
|
||||
ln -s /usr/lib/systemd/user/mako.service ~/.config/systemd/user/niri.service.wants/
|
||||
ln -s /usr/lib/systemd/user/waybar.service ~/.config/systemd/user/niri.service.wants/
|
||||
systemctl --user add-wants niri.service mako.service
|
||||
systemctl --user add-wants niri.service waybar.service
|
||||
```
|
||||
|
||||
4. `swaybg` does not provide a systemd unit, since you need to pass the background image as a command-line argument.
|
||||
This will create links in `~/.config/systemd/user/niri.service.wants/`, a special systemd folder for services that need to start together with `niri.service`.
|
||||
|
||||
3. `swaybg` does not provide a systemd unit, since you need to pass the background image as a command-line argument.
|
||||
So we will make our own.
|
||||
Put the following into `~/.config/systemd/user/swaybg.service`:
|
||||
Create `~/.config/systemd/user/swaybg.service` with the following contents:
|
||||
|
||||
```
|
||||
[Unit]
|
||||
@@ -37,13 +34,14 @@ In contrast to [`spawn-at-startup`](./Configuration:-Miscellaneous.md#spawn-at-s
|
||||
|
||||
After editing `swaybg.service`, run `systemctl --user daemon-reload` so systemd picks up the changes in the file.
|
||||
|
||||
Now, also symlink this to `niri.service.wants`:
|
||||
Now, add it to the niri session:
|
||||
|
||||
```
|
||||
ln -s ~/.config/systemd/user/swaybg.service ~/.config/systemd/user/niri.service.wants/
|
||||
systemctl --user add-wants niri.service swaybg.service
|
||||
```
|
||||
|
||||
5. `swayidle` similarly does not provide a service so we will also make our own. Put the following into `~/.config/systemd/user/swayidle.service`:
|
||||
4. `swayidle` similarly does not provide a service, so we will also make our own.
|
||||
Create `~/.config/systemd/user/swayidle.service` with the following contents:
|
||||
|
||||
```
|
||||
[Unit]
|
||||
@@ -56,20 +54,23 @@ In contrast to [`spawn-at-startup`](./Configuration:-Miscellaneous.md#spawn-at-s
|
||||
Restart=on-failure
|
||||
```
|
||||
|
||||
Then, run `systemctl --user daemon-reload` and symlink this file to `niri.service.wants`:
|
||||
Then, run `systemctl --user daemon-reload` and add it to the niri session:
|
||||
|
||||
```
|
||||
ln -s ~/.config/systemd/user/swayidle.service ~/.config/systemd/user/niri.service.wants/
|
||||
systemctl --user add-wants niri.service swayidle.service
|
||||
```
|
||||
|
||||
That's it!
|
||||
Now these three utilities will be started together with the niri session and stopped when it exits.
|
||||
You can also restart them with a command like `systemctl --user restart waybar.service`, for example after editing their config files.
|
||||
|
||||
To remove a service from niri startup, remove its symbolic link from `~/.config/systemd/user/niri.service.wants/`.
|
||||
Then, run `systemctl --user daemon-reload`.
|
||||
|
||||
### Running Programs Across Logout
|
||||
|
||||
When running niri as a session, exiting it (logging out) will kill all programs that you've started within. However, sometimes you want a program, like `tmux`, `dtach` or similar, to persist in this case. To do this, run it in a transient systemd scope:
|
||||
|
||||
```
|
||||
systemd-run --user --scope tmux new-session
|
||||
```
|
||||
```
|
||||
|
||||
+18
-1
@@ -6,7 +6,7 @@ Then niri will ask windows to omit client-side decorations, and also inform them
|
||||
Note that currently this will prevent edge window resize handles from showing up.
|
||||
You can still resize windows by holding <kbd>Mod</kbd> and the right mouse button.
|
||||
|
||||
### Why is the border/focus ring showing up through semitransparent windows?
|
||||
### Why are transparent windows tinted? / Why is the border/focus ring showing up through semitransparent windows?
|
||||
|
||||
Uncomment the [`prefer-no-csd` setting](./Configuration:-Miscellaneous.md#prefer-no-csd) at the top level of the config, and then restart your apps.
|
||||
Niri will draw focus rings and borders *around* windows that agree to omit their client-side decorations.
|
||||
@@ -46,3 +46,20 @@ To run X11 apps, you can use [xwayland-satellite](https://github.com/Supreeeme/x
|
||||
Check [the Xwayland wiki page](./Xwayland.md) for instructions.
|
||||
|
||||
Keep in mind that you can run many Electron apps such as VSCode natively on Wayland by passing the right flags, e.g. `code --ozone-platform-hint=auto`
|
||||
|
||||
### Why doesn't niri integrate Xwayland like other compositors?
|
||||
|
||||
A combination of factors:
|
||||
|
||||
- Integrating Xwayland is quite a bit of work, as the compositor needs to implement parts of an X11 window manager.
|
||||
- You need to appease the X11 ideas of windowing, whereas for niri I want to have the best code for Wayland.
|
||||
- niri doesn't have a good global coordinate system required by X11.
|
||||
- You tend to get an endless stream of X11 bugs that take further time and effort away from other tasks.
|
||||
- There aren't actually that many X11-only clients nowadays, and xwayland-satellite takes perfect care of most of those.
|
||||
- niri isn't a Big Serious Desktop Environment which Must Support All Use Cases (and is Backed By Some Corporation).
|
||||
|
||||
All in all, the situation works out in favor of avoiding Xwayland integration.
|
||||
|
||||
Also, in the next release niri will have seamless built-in xwayland-satellite integration, that will solve the big rough edge of having to set it up manually.
|
||||
|
||||
Besides, I wouldn't be too surprised if, down the road, xwayland-satellite becomes the standard way of integrating Xwayland into new compositors, since it takes on the bulk of the annoying work, and isolates the compositor from misbehaving clients.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user