Compare commits

...

209 Commits

Author SHA1 Message Date
Ivan Molodetskikh ce76877b04 pw_utils: Implement explicit sync
Largely following the Mutter implementation:
https://gitlab.gnome.org/GNOME/mutter/-/merge_requests/3876
2025-07-20 11:24:44 +03:00
Ivan Molodetskikh 62b8b11909 pw_utils: Add clarifying comments on maxsize and size 2025-07-20 09:41:46 +03:00
Ivan Molodetskikh fefc0bc0a7 README: Link LWN article 2025-07-18 23:28:49 +03:00
zimward 0b1a6c76ec ci/alpine: switch to container to not rely on overloaded alpine gitlab 2025-07-18 12:10:47 -07:00
sodiboo 485e667fec block signals early: now handled correctly with tracy ondemand 2025-07-18 11:41:17 -07:00
sodiboo 8f442dee06 refactor signal handling, and clear sigmask before spawning 2025-07-18 11:41:17 -07:00
ジムワルド 9c09bc730f ci: add musl/alpine build (#2065)
* ci: add musl build

* Update .github/workflows/ci.yml

* Update .github/workflows/ci.yml

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-07-17 20:05:36 +00:00
Ivan Molodetskikh 7b065f8618 wiki/Nvidia: Mention screencast flickering fix 2025-07-16 11:57:55 +03:00
hecate cantus 60fbcd2329 Add Nvidia.md leaf file, add links in sidebar & getting started (#2029)
* Add Nvidia.md leaf file, add links in sidebar & getting started

* squash review-commits from gh to one commit

* heap reuse ratio from 1 => 0 to match currently shipped solution

* Update wiki/Nvidia.md

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-07-16 06:06:07 +00:00
Ivan Molodetskikh 5ac440a760 Mention localectl in the docs 2025-07-15 18:38:00 +03:00
Ivan Molodetskikh 0e3d078a85 Implement fetching xkb options from org.freedesktop.locale1 2025-07-15 18:19:11 +03:00
Bloxx12 36efd6e3f9 nix: update flake inputs 2025-07-15 15:55:45 +03:00
Bloxx12 30a9c6c31b nix: replace nix-filter with lib.fileset
Co-authored-by: sodiboo <git@sodi.boo>
2025-07-15 15:55:45 +03:00
Ivan Molodetskikh bc0a06226a niri-session: Also unset DISPLAY
We set it now for xwayland-satellite integration.
2025-07-15 15:54:50 +03:00
sodiboo ed799f5afc revert nushell completion for flake.nix 2025-07-15 14:32:45 +03:00
Ivan Molodetskikh 007d35541d README: Mention Contributing 2025-07-15 10:45:54 +03:00
Ivan Molodetskikh e46a27351d README: Move Media higher up 2025-07-15 10:40:45 +03:00
Ivan Molodetskikh 56901eed5d Print when exiting by signal
Doesn't appear to work at the moment?
2025-07-14 14:58:27 +03:00
Ivan Molodetskikh 48fe08caf4 CONTRIBUTING.md: Mention testing in writing PRs 2025-07-14 14:57:44 +03:00
Horu df00f0328e Register org.freedesktop.ScreenSaver at /ScreenSaver 2025-07-14 14:56:11 +03:00
Ivan Molodetskikh d85eaf9799 Fix LockedHint locked condition 2025-07-14 14:39:57 +03:00
peelz 25cbb739ae Set logind LockedHint on lock/unlock (#1763)
* Set logind LockedHint on lock/unlock

* fixup! Set logind LockedHint on lock/unlock

- use warn!() instead of error!()
- extract dbus call into a separate method

* fixup! Set logind LockedHint on lock/unlock

- Update LockedHint in refresh_and_flush_clients

* fixup! Set logind LockedHint on lock/unlock

woops

* fixup! Set logind LockedHint on lock/unlock

- only call SetLockedHint if niri was run with `--session`

* fixes

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-07-14 11:34:10 +00:00
Vladimir-csp 88339633b1 Detect external session management
This should make `uwsm start niri.desktop` possible like with other compositors.
2025-07-14 13:20:30 +03:00
sodiboo 22e43193e0 handle SIGINT, SIGTERM, SIGHUP 2025-07-14 13:16:10 +03:00
sodiboo 7a2379ad35 don't use smithay::reexports for calloop::EventLoop 2025-07-14 13:16:10 +03:00
Ivan Molodetskikh fe2c2eec29 Add CONTRIBUTING.md 2025-07-14 12:04:36 +03:00
Artrix 746a7e81b7 Add nushell completion support (#2009)
* Add nushell completion support

Adds `clap_complete_nushell` crate and implements it into the `niri
completions` command.

* Add nushell to flake.nix autocompletions

* Convert to `TryFrom`

* Fix linting errors

* Move types down

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-07-14 06:29:26 +00:00
abmantis 51b6a495c5 Simplify pointer handling in constraint check
Minor change so that `get_pointer()` (which has a lock) does not get
called twice. Also moved the call to `current_location()` to the scope
where it is needed.
2025-07-14 06:48:56 +03:00
Ivan Molodetskikh bb40a35ccf wiki/Xwayland: Link FAQ entry with reasons 2025-07-13 17:46:47 +03:00
Ivan Molodetskikh 37c6412e80 wiki/FAQ: Mention reasons for not integrating Xwayland 2025-07-13 17:44:07 +03:00
Sharun 19c8fca836 feat: add hint to disable "Important Hotkeys" in the default config file (#1881)
* feat: add hint to disable "Important Hotkeys" in the default config file

* Update resources/default-config.kdl

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-07-13 11:29:27 +00:00
Lin Xianyi 186e0b608a Fix docs for FocusWindowOrWorkspaceDown
Typo fix for the doc comment
2025-07-13 14:11:17 +03:00
Ivan Molodetskikh ce501bca9e tests: Add layer-shell scaffolding and an overflow test 2025-07-13 12:59:01 +03:00
Ivan Molodetskikh 45e9bb769d Update deps & Smithay (layer-shell overflows fix) 2025-07-13 12:58:52 +03:00
Ivan Molodetskikh dfb3683187 Fix new Clippy warnings 2025-07-13 12:54:03 +03:00
Ivan Molodetskikh ce9ba00d54 Implement ext-workspace 2025-07-13 11:43:59 +03:00
Ivan Molodetskikh 37458d94b2 Bump xcursor version in Cargo.toml too 2025-06-24 21:52:04 +03:00
Ivan Molodetskikh 044f14f8f9 Update xcursor (fixes regression in last update) 2025-06-24 21:47:39 +03:00
Ivan Molodetskikh 4c02f3bba4 Update dependencies 2025-06-23 16:12:45 +03:00
Ivan Molodetskikh b55a80c641 Update Smithay 2025-06-23 16:12:45 +03:00
Nikolay Yakimov e0b0b04b44 Expose libinput Button Scrolling Button Lock Enabled property 2025-06-19 05:05:47 -07:00
Baily ed14e8da84 Fix typos (#1822)
* Fix: Correct typo in xwayland module and update documentation

This commit includes several improvements:

1.  **Code Fix (clippy):**
    - I corrected a typo in `src/utils/xwayland/mod.rs` from `OFlags::WRONGLY` to `OFlags::WRONLY`. This was identified by `clippy` during the build process.

2.  **Documentation Updates:**
    - **README.md**:
        - I clarified the sentence about finding help in the Matrix channel to be more inviting for new users.
        - I corrected a future date typo in the Media section for an interview (June 2025 to June 2024).
    - **wiki/Getting-Started.md**:
        - I changed the Russian month "мая" to "May" in an example output for better international readability.
        - I improved keybinding notation for monitor focus/move keys (e.g., Mod+Shift+H / J / K / L) to avoid ambiguity.
        - I updated `apt-get` to `apt` in Ubuntu dependency installation commands for modern practice.

No new typos were found by `typos-cli` in this pass.

* Revert README&GS.md to previous version

* Apply rustfmt

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-06-18 08:49:47 +03:00
Nicolaos Skimas e53f8527b0 Add backlight adjustment keys to default config (#1824)
* Support backlight adjustment keys in default config

* Update resources/default-config.kdl

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-06-17 06:37:00 +00:00
Ivan Molodetskikh da3dc913a6 README: Link a new video and podcast 2025-06-16 14:59:08 +03:00
Ivan Molodetskikh f3f6e79eec Return app ids with ".desktop" appended to Shell.Introspect
This isn't the correct solution, but it seems to work often enough for window
icons in the screencast dialog.
2025-06-13 09:55:08 +03:00
Ivan Molodetskikh 83ec369536 layout/scrolling: Take unfullscreen view offset unconditionally
It might get set and unset all while the view is frozen with a gesture.
2025-06-13 08:54:59 +03:00
Ivan Molodetskikh 97dfd2b1a0 screenshot_ui: Move selection with a second touch too 2025-06-12 21:17:25 +03:00
Anselm Schüler 730eab09fb default-config.kdl: add repeat=false to close-window 2025-06-12 07:25:31 -07:00
Ivan Molodetskikh a23ce10311 screenshot_ui: Move selection when holding Space 2025-06-12 15:24:49 +03:00
Ivan Molodetskikh 2f18d8e328 Implement move-column/window-to-monitor actions for the screenshot UI 2025-06-12 08:56:52 +03:00
Ivan Molodetskikh 7aec37f5c9 Extract output_left/right/up/down/previous/next_of() 2025-06-11 21:09:55 +03:00
Andrew Davis 07080a0431 Clamp colors to valid values when parsing config
The oklch color space often creates weird values when parsed by csscolorparser.
clamping the output to values between 0 and 1 should fix inconsistent color handling
on borders.
2025-06-11 02:40:36 -07:00
Ivan Molodetskikh aa47223c19 Upgrade deps and Smithay (cursor-shape v2) 2025-06-11 10:21:17 +03:00
Illia Ostapyshyn 10a6d6ae45 Expand screenshot UI to handle move-X-or-to-workspace/monitor-X (#1669)
* Expand screenshot UI to handle more moving actions

Currently, screenshot UI handles MoveColumn{Left,Right} and
MoveWindow{Up,Down} which move the screenshot selection as if it were a
floating window.  Expand this to include MoveColumn*OrToMonitor* and
MoveWindow*OrToWorkspace* and adjust their logic to move the screenshot
selection.

* Update src/input/mod.rs

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-06-11 06:28:03 +00:00
Nathan 7db864d203 Update Example-systemd-Setup.md to use add-wants (#1710)
Instead of hard-linked locations tried to reword to simplify for both XDG-compliance and/or non-compliance. Additionally, replace 'ln ...' references with "add-wants" from systemctl. This advantage is that it creates all the right directories and the only thing the user needs to worry about later is possibly removing a symlink manually.
2025-06-11 06:18:26 +00:00
sashomasho 8d7b22d1a8 Add deactivate-unfocused-windows debug flag (#1706)
* force xdg deactivation on invisable workspaces

This debug option provides a workaround for many Chromium-based chat
applications that fail to show notifications when they're active in
a workspace that's not currently visible and don't have keyboard focus

Signed-off-by: Alex Yosifov <sashomasho@gmail.com>

* fixes

---------

Signed-off-by: Alex Yosifov <sashomasho@gmail.com>
Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-06-11 06:05:14 +00:00
Ivan Molodetskikh 0407ac5e4c Ignore lock surfaces from unrelated clients
gtklock doesn't mind the fact that it got denied the lock, and just creates a
new lock surface anyway. And we happily replace the running lock with it.
2025-06-10 17:03:32 +03:00
Ivan Molodetskikh a18d24fc24 Don't forget to update insta snapshots 2025-06-09 16:13:40 +03:00
Ivan Molodetskikh 2bacc80c93 default-config: Make sample gradients more obvious 2025-06-09 14:40:31 +03:00
Ivan Molodetskikh c91638c12e default-config: Clarify focus-ring inactive-color 2025-06-09 14:27:50 +03:00
Ivan Molodetskikh f8a0c9df2c default-config: Clarify that input settings are not defaults 2025-06-09 14:25:30 +03:00
Ivan Molodetskikh 6bab912383 Accept FloatOrInt for input accel_speed, animation slowdown
Technically cfg-breaking due to introducing min/max limits at parse time, but
values outside these limits were invalid anyway, so maybe it's fine?
2025-06-09 14:04:56 +03:00
Ivan Molodetskikh 3edb8fd906 layout/scrolling: Take parent area into account for popup unconstraining 2025-06-09 13:52:18 +03:00
Ivan Molodetskikh c9b1514d63 layout/scrolling: Store parent_area in ScrollingSpace 2025-06-09 13:43:28 +03:00
Ivan Molodetskikh 2066737024 layout/scrolling: Inline popup_target_rect up to ScrollingSpace 2025-06-09 13:39:55 +03:00
Ivan Molodetskikh f918eabe6a Implement xwayland-satellite integration 2025-06-07 13:12:50 -07:00
Ivan Molodetskikh 0698f167e5 Update generate-rpm version 2025-06-07 23:03:01 +03:00
Ivan Molodetskikh 242ebf2945 wiki: Add Since to hide-not-bound 2025-06-05 11:42:32 +03:00
Ivan Molodetskikh 9858599ac1 Round lock surface size, rather than floor
There's no problem with 1 px overflow here, while 1 px underflow shows up as a
border.
2025-06-04 09:40:22 +03:00
Kent Daleng abac28a65c add option to hide unbound actions in hotkey overlay (#1618)
* add option to hide unbound actions in hotkey overlay

* fix config test, add some docs

* Add kdl language hint

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>

* Improve docs

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>

* hide_unbound -> hide_not_bound

* forgot to rename in wiki

* filter actions before calling format

* use any instead of contains

* retain instead of filter

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-06-03 20:31:18 +03:00
Gwen a7186a0441 Add debug option to skip cursor-only updates while VRR is active (#1616)
* Add debug option to skip cursor-only updates while VRR is active

* Update niri-config/src/lib.rs

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>

* Update src/backend/tty.rs

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>

* Update wiki/Configuration:-Debug-Options.md

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>

* Update Configuration:-Debug-Options.md

* Update tty.rs

* Update lib.rs

* Update Configuration:-Debug-Options.md

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-06-03 15:56:21 +00:00
Ivan Molodetskikh 1911cf3f55 wiki/Xwayland: Remove the scary "experimental" word from xwl-s 2025-06-01 19:22:17 +03:00
Ivan Molodetskikh 09da884cd8 README: Update Configuration link 2025-06-01 09:56:28 +03:00
Ivan Molodetskikh 8ba57fcf25 Bump version to 25.05.1 2025-05-25 08:45:41 +03:00
Ivan Molodetskikh 126ca37d96 Rename Un/Set/ToggleUrgent to Un/Set/ToggleWindowUrgent
Overlooked this when reviewing. This change is not cfg-breaking (since you
can't bind these directly), but it does break calling these actions through
IPC. I don't imagine they are widely used though, and the original PR author
who also implemented urgency for bars said he didn't use these actions either.
2025-05-25 08:43:27 +03:00
Ivan Molodetskikh e6bd60fbb1 wiki: Remove note about numlock issue
It's been fixed.
2025-05-25 08:36:33 +03:00
Ivan Molodetskikh a605a3f016 Account for hidden pointer in move_cursor() 2025-05-23 23:08:51 +03:00
Ivan Molodetskikh ef44adea69 Set pointer contents straight to nothing when disabling pointer 2025-05-23 23:08:51 +03:00
Duncan Overbruck 7fdb918cd0 input: do not revert fully invisible cursor to hidden (#1650)
* input: do not force redraw to hide an already hidden cursor

* more

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-05-23 05:24:24 +00:00
Ivan Molodetskikh 8347cc20dc Update Smithay (pen tilt, num lock, keymap spam) 2025-05-22 18:05:17 +03:00
alex-huff 51a176ec4a layer-shell: only reset 'initial_configure_sent' for mapped surfaces 2025-05-22 08:02:56 -07:00
alex-huff d618daf6b9 layer-shell: don't dismiss popups because of unmapped layer surfaces
Fixes #1640
2025-05-22 07:55:29 -07:00
Ivan Molodetskikh 357f9157cc wiki/packaging: Mention RUN_SLOW_TESTS 2025-05-22 11:37:09 +03:00
Ivan Molodetskikh c4a759e620 wiki/packaging: Document limiting test threads 2025-05-22 11:35:12 +03:00
Ivan Molodetskikh f369a0f810 input: Add missing check for output under 2025-05-22 08:55:01 +03:00
Ivan Molodetskikh 71251a7003 input: Add missing redraws on urgency actions
The layout urgent colors update even without window rule changes.
2025-05-21 19:50:13 +03:00
alex-huff 2415346caa layer-shell: properly handle re-map
According to the zwlr_layer_surface_v1 documentation: Unmapping a
layer_surface means that the surface cannot be shown by the compositor
until it is explicitly mapped again. The layer_surface returns to the
state it had right after layer_shell.get_layer_surface. The client can
re-map the surface by performing a commit without any buffer attached,
waiting for a configure event and handling it as usual.

Before this commit, no configure event was sent when a client performed
a commit without any buffer attached.
2025-05-21 07:25:22 -07:00
Ivan Molodetskikh 3f2b7e63ba Improve comment in on-demand layer-shell keyboard alive check 2025-05-19 09:18:07 +03:00
Ivan Molodetskikh ae89cb6017 Update README.md 2025-05-17 15:59:05 +03:00
Ivan Molodetskikh b6fc4d0455 wiki/Overview: Link the new video 2025-05-17 14:38:24 +03:00
Federico Ceratto d8265ad34e Stop including broken LFS files in source tarball 2025-05-17 04:29:46 -07:00
Ivan Molodetskikh 3b864dc104 Bump version to 25.05 2025-05-17 13:50:36 +03:00
Ivan Molodetskikh 15093221ed wiki/Overview: Update wording 2025-05-17 13:44:55 +03:00
Ivan Molodetskikh ac7b3fbf19 wiki: Link to issue in numlock
https://github.com/YaLTeR/niri/issues/1501
2025-05-17 07:58:52 +03:00
Ivan Molodetskikh bb8eb377c7 Update dependencies more carefully
No winit deadlock in this update.
2025-05-16 22:54:37 +03:00
Ivan Molodetskikh 6169c0312a Revert "Update dependencies"
Something is causing winit deadlock on nested niri exit.

This reverts commit 2ae99224ab.

This reverts commit 0d6843ea67.
2025-05-16 22:53:54 +03:00
Ivan Molodetskikh 2ae99224ab Update dependencies 2025-05-16 22:29:09 +03:00
Ivan Molodetskikh 4f63e13385 Deal with new Clippy warnings 2025-05-16 22:21:14 +03:00
Ivan Molodetskikh 46a8f81160 ipc/client: Make compositor version check for JSON parsing errors
These can happen when adding new fields to returned structs.
2025-05-15 09:08:53 +03:00
Ivan Molodetskikh 0d6843ea67 Update dependencies 2025-05-13 17:13:35 +03:00
Ivan Molodetskikh 6d083ea497 layout: Fix workspace swipe to same workspace forgetting previous id
This manifested much more prominently in the overview.
2025-05-13 08:17:15 +03:00
Ivan Molodetskikh 7a42140d6c dependabot: Change to weekly
Let's see if this fixes it missing from the GitHub UI.
2025-05-12 20:05:33 +03:00
Ivan Molodetskikh eeb411bef5 wiki: Add Since for touchpad drag 2025-05-12 20:05:10 +03:00
Ivan Molodetskikh defd4c5c4d Add center-visible-columns action 2025-05-12 14:13:51 +03:00
dependabot[bot] 7227e64149 build(deps): bump clap in the rust-dependencies group
Bumps the rust-dependencies group with 1 update: [clap](https://github.com/clap-rs/clap).


Updates `clap` from 4.5.37 to 4.5.38
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.37...clap_complete-v4.5.38)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.38
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-12 02:58:56 -07:00
Ivan Molodetskikh c98537a2b0 Implement baba-is-float for layers 2025-05-12 09:10:59 +03:00
Ivan Molodetskikh 9c103f1f1d Add missing "to" in comment 2025-05-12 08:26:39 +03:00
Ivan Molodetskikh 2aff1ec71a ipc/socket: Support multiple requests 2025-05-11 21:51:26 -07:00
Jon Heinritz 3466fc0a66 ipc: document the new socket behavior 2025-05-11 21:51:26 -07:00
Jon Heinritz f917932b3e ipc: support long living sockets 2025-05-11 21:51:26 -07:00
Ivan Molodetskikh 89b7423ee5 Print urgent status in niri msg windows 2025-05-10 23:43:00 +03:00
Ivan Molodetskikh a2efaf2816 Add is-urgent window rule matcher 2025-05-10 22:49:55 +03:00
Ivan Molodetskikh 5816691460 Add urgent color support to tab indicators 2025-05-10 22:42:45 +03:00
Ivan Molodetskikh 4b5e9e6cb0 wiki: Document urgent-color 2025-05-10 22:42:45 +03:00
Duncan Overbruck a8259b4cea add WindowUrgencyChanged ipc event 2025-05-10 12:14:41 -07:00
Duncan Overbruck 9d3d7cb0e9 add {toggle,set,unset}-urgent cli actions 2025-05-10 12:14:41 -07:00
Duncan Overbruck 398bc78ea0 add urgent border color and gradient 2025-05-10 12:14:41 -07:00
Duncan Overbruck caa6189448 add workspace urgency ipc event 2025-05-10 12:14:41 -07:00
Duncan Overbruck 86f57c2ec7 add window urgency through xdg-activation-v1
urgency is done through activation requests without a serial from a
previous interaction.

https://gitlab.freedesktop.org/wayland/wayland-protocols/-/issues/150
2025-05-10 12:14:41 -07:00
Charlie Le 3cc67897af Implement IPC for the overview state (#1526)
* Implement IPC for the overview state

* Update Overview IPC to maintain naming consistency, renamed OverviewToggled to be more clear, simplify overview state request on the server, consolidate ipc refresh

* Fix Overview is_open in IPC client

* Change opened to is_open

* Update niri-ipc/src/lib.rs

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>

* Update niri-ipc/src/state.rs

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>

* Update src/ipc/client.rs

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>

* Update src/ipc/client.rs

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>

* Add overview state to EventStreamStatePart replicate and apply

* Fix formatting

* Rename Overview to OverviewState

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-05-09 18:01:01 +03:00
dependabot[bot] a99489c6c0 build(deps): bump clap_complete in the rust-dependencies group
Bumps the rust-dependencies group with 1 update: [clap_complete](https://github.com/clap-rs/clap).


Updates `clap_complete` from 4.5.49 to 4.5.50
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.49...clap_complete-v4.5.50)

---
updated-dependencies:
- dependency-name: clap_complete
  dependency-version: 4.5.50
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-09 07:38:04 -07:00
Ivan Molodetskikh 0763c7e196 Add a clickable button to capture the screenshot
Allows tablet-, touch- and mouse-only confirmation.
2025-05-09 15:42:23 +03:00
Ivan Molodetskikh fb5c5204e8 Extract confirm_screenshot() 2025-05-09 15:41:57 +03:00
Ivan Molodetskikh d207cd385b screenshot_ui: Refactor mouse down + touch slot state 2025-05-09 15:10:00 +03:00
Ivan Molodetskikh 99bf2df2b4 Silence new zvariant De/SerializeDict deprecations
Questionable exercise converting to serde with much more boilerplate, and
breaking compat with older zvariant versions. Plus maybe this will be
undeprecated back.
2025-05-09 10:35:16 +03:00
Ivan Molodetskikh 09be90f4e6 Add touch selection support to the screenshot UI 2025-05-09 10:28:20 +03:00
Ivan Molodetskikh dfc42b9d82 Split ScreenshotUi::pointer_down() and up() 2025-05-09 10:28:20 +03:00
Ivan Molodetskikh e2b9838d89 Extract evt.slot() 2025-05-09 10:28:20 +03:00
Ivan Molodetskikh 816a0d479c Rename touch_location to pos 2025-05-09 10:28:20 +03:00
Ivan Molodetskikh 84323d10a4 Support tablet input for screenshot UI selection 2025-05-09 10:28:20 +03:00
Ivan Molodetskikh b956f2775c Use early return 2025-05-09 10:28:20 +03:00
Ivan Molodetskikh 9ff2f83db0 Simplify ScreenshotUi::pointer_button() 2025-05-09 10:28:20 +03:00
James Sully 7a10f71ee5 refactor(main): eliminate a mut from config load code in main
I think this makes for marginally better readability, since you don't
have to wonder whether config_errored is set anywhere else. It's also
slightly terser.
2025-05-09 00:25:54 -07:00
James Sully ea7add3563 fix: don't try to create a default config at path that exists
Currently this bug has no actual consequences, we just continue silently
on AlreadyExists in main()
(this line: https://github.com/YaLTeR/niri/blob/e9c6f08906143c3fec1ad1301d538bef4cbc1978/src/main.rs#L151).

This commit just eliminates the redundant attempt.
2025-05-08 21:52:39 -07:00
Ivan Molodetskikh e9c6f08906 Add a resize transaction client-server test 2025-05-07 22:59:57 +03:00
Ivan Molodetskikh 17343a6740 wiki: Fix Until note location 2025-05-06 17:42:14 +03:00
Ivan Molodetskikh 140d726cd3 wiki: Clarify that layers within backdrop ignore input 2025-05-06 17:40:52 +03:00
Ivan Molodetskikh c37d3b3442 wiki: Link to output backdrop-color from overview {} 2025-05-06 17:34:40 +03:00
Ivan Molodetskikh 497f186422 Add layout background-color setting 2025-05-06 17:34:40 +03:00
Ivan Molodetskikh 3e31c134a6 Implement place-within-backdrop layer rule 2025-05-06 17:34:40 +03:00
Ivan Molodetskikh fe682938db Simplify exclusive focus on layer check 2025-05-06 17:34:40 +03:00
Ivan Molodetskikh 6142922ca4 wiki: Mention Overview behavior on layer-shell page 2025-05-06 17:34:40 +03:00
Ivan Molodetskikh 4b44fba14c wiki: Clarify FAQ question about border with background 2025-05-06 17:34:40 +03:00
dependabot[bot] 57639ca84c build(deps): bump the rust-dependencies group across 1 directory with 3 updates
Bumps the rust-dependencies group with 3 updates in the / directory: [clap_complete](https://github.com/clap-rs/clap), [glam](https://github.com/bitshifter/glam-rs) and [zbus](https://github.com/dbus2/zbus).


Updates `clap_complete` from 4.5.48 to 4.5.49
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.48...clap_complete-v4.5.49)

Updates `glam` from 0.30.2 to 0.30.3
- [Changelog](https://github.com/bitshifter/glam-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bitshifter/glam-rs/compare/0.30.2...0.30.3)

Updates `zbus` from 5.5.0 to 5.6.0
- [Release notes](https://github.com/dbus2/zbus/releases)
- [Commits](https://github.com/dbus2/zbus/compare/zbus-5.5.0...zbus-5.6.0)

---
updated-dependencies:
- dependency-name: clap_complete
  dependency-version: 4.5.49
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: glam
  dependency-version: 0.30.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zbus
  dependency-version: 5.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 01:38:03 -07:00
Ivan Molodetskikh ec88aae77d wiki: Add Since to numlock 2025-05-05 20:29:55 +03:00
Ivan Molodetskikh 6c9705dd4b layout/scrolling: Update view offset on config update
Fix always-centering not applied right away. No other changes intended.
2025-05-01 21:37:34 +03:00
Aberter Yan eb590c5346 Implement --focus for MoveColumnToWorkspace/Up/Down 2025-05-01 11:06:34 -07:00
Ivan Molodetskikh 02baad91ac wiki: Clarify how key bindings are resolved 2025-05-01 16:28:28 +03:00
Ivan Molodetskikh 68589cd5a1 wiki: Remove "experimental" from custom shaders
They've been around for a while.
2025-05-01 11:19:38 +03:00
Ivan Molodetskikh f2c690802b Adjust the workspace shadow defaults some more 2025-05-01 11:04:47 +03:00
Ivan Molodetskikh 9d6037b94c Normalize workspace shadows to 1080 px tall screen, adjust defaults
Workspace gaps are dependent on screen size, so it makes sense to make shadows
depend on the screen size to, to avoid them filling more or less of the gap.
2025-05-01 10:33:53 +03:00
Ivan Molodetskikh 7b4cf094ef Draw workspace shadows behind all workspaces 2025-05-01 10:10:11 +03:00
Ivan Molodetskikh 446bc155ce Add workspace-shadow {} config to overview {} 2025-05-01 09:45:38 +03:00
Ivan Molodetskikh 3289324ce4 wiki: Use subheadings for overview settings 2025-05-01 09:36:39 +03:00
Ivan Molodetskikh 9fb02b9571 layout: Fix DnD scroll not stopping when interactive moving unfullscreen to floating 2025-04-30 20:32:56 +03:00
erdii 0e9496b01e chore(wiki): document numlock setting
Co-Authored-By: Ivan Molodetskikh <yalterz@gmail.com>
Signed-off-by: erdii <me@erdii.engineering>
2025-04-30 09:54:19 -07:00
erdii 82dabc21f3 feat: implement support to enable numlock at startup
Signed-off-by: erdii <me@erdii.engineering>
2025-04-30 09:54:19 -07:00
erdii 39b3d62873 chore: bump smithay 2025-04-30 09:54:19 -07:00
dependabot[bot] af080a03cd build(deps): bump the rust-dependencies group with 3 updates
Bumps the rust-dependencies group with 3 updates: [wayland-backend](https://github.com/smithay/wayland-rs), [insta](https://github.com/mitsuhiko/insta) and [wayland-client](https://github.com/smithay/wayland-rs).


Updates `wayland-backend` from 0.3.9 to 0.3.10
- [Release notes](https://github.com/smithay/wayland-rs/releases)
- [Changelog](https://github.com/Smithay/wayland-rs/blob/master/historical_changelog.md)
- [Commits](https://github.com/smithay/wayland-rs/commits)

Updates `insta` from 1.43.0 to 1.43.1
- [Release notes](https://github.com/mitsuhiko/insta/releases)
- [Changelog](https://github.com/mitsuhiko/insta/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mitsuhiko/insta/compare/1.43.0...1.43.1)

Updates `wayland-client` from 0.31.9 to 0.31.10
- [Release notes](https://github.com/smithay/wayland-rs/releases)
- [Changelog](https://github.com/Smithay/wayland-rs/blob/master/historical_changelog.md)
- [Commits](https://github.com/smithay/wayland-rs/commits)

---
updated-dependencies:
- dependency-name: wayland-backend
  dependency-version: 0.3.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: insta
  dependency-version: 1.43.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: wayland-client
  dependency-version: 0.31.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-30 01:46:05 -07:00
Ivan Molodetskikh 5f117c61dc animation/spring: Guard against numerical instability 2025-04-29 10:51:53 +03:00
Christian Meissl cb857e32e4 Bump Smithay and others
Presentation subsurface fix, popup unconstrain resize fix, cursor shape fix, refactors.
2025-04-29 08:53:25 +03:00
dependabot[bot] 199be26947 build(deps): bump the rust-dependencies group across 1 directory with 5 updates
Bumps the rust-dependencies group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [anyhow](https://github.com/dtolnay/anyhow) | `1.0.97` | `1.0.98` |
| [clap](https://github.com/clap-rs/clap) | `4.5.34` | `4.5.37` |
| [glam](https://github.com/bitshifter/glam-rs) | `0.30.1` | `0.30.2` |
| [libc](https://github.com/rust-lang/libc) | `0.2.171` | `0.2.172` |
| [insta](https://github.com/mitsuhiko/insta) | `1.42.2` | `1.43.0` |



Updates `anyhow` from 1.0.97 to 1.0.98
- [Release notes](https://github.com/dtolnay/anyhow/releases)
- [Commits](https://github.com/dtolnay/anyhow/compare/1.0.97...1.0.98)

Updates `clap` from 4.5.34 to 4.5.37
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.34...clap_complete-v4.5.37)

Updates `glam` from 0.30.1 to 0.30.2
- [Changelog](https://github.com/bitshifter/glam-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bitshifter/glam-rs/compare/0.30.1...0.30.2)

Updates `libc` from 0.2.171 to 0.2.172
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.172/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.171...0.2.172)

Updates `insta` from 1.42.2 to 1.43.0
- [Release notes](https://github.com/mitsuhiko/insta/releases)
- [Changelog](https://github.com/mitsuhiko/insta/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mitsuhiko/insta/compare/1.42.2...1.43.0)

---
updated-dependencies:
- dependency-name: anyhow
  dependency-version: 1.0.98
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: clap
  dependency-version: 4.5.37
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: glam
  dependency-version: 0.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libc
  dependency-version: 0.2.172
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: insta
  dependency-version: 1.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-28 22:35:11 -07:00
Ivan Molodetskikh d5c0c74d2c Fix hot corners preventing focus even when disabled 2025-04-29 08:24:45 +03:00
Ivan Molodetskikh 9bb292ec82 default-config: Set repeat=off for the Overview bind 2025-04-28 12:05:55 +03:00
Ivan Molodetskikh a1ba6bcaa0 wiki: Update backdrop-color examples 2025-04-28 09:32:44 +03:00
Ivan Molodetskikh fd389af6d8 Add backdrop-color setting to overview {} 2025-04-28 09:14:43 +03:00
Ivan Molodetskikh db09727b18 Replace Smithay's SolidColor elements with ours
Must've forgotten about these back when I replaced others.
2025-04-28 09:05:55 +03:00
Ivan Molodetskikh c9d6478c3c wiki: Rename Configuration: Overview page to Introduction 2025-04-28 07:54:02 +03:00
bogdanov 758cca5432 Fix pointer hiding so that it is no longer annoying (#1426)
* replace `pointer_hidden` with `pointer_visiblity`

* disable hidden pointer after content underneath has changed

* fixes

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-04-27 06:25:36 +00:00
Ivan Molodetskikh 78e3daf5f8 overview: Activate window upon dropping from interactive move 2025-04-26 13:29:36 +03:00
Mitchel Stewart a99a0b2492 Steam Black Screen system-composer 2025-04-26 00:38:48 -07:00
Ivan Molodetskikh bfd42c74f4 layout/tab_indicator: Fix negative gap
Regressed in a recent commit that added max1.
2025-04-26 09:25:31 +03:00
Ivan Molodetskikh 501ea47128 wiki/Overview: Mention backdrop-color 2025-04-25 17:56:17 +03:00
Ivan Molodetskikh d2a1cf53b4 Fix panic when interactively moving to invisible workspace
Introduced in the interactive move between workspaces commit.
2025-04-25 16:55:36 +03:00
Ivan Molodetskikh 62d47d77d5 wiki: Document backdrop-color and overview-open-close animation 2025-04-25 15:29:42 +03:00
Ivan Molodetskikh 85cd64e830 Document the Overview and other new things 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 55c14eebf2 hotkey_overlay: Show the ToggleOverview bind 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 3fe67549b4 default-config: Bind Mod+O to toggle-overview 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 1835b532d9 Implement interactive move to a new workspace above/between 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh e6d82d3ee3 Implement top-left hot corner to toggle the Overview
Compared to third-party implementations such as waycorner:

- It works during interactive window move (no surfaces receive pointer
  focus in this case, so this cannot work through layer-shell).
- It works during drag-and-drop.
- It disables itself over fullscreen windows.
- It does not prevent direct scanout.
2025-04-25 02:00:18 -07:00
Ivan Molodetskikh fae3a27641 Implement DnD hold to activate window or workspace 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 31e76cf451 overview: Add DnD up/down scrolling 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh b8a9be542f overview: Add touchscreen gestures 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 59de6918b3 overview: Add two-finger touchpad scroll 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh bd3d554389 overview: Add hardcoded mouse scroll binds 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh af1fca35bb Implement an Overview 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 9571d149b2 Render workspaces separately with gaps between
This design makes more sense spatially, and is required for the
Overview. Gaps also make it clear how clipping windows to workspace
bounds works.

Background and bottom layer-shell surfaces get duplicated for each
workspace, while top and overlay stay "on top".
2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 99358e36b3 layout/monitor: Extract activate_workspace_with_anim_config() 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 8b878f355f Put interactively moved window on top of background and bottom layer popups 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 395b6d9a4f layout: Extract interactive_moved_window_under() and add output check
Fixes interactively moved window getting input on every output rather
than just its own.
2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 25f24f668c Extract mapped_hit_data() 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 929eaf0d69 Pass target workspace to view offset grab 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh ce3103949f layout/scrolling: Support view offset anim during gesture
Brings back moving the newly active window into focus upon interactive
move dragging out.
2025-04-25 02:00:18 -07:00
Ivan Molodetskikh ef60dd81d7 layout/monitor: Cache scale, view_size, working_area 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 7671a5d833 layout/monitor: Don't consider workspace switch in active_tile_visual_rectangle()
This only did something when in the middle of a touchpad gesture, and it
didn't really make sense for that edge case.
2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 3f09352067 layout/monitor: Extract add_workspace_at() 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 5059cce886 Add with_alpha() to shader and shadow element 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh b20dd226c0 layout: Move insert hint from ScrollingSpace to Monitor 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh acb69c3b4d layout: Return floating and scrolling elems separately from Workspace 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh dbe0a9e293 layout/tab_indicator: Use round_max1 where appropriate 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh d3a79faeec layout/monitor: Extract workspace_render_idx() 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 21630ddb5e layout/monitor: Extract workspaces_render_geo() 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 9e5e0c85bb Simplify condition 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 5cd8040d1a Extract is_layout_obscured_under() 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 86351938f2 Put the top layer above bottom and background layer popups
Makes it consistent with how window popups are below the top layer, also
will make more sense for the overview.
2025-04-25 02:00:18 -07:00
Ivan Molodetskikh ee4c5e23ab Reformat scroll factor computation 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh ffd6acc0aa layout/monitor: Extract WorkspaceSwitchGesture::min_max() 2025-04-25 02:00:18 -07:00
Ivan Molodetskikh cee11dc329 layout/monitor: Keep track of workspace switch gesture start idx
Fixes jump when "catching" an animation with a gesture.
2025-04-25 02:00:18 -07:00
Ivan Molodetskikh 59a42249a4 Remove cancellation from swipe gestures
It only worked for workspace switch, and even there it was more confusing than
helpful.
2025-04-25 02:00:18 -07:00
109 changed files with 10379 additions and 2221 deletions
+11
View File
@@ -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'
+2 -2
View File
@@ -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"
+21
View File
@@ -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:
+96
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+28 -27
View File
@@ -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" },
+31 -13
View File
@@ -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&nbsp;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&nbsp;Showcase</a>
</p>
![niri with a few windows open](https://github.com/user-attachments/assets/d142e57d-a25d-4ddb-ab46-311417458211)
![niri with a few windows open](https://github.com/user-attachments/assets/535e6530-2f44-4b84-a883-1240a3eee6e9)
## 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
View File
@@ -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": {
+9 -11
View File
@@ -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
];
};
+2 -2
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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() {
+1 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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))
}
}
}
+45
View File
@@ -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
}
}
+3 -3
View File
@@ -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.,
+3
View File
@@ -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()
}
+4
View File
@@ -272,4 +272,8 @@ impl LayoutElement for TestWindow {
fn interactive_resize_data(&self) -> Option<InteractiveResizeData> {
None
}
fn is_urgent(&self) -> bool {
false
}
}
+42 -6
View File
@@ -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
View File
@@ -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
+19
View File
@@ -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);
}
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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"),
}
}
}
+144
View File
@@ -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)
}
+20 -11
View File
@@ -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();
+18
View File
@@ -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);
}
}
+3 -8
View File
@@ -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()
+1 -1
View File
@@ -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());
+32 -3
View File
@@ -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
View File
@@ -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);
}
+34 -9
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+42 -5
View File
@@ -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,
+69
View File
@@ -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
}
}
+28 -8
View File
@@ -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);
+274
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+14
View File
@@ -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);
}
+5 -2
View File
@@ -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
+7 -2
View File
@@ -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
+5 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+977 -200
View File
File diff suppressed because it is too large Load Diff
+225 -156
View File
@@ -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.));
+18 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+715
View File
@@ -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
View File
@@ -1,3 +1,4 @@
pub mod ext_workspace;
pub mod foreign_toplevel;
pub mod gamma_control;
pub mod mutter_x11_interop;
+552 -100
View File
@@ -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);
}
+12 -7
View File
@@ -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));
+1 -1
View File
@@ -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")?;
+9 -9
View File
@@ -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(());
}
+1
View File
@@ -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)),+
+7 -2
View File
@@ -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 {
+5
View File
@@ -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)
+1 -1
View File
@@ -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;
};
+8 -8
View File
@@ -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(());
}
+231
View File
@@ -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,
+1 -1
View File
@@ -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.
+90
View File
@@ -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");
}
+2
View File
@@ -6,4 +6,6 @@ mod server;
mod floating;
mod fullscreen;
mod layer_shell;
mod transactions;
mod window_opening;
+1
View File
@@ -23,6 +23,7 @@ impl Server {
display,
true,
false,
false,
)
.unwrap();
+105
View File
@@ -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
");
}
+1 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+82
View File
@@ -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())
}
}
+11
View File
@@ -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;
};
+2
View File
@@ -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();
+151
View File
@@ -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,
})
}
+311
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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:
+21 -3
View File
@@ -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>
+38 -4
View File
@@ -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.
+53
View File
@@ -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
}
}
```
+53 -3
View File
@@ -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`.
+150
View File
@@ -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/
+11 -2
View File
@@ -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>&lt;</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>&lt;</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 {
+40 -1
View File
@@ -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
}
```
+27 -2
View File
@@ -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).
+108 -2
View File
@@ -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).
+19 -3
View File
@@ -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
View File
@@ -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).
+19 -1
View File
@@ -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:
![](https://github.com/user-attachments/assets/375b381e-3a87-4e94-8676-44404971d893)
#### `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.
+18 -17
View File
@@ -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
View File
@@ -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