Compare commits

...

107 Commits

Author SHA1 Message Date
dependabot[bot] 5e140a32b1 build(deps): bump glam from 0.30.10 to 0.31.0
Bumps [glam](https://github.com/bitshifter/glam-rs) from 0.30.10 to 0.31.0.
- [Changelog](https://github.com/bitshifter/glam-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bitshifter/glam-rs/compare/0.30.10...0.31.0)

---
updated-dependencies:
- dependency-name: glam
  dependency-version: 0.31.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-29 08:46:44 +00:00
Ivan Molodetskikh f30db163b5 layout/tile: Remove redundant .to_f64() 2026-01-28 08:12:06 +03:00
Ivan Molodetskikh a78f07cd58 Remove ResolvedLayerRules::empty()
Same cleanup as ResolvedWindowRules earlier, but here we didn't even
have any reason to keep having it.
2026-01-28 08:12:06 +03:00
Semper_ 765a241c5a Link to Electron section of the wiki in FAQ (#3324)
* Update Electron info

There were changes made that remove the env variable:
https://github.com/electron/electron/issues/48001

* Clarify Electron versions

* Link to the electron section of the wiki

* Edit wording and link to the electron section of the wiki
2026-01-27 20:52:25 +03:00
Ivan Molodetskikh a00b271a15 pw_utils: Lower default buffer count to 8
We certainly don't need 16 buffers.
2026-01-27 20:42:25 +03:00
Semper_ e1015ac92f Docs: Update Electron info (#3320)
* Update Electron info

There were changes made that remove the env variable:
https://github.com/electron/electron/issues/48001

* Clarify Electron versions
2026-01-27 09:21:21 +03:00
Ivan Molodetskikh a34ed51586 Make debug_draw_opaque_regions work in screencasts again 2026-01-26 06:18:35 +03:00
Ivan Molodetskikh 5ddcf195dd Remove unused portable-atomic dep
Has been unused for a long while (since the animation clock refactor).
2026-01-25 20:49:07 +03:00
Ivan Molodetskikh e11abe554f Fix expel-window-from-column comment
It's been changed to this a while ago.
2026-01-25 13:33:15 +03:00
Ivan Molodetskikh 9261fd6342 layout/tests: Add test for second workspace y = 0 2026-01-25 18:28:35 +08:00
SAKURA fb2f66f361 layout/monitor: round workspace render geo to physical pixels 2026-01-25 18:28:35 +08:00
dependabot[bot] e2e15b7a18 build(deps): bump zbus in the rust-dependencies group
Bumps the rust-dependencies group with 1 update: [zbus](https://github.com/z-galaxy/zbus).


Updates `zbus` from 5.13.0 to 5.13.1
- [Release notes](https://github.com/z-galaxy/zbus/releases)
- [Changelog](https://github.com/z-galaxy/zbus/blob/main/release-plz.toml)
- [Commits](https://github.com/z-galaxy/zbus/compare/zbus-5.13.0...zbus-5.13.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-25 18:23:49 +08:00
Xarth 0a416eedda docs: Update Arch Linux installation instructions
Removed 'wl-clipboard' and 'cliphist' from the installation command for Arch Linux. Because dms doesn't request that now.
2026-01-25 18:22:38 +08:00
Ivan Molodetskikh d7184a04b9 render_helpers: Add Smithay Tracy GPU spans 2026-01-17 22:31:05 +03:00
Ivan Molodetskikh bdf394260a render_helpers: Add Tracy spans to draw() calls 2026-01-17 22:30:32 +03:00
Ivan Molodetskikh 74d14be01f Update Smithay (virtual keyboard, layer-shell geometry, GPU profiling)
Also includes the necessary code to handle the virtual keyboard
compositor-side. Similar to the virtual pointer, we have an InputDevice
impl that allows reusing the logic from process_input_event().

Co-authored-by: wxt <3264117476@qq.com>
2026-01-17 22:29:10 +03:00
Ivan Molodetskikh 3ccb06f564 Fix panic in screencopy manager destroyed() 2026-01-17 15:32:20 +03:00
Ivan Molodetskikh d9e755d575 screencasting: Only render pointer when it's within output 2026-01-17 13:59:23 +03:00
Ivan Molodetskikh 87e2dd0361 Revert "Move set_dynamic_cast_target() stub closer to the other ones"
This reverts commit dd93c39ed0.

Why did I do this, that function is on a different type
2026-01-15 17:29:37 +03:00
Ivan Molodetskikh dd93c39ed0 Move set_dynamic_cast_target() stub closer to the other ones 2026-01-15 13:14:23 +03:00
Ivan Molodetskikh 849788bb28 Add niri msg stop-cast --session-id 2026-01-15 13:13:50 +03:00
Ivan Molodetskikh 9015ff8e36 ipc: Add pw_node_id to PipeWire Casts 2026-01-15 08:42:25 +03:00
Ivan Molodetskikh e546b339a3 ipc: Add PID to screencopy Casts 2026-01-15 08:42:25 +03:00
Ivan Molodetskikh b39edf405a screencopy: Add timeout to casts considered stopped
Otherwise xdp-wlr never stops the cast after it first starts.
2026-01-15 08:42:25 +03:00
Ivan Molodetskikh b98f4906da ipc: Add CastKind 2026-01-15 08:42:25 +03:00
Ivan Molodetskikh e82830c68c ipc: Add screencopy cast tracking
Track wlr-screencopy sessions that use with_damage as screencasts. These
are used by tools like wl-screenrec for continuous recording.
2026-01-15 08:42:25 +03:00
Ivan Molodetskikh 238caaf8da ipc: Add screencast request and events for PipeWire casts
Allows desktop bars to show when screen recording is active.
2026-01-15 08:42:25 +03:00
Ivan Molodetskikh 9c79108afa Refactor wlr-screencopy state cleanup
Before we cleaned up when binding a new manager, meaning that after a
screencopy client exited, the queue kept existing until a new one is
bound. We'll need precise tracking for the screencast IPC, so this
commit refactors to do just that: clean up the queue immediately when
all referring objects no longer exist.

This commit also fixes an issue where destroyed frames (e.g. from a
killed client) didn't clean the corresponding screencopy objects,
leading them to exist forever.
2026-01-13 23:01:21 +03:00
Ivan Molodetskikh 2571242887 screencopy: Pop first screencopy instead of last
This was never found probably because no client submits multiple frames
at once.
2026-01-13 23:00:38 +03:00
Ivan Molodetskikh 6f92b3296a Store output name in CastTarget
Will be useful in the next commit to avoid fetching it every time.
2026-01-13 21:31:51 +03:00
Ivan Molodetskikh 570ea119ba Extract cast session/stream ID counters to global scope
Add CastSessionId and CastStreamId newtypes. This lifts the atomic
counters from the D-Bus mutter_screen_cast module to a shared location,
preparing for adding screencopy cast tracking which will need the same
ID types.
2026-01-13 21:31:51 +03:00
Ivan Molodetskikh df4614e62c screencasting: Use spans to reduce logging boilerplate 2026-01-12 21:33:29 +03:00
Ivan Molodetskikh 3672e79369 Delay starting dynamic casts until there's a target
This avoids a weird 1x1 stream as well as one renegotiation which is a
complex operation, and some clients apparently have a problem with it.
2026-01-12 08:45:03 +03:00
Ivan Molodetskikh 2d16abdaae Move dynamic_target set outside pw_utils 2026-01-12 08:37:58 +03:00
Ivan Molodetskikh ff081acddc screencasting: Extract some logic into functions 2026-01-12 08:37:58 +03:00
Ivan Molodetskikh afe27a143b Move xdp-gnome-screencast code into separate module 2026-01-12 07:59:56 +03:00
Ivan Molodetskikh fd2916eb72 Honor pointer visibility in screencasts
Regressed in 05599ce2c4.
2026-01-12 07:11:56 +03:00
Janis e9d888cd52 Set NIRI_BUILD_COMMIT in flake.nix (#3235) 2026-01-11 19:59:43 +03:00
abmantis 05599ce2c4 Implement cursor metadata in window screencast
Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2026-01-11 06:51:14 -08:00
Ivan Molodetskikh 0fb6c5706b Extract pointer_pos_for_window_cast()
Will be used for window screencasts too.
2026-01-11 06:51:14 -08:00
Ivan Molodetskikh 79aaa4c6c0 Upgrade dependencies 2026-01-11 15:37:00 +03:00
Ivan Molodetskikh 7e559dc468 Use unadjusted clock for config notification shown duration
It shouldn't be affected by anim slowdown (or, more importantly,
speedup).
2026-01-11 15:08:58 +03:00
Ivan Molodetskikh 45fc763281 Update Insta snapshots for new Insta version
No functional changes intended.
2026-01-10 15:45:47 +03:00
Ivan Molodetskikh 39d3cd2415 Add a push version of render()
Will be useful for screencasting.
2026-01-10 15:32:21 +03:00
Ivan Molodetskikh 19b1074a8b Fix root surface tracking for sync subsurfaces
In particular, fixes screenshot-window with show-pointer on foot CSD.
2026-01-10 15:32:21 +03:00
Ivan Molodetskikh 539a5a8030 Fix tablet cursor for screenshot-window with pointer 2026-01-10 15:32:21 +03:00
Ivan Molodetskikh 53b7477d20 render_helpers: Fix encompassing_geo() argument type 2026-01-10 15:32:21 +03:00
Ivan Molodetskikh c34f7b18ec pick_color_grab: Remove unnecessary Vec allocation 2026-01-10 15:32:21 +03:00
Anton Kesy a6baef7b68 Fix typo 2026-01-10 04:30:42 -08:00
Manuel Romei 10df9f4717 fix(pw_utils): prevent write-after-free by reordering Cast struct fields
The Cast struct fields were ordered such that `stream` was dropped before
`_listener`. In Rust, struct fields are dropped in declaration order.

Because `StreamListener` attempts to unregister itself from the stream on
drop, and `StreamRc` destroys the underlying PipeWire stream on drop, the
previous order caused `_listener` to access the stream after it had
already been freed.

This reorders the fields so `_listener` is declared before `stream`,
ensuring the listener unregisters itself while the stream is still valid.

* Apply suggestion from @YaLTeR

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2026-01-07 13:32:06 +00:00
Ivan Molodetskikh 9f8eadc5bc Add screenshot-window show-pointer=true 2026-01-07 07:53:05 +03:00
Ivan Molodetskikh a496307daf Move pointer visibility check outside render_pointer() 2026-01-07 07:53:05 +03:00
Ivan Molodetskikh bc7bb51b6f Fix Tracy span name 2026-01-07 07:53:05 +03:00
Ivan Molodetskikh b7eb8a635b default-config: Bind Mod+M to maximize-window-to-edges 2026-01-05 10:28:18 +03:00
Ivan Molodetskikh d060b06667 Replace TODO with FIXME
We use TODO for things to be fixed before committing.
2026-01-05 08:29:05 +03:00
Ivan Molodetskikh 54c2e2ab47 utils/spawning: Remove unnecessary cfgs
Forgotten when this was refactored.
2026-01-04 15:43:42 +03:00
Ivan Molodetskikh df3f3979e9 layout/scrolling: Preserve gesture anim in dnd_scroll_gesture_end()
Fixes interactive move unmaximize/unfullscreen into floating skipping
the view offset anim.
2026-01-04 15:43:42 +03:00
Vishal 6215b5f0b1 doc: link DMS compositor setup in Quick Start (#3179)
* doc: add `dms setup` command in Getting Started section along with clarification

* mention DMS setup page

* Update docs/wiki/Getting-Started.md

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2026-01-04 15:04:22 +03:00
Mark Karlinsky 3bfa4a71ff Improve dinit service files (#3193)
* Improve dinit service files and niri-session

Two main changes were made:
 - After a discussion in davmac314/dinit#496, 2 dinit services are now
   provided. The first one is 'niri', which runs niri itself, and the
   second one is 'niri.target' which brings up all the dependences from
   user configuration.
 - Made the behaviour of 'niri-session' when running under dinit closer
   to the behaviour when running under systemd. In particular, now the
   script wait for service completion, because some login managers shut
   the session down the moment the startup script completes.

* Update paths in docs
2026-01-04 10:04:03 +03:00
LuckShiba 3158f5a9c0 nix: fix path replace in SystemD service 2026-01-03 21:43:13 +03:00
Axlefublr d8250fa876 niri.service: don't hardcode the path 2026-01-03 19:43:14 +03:00
Ivan Molodetskikh cf0b4bc0ca Fix missing redraw when floating DnD in overview scrolls workspaces
Regressed in 396097c3ab
2025-12-31 08:46:49 +03:00
Ivan Molodetskikh 1ab1737653 tty: Load libinput plugins if available
Some distros like Fedora build libinput with plugin autoloading, however
by default, the compositor needs to explicitly load them. Plus, we need
to load manually if we want to also load from
$XDG_CONFIG_HOME/libinput/plugins.

Ref. https://gitlab.gnome.org/GNOME/mutter/-/commit/c5b12fbf6313d51f3279901bb561023e56181e36
2025-12-30 08:22:11 +03:00
Ivan Molodetskikh b5640d5293 Don't add padding to layer-shell popups 2025-12-26 15:25:30 +03:00
Ivan Molodetskikh 860a08cce6 Update Smithay (text-input enter/leave fix, multigpu formats improvement) 2025-12-26 08:31:34 +03:00
Ivan Molodetskikh 2a9d0e495a Fix consume-or-expel-left anim to the left of active column
Regressed in c4462d0c7f.
2025-12-25 14:26:49 +03:00
Ivan Molodetskikh 7f132ecf95 Refactor rendering to push-based instead of pull-based (#3113)
Our current rendering code constructs and returns complex
`-> impl Iterator<Item = SomeRenderElement>` types that are collected
into a vector at the top level Niri::render(). This causes some
problems:
- It's hard to write logic around returning iterators. Especially things
  like conditions, since the returned iterator must have a single type,
  you can't branch and return different iterators. This will be solved
  by gen fn but alas it's not here yet.
- In many cases, the returned `-> impl Iterator` will borrow from &self
  leading to complex lifetimes. In certain cases, it is also desirable
  for it to borrow the &mut NiriRenderer, which causes a lot of issues
  because it's exclusive (&mut).
- Sometimes those issues are too hard to deal with, leading to the
  escape hatch of allocating and returning a temporary
  Vec<SomeRenderElement>, like in
  Scrolling/FloatingSpace::render_elements(). These allocations are
  unfortunate because they are not really necessary.
- It's impossible to use some downstream combinators with this
  `-> impl Iterator` approach, leading to functions like Smithay's
  render_elements_from_surface_tree() returning a Vec. This is extra
  unfortunate because it results in a temporary allocation per Wayland
  toplevel/popup.
- It's hard to properly create profiling spans for the rendering
  functions since the spans are dropped when the (lazy) iterator is
  returned and not when all the code actually completes.
- The code compiles down to complex state machines in generated iterator
  types with logic located in Iterator::next(), which makes it annoying
  to follow in debuggers and profiling tools.

This refactor changes the code to push-based iteration: rendering
functions receive a push() closure that they call to push their render
elements. It solves all of the aforementioned problems:
- The logic becomes simpler. Just use conditionals and loops as normal.
- No borrowing and lifetimes since we're not returning anything.
- All temporary Vecs are removed because the problems they worked around
  no longer exist.
- The new push_elements_from_surface_tree() helper is the same as
  render_elements_from_surface_tree() but doesn't allocate a temporary
  Vec since it's not necessary; the push() closure can be passed down.
- Profiling spans work normally since the function returns when it ran
  all of the logic.
- The code compiles down to normal functions and calls as expected.

Generally, the iterator approach gives these advantages:
- You can wrap the returned items in the upstream logic. This is
  possible in exactly the same way with the push closure.
- You can decide to cut the iterator short in the upstream logic. This
  is not possible with push-based iteration, but we don't actually use
  it anywhere.

I chose the push closure type to be &mut dyn FnMut(SomeRenderElement).
It's deliberately not a generic impl FnMut() to avoid duplicating the
rendering logic when it's called from several different places. But it's
still a normal closure that can capture the outside context.

While my original idea for this refactor was to simplify the logic while
getting rid of temporary Vecs, it also appears to have brought a
consistent 2-3x speedup to the whole render list construction. On an old
Eee PC laptop I even observed a 8x speedup.

The refactor also results in smaller binary size, presumably due to
removing many iterator combinators and state tracking.
2025-12-25 14:26:19 +03:00
Ivan Molodetskikh 1a63089d67 Fix tracy span names 2025-12-25 09:52:50 +03:00
Ivan Molodetskikh 88dc6e22d0 Remove redundant clippy allow 2025-12-25 09:42:08 +03:00
Ivan Molodetskikh ce8171bed3 Fix wrong rendering order when switching dynamic cast to window 2025-12-25 08:51:43 +03:00
Ivan Molodetskikh 6edd29170f opening window: Remove unused method 2025-12-25 08:51:43 +03:00
Ivan Molodetskikh 9d62b94688 scrolling: Don't forget to call tab_indicator.update_shaders()
This didn't actually break anything since those shaders aren't
configurable.
2025-12-25 08:51:43 +03:00
Ivan Molodetskikh 4d295418ce clipped surface: Compute uniforms on-demand
Removes two allocations for every clipped surface.
2025-12-23 12:51:59 +03:00
HigherOrderLogic f01d48bc51 ci: user Cachix nix installer 2025-12-23 10:27:32 +03:00
HigherOrderLogic 31ca509160 ci: remove flake check action 2025-12-23 10:27:32 +03:00
Ivan Molodetskikh 396097c3ab Fix constant repaint in the open overview 2025-12-23 08:51:54 +03:00
Ivan Molodetskikh ad62c8e487 gradient_fade: Store uniform inline 2025-12-23 07:50:58 +03:00
Ivan Molodetskikh 9e73beb165 shader: Store uniforms in Rc instead of Vec
It's frequently cloned (e.g. every border piece every render) and we
don't change it.
2025-12-23 07:50:58 +03:00
Ivan Molodetskikh 4fca614510 Update Smithay (DnD rework fix, dmabuf and geometry improvements) 2025-12-23 07:50:58 +03:00
Ivan Molodetskikh 19e55a2df0 Don't override IME grab with popup keyboard grab
Fixes menu in Telegram. Some weird behavior is still possible e.g. with
gtk4-widget-factory and dropdowns on entries, but things seem to be
slightly less broken this way.
2025-12-20 14:11:02 +03:00
Ivan Molodetskikh 6472209b45 Comment out spammy trace!() 2025-12-20 14:08:48 +03:00
Ivan Molodetskikh d9ceff7c70 Remove IME grab check, fix GTK 4 popups with IME
The wording in the deleted comment still stands: Smithay doesn't handle
overlapping grabs. However, in this case things appear to more or less
work themselves out. IME seems to re-request its grab every time an
input field is focused, replacing the popup keyboard grab. And the popup
keyboard grab doesn't seem to mind being replaced this way.
2025-12-20 13:46:59 +03:00
Ivan Molodetskikh 813c5ee05f Warp pointer across the screen during spatial movement grabs 2025-12-20 10:50:07 +03:00
Ivan Molodetskikh 47e217c00e Use relative motion in move and spatial movement grab
Will be used for pointer warping.
2025-12-20 10:49:06 +03:00
Ivan Molodetskikh 9b52465e42 layout: Synchronize unfullscreen view movement anim to resize
Before this commit, maximize/fullscreen was synchronized, but
unmaximize/unfullscreen wasn't.
2025-12-20 09:08:17 +03:00
Ivan Molodetskikh 7d60231e35 wiki: Clarify that environment isn't imported to systemd 2025-12-20 08:33:02 +03:00
John Rinehart 7a237e519c Implement include optional=true (#3022)
* feat(niri): support `include optional=true "filename.kdl"`

* chore: warn if optional include ENOENT

* chore: validate include directive arguments and properties

Add proper validation to reject:
- Extra arguments beyond the path
- Unknown properties (other than "optional")
- Unexpected child nodes

* docs: implement suggested typographical/prose changes

* fixes

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-12-20 05:04:18 +00:00
Ivan Molodetskikh c4462d0c7f layout/scrolling: Fix add_column() skipping activate_column() sometimes
When the column was added immediately to the left of the current column
and activated, the new idx would be equal to active_column_idx, which
would skip activate_column() with its variable resets.
2025-12-18 22:19:03 +03:00
Ivan Molodetskikh f85cb5c5f9 dependabot: Add cooldown 2025-12-18 13:39:30 +03:00
Ivan Molodetskikh 7ca46b44b2 Update Smithay (DnD rework, primary GPU improvement) 2025-12-18 13:17:35 +03:00
Ivan Molodetskikh f913219f94 Use is_none_or() 2025-12-18 11:54:07 +03:00
Ivan Molodetskikh 80469abc20 Bump MSRV to 1.85, upgrade deps 2025-12-18 11:54:07 +03:00
Kirill Chibisov 890935d2ba Use Grabbing cursor for Mod+LMB interactive move (#3045)
* Use Grabbing cursors for interactive move

There was no real indication that something can be dragged and thus
it's generally harder to discover for someone not familiar with Mod+LMB
to start dragging window around.

* fixes

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-12-18 08:07:24 +03:00
Ivan Molodetskikh d2fa1f54d4 Add force-disable-connectors-on-resume debug flag 2025-12-18 07:39:44 +03:00
Ivan Molodetskikh 2641356d41 mru: Don't handle pointer input until visible 2025-12-16 08:05:51 +03:00
Ivan Molodetskikh 7c0898570c Remove url dependency
Just use the glib function.

Turns out url comes with a huge dep tree. Well, I guess back when I
wrote this, we didn't have glib in our deps, but we had for a long time.
2025-12-14 07:50:00 +03:00
Ivan Molodetskikh d1fc1ab731 CI/freebsd: Fix PW patch application 2025-12-13 14:39:57 +03:00
Ivan Molodetskikh d9a9e6ddc4 CI: Remove Rust install from FreeBSD action
We don't need it since we removed the cache.
2025-12-13 14:27:21 +03:00
Ivan Molodetskikh 0cb20b55b8 CI: Update FreeBSD to 15.0 2025-12-13 14:26:17 +03:00
Ivan Molodetskikh 3d2d7b95d9 CI: Re-enable FreeBSD 2025-12-13 14:23:32 +03:00
Ivan Molodetskikh c22d8358c2 wiki/packaging: Mention recommended deps 2025-12-12 10:26:13 +03:00
Ivan Molodetskikh 4d058e6111 rpkg: Add explicit libwayland-server dependency 2025-12-09 22:02:36 +03:00
DerRockWolf 83a733e085 Update issue template to put niri config into <details> block
This makes issues much more readable and prevents readers from needing to scroll all the way past the config.
2025-12-09 07:55:53 +03:00
Ivan Molodetskikh ba29735fbb contributing: Add a section on how to get PR reviewed more quickly 2025-12-05 23:21:54 +03:00
Ivan Molodetskikh 6fc092cc4f contributing: Add a section on AI contributions 2025-12-05 23:21:47 +03:00
Robert Gu f874b2fce5 Update Integrating-niri.md on multi-file configs (#2943)
* Update Integrating-niri.md on multi-file configs

* Apply suggestion from @YaLTeR

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-12-02 15:23:01 +03:00
Semper_ 311ca6b5da Docs: add a few notes and warnings (#2925)
* update docs.

* Update Xwayland.md.

* Apply suggestion from @YaLTeR

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
2025-11-30 09:51:13 +03:00
1079 changed files with 5563 additions and 4032 deletions
+7
View File
@@ -10,6 +10,13 @@ assignees: ''
<!-- Please describe the issue here at the top, then fill in the system information below. -->
<!-- Attaching your full niri config can help diagnose the problem. -->
<details><summary>Config</summary>
```kdl
insert config here
```
</details>
<!--
If you have a problem with a specific app, please verify that it is running on Wayland, rather than X11. An easy way is to run xeyes and mouse over the app: xeyes will be able to "see" only X11 windows.
+4 -2
View File
@@ -13,10 +13,12 @@ updates:
update-types:
- "minor"
- "patch"
cooldown:
default-days: 7
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
ignore:
- dependency-name: "Andrew-Chen-Wang/github-wiki-action"
cooldown:
default-days: 7
+6 -13
View File
@@ -181,7 +181,7 @@ jobs:
sudo apt-get update -y
sudo apt-get install -y ${{ env.DEPS_APT }} libadwaita-1-dev
- uses: dtolnay/rust-toolchain@1.80.1
- uses: dtolnay/rust-toolchain@1.85.0
- uses: Swatinem/rust-cache@v2
@@ -246,7 +246,6 @@ jobs:
- run: cargo build --all
freebsd:
if: false # Waiting for a new version of the pipewire-rs patch.
runs-on: ubuntu-24.04
env:
CARGO_HOME: /home/runner/work/niri/niri/cargo-home
@@ -256,28 +255,26 @@ jobs:
with:
show-progress: false
# Required for the rust-cache action to work.
- uses: dtolnay/rust-toolchain@stable
# Remove man-db triggers to speed up Ubuntu upgrade by a minute or two during vmactions/freebsd-vm action run.
- run: |
sudo rm /var/lib/dpkg/info/man-db.*
- name: Build
uses: vmactions/freebsd-vm@966989c456d41351f095a421f60e71342d3bce41 # v1.2.1
uses: vmactions/freebsd-vm@v1
with:
release: "15.0"
copyback: false
prepare: |
pkg update -f
pkg install -y ${{ env.DEPS_PKG }}
run: |
curl -o patch-pipewire_init 'https://cgit.freebsd.org/ports/plain/x11-wm/niri/files/patch-pipewire_init?id=f3f7e555b06d9a87d63c047ce3e82e936a11f2fe'
curl -o patch-pipewire_init 'https://cgit.freebsd.org/ports/plain/x11-wm/niri/files/patch-pipewire_init?id=cadf6784d264cf780b6e0ad59bd15b831d36cf80'
export CARGO_HOME="$PWD/cargo-home"
cargo fetch
( cd $CARGO_HOME/git/checkouts/pipewire-rs-*/*/; patch -p2 < $CARGO_HOME/../patch-pipewire_init; )
( cd $CARGO_HOME/registry/src/index.crates.io-*/; patch -p1 < $CARGO_HOME/../patch-pipewire_init; )
cargo build \
--offline \
@@ -296,12 +293,8 @@ jobs:
with:
show-progress: false
- name: Check flake inputs
uses: DeterminateSystems/flake-checker-action@v4
continue-on-error: true
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v3
uses: cachix/install-nix-action@v31
continue-on-error: true
- run: nix flake check
+18
View File
@@ -90,6 +90,24 @@ When creating pull requests, please keep the following in mind.
- 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.
### How to get your pull request reviewed more quickly
- Make it small and self-contained. Avoid mixing several unrelated changes in one PR.
- Split the PR into small and self-contained commits. This makes it much easier to review.
- Discuss new features, options, or behavior changes beforehand; make sure there's consensus about the design.
- When creating the pull request, clearly write what it does, what problem it solves, how to test it.
- Follow the rest of the advice from this document.
## AI contributions
If you use LLMs for your contribution (issue, comment, pull request), then it is *your job* to check and clean up its output, just like with any other tool.
*You* have to spend the time doing this.
Particularly:
- If I can tell that a pull request is mostly LLM-generated, then very likely this pull request will take *significantly more time and effort* than usual to review and finish. This is based on my prior review experience. Therefore, I'm not interested in such pull requests—there's always plenty of human-written ones which take priority.
- When using an LLM to prepare an issue, the text usually has a lot of unnecessary wording and irrelevant details. Anyone looking at such an issue will quickly lose interest in reading through it (myself certainly). Clean up the text and keep only those details that actually matter.
- When using an LLM to comment on an issue, *you* have to verify that the comment makes sense, contributes something useful, and doesn't have unnecessary repetition.
[cosmic-comp]: https://github.com/pop-os/cosmic-comp
[anvil]: https://github.com/Smithay/smithay/tree/master/anvil
Generated
+489 -1003
View File
File diff suppressed because it is too large Load Diff
+28 -27
View File
@@ -12,21 +12,21 @@ authors = ["Ivan Molodetskikh <yalterz@gmail.com>"]
license = "GPL-3.0-or-later"
edition = "2021"
repository = "https://github.com/YaLTeR/niri"
rust-version = "1.80.1"
rust-version = "1.85"
[workspace.dependencies]
anyhow = "1.0.100"
bitflags = "2.9.4"
clap = { version = "4.5.48", features = ["derive"] }
insta = "1.43.2"
bitflags = "2.10.0"
clap = { version = "4.5.54", features = ["derive"] }
insta = "1.46.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
tracing = { version = "0.1.41", features = ["max_level_trace", "release_max_level_debug"] }
serde_json = "1.0.149"
tracing = { version = "0.1.44", features = ["max_level_trace", "release_max_level_debug"] }
# 0.3.20 filters out all ANSI codes to "fix a security issue" while also breaking
# everyone who relied on them for color output, with no fallback available.
# https://github.com/tokio-rs/tracing/issues/3378
tracing-subscriber = { version = "=0.3.19", features = ["env-filter"] }
tracy-client = { version = "0.18.3", default-features = false }
tracy-client = { version = "0.18.4", default-features = false }
[workspace.dependencies.smithay]
# version = "0.4.1"
@@ -53,38 +53,37 @@ readme = "README.md"
keywords = ["wayland", "compositor", "tiling", "smithay", "wm"]
[dependencies]
accesskit = { version = "0.21.0", optional = true }
accesskit_unix = { version = "0.17.0", optional = true }
accesskit = { version = "0.22.0", optional = true }
accesskit_unix = { version = "0.18.0", optional = true }
anyhow.workspace = true
arrayvec = "0.7.6"
async-channel = "2.5.0"
async-io = { version = "2.6.0", optional = true }
atomic = "0.6.1"
bitflags.workspace = true
bytemuck = { version = "1.23.2", features = ["derive"] }
bytemuck = { version = "1.24.0", features = ["derive"] }
calloop = { version = "0.14.3", features = ["executor", "futures-io", "signals"] }
clap = { workspace = true, features = ["string"] }
clap_complete = "4.5.58"
clap_complete_nushell = "4.5.8"
clap_complete = "4.5.65"
clap_complete_nushell = "4.5.10"
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.8"
glam = "0.30.10"
input = { version = "0.9.1", features = ["libinput_1_21"] }
keyframe = { version = "1.1.1", default-features = false }
libc = "0.2.176"
libc = "0.2.180"
libdisplay-info = "0.3.0"
log = { version = "0.4.28", features = ["max_level_trace", "release_max_level_debug"] }
log = { version = "0.4.29", features = ["max_level_trace", "release_max_level_debug"] }
niri-config = { version = "25.11.0", path = "niri-config" }
niri-ipc = { version = "25.11.0", path = "niri-ipc", features = ["clap"] }
ordered-float = "5.1.0"
pango = { version = "0.20.12", features = ["v1_44"] }
pangocairo = "0.20.10"
pango = { version = "0.21.5", features = ["v1_44"] }
pangocairo = "0.21.5"
pipewire = { version = "0.9.2", optional = true, features = ["v0_3_33"] }
png = "0.18.0"
portable-atomic = { version = "1.11.1", default-features = false, features = ["float"] }
profiling = "1.0.17"
sd-notify = "0.4.5"
serde.workspace = true
@@ -93,11 +92,10 @@ smithay-drm-extras.workspace = true
tracing-subscriber.workspace = true
tracing.workspace = true
tracy-client.workspace = true
url = { version = "2.5.7", optional = true }
wayland-backend = "0.3.11"
wayland-scanner = "0.31.7"
wayland-backend = "0.3.12"
wayland-scanner = "0.31.8"
xcursor = "0.3.10"
zbus = { version = "5.11.0", optional = true }
zbus = { version = "5.13.0", optional = true }
[dependencies.smithay]
workspace = true
@@ -121,22 +119,25 @@ features = [
approx = "0.5.1"
calloop-wayland-source = "0.4.1"
insta.workspace = true
proptest = "1.8.0"
proptest-derive = { version = "0.6.0", features = ["boxed_union"] }
proptest = "1.9.0"
proptest-derive = { version = "0.7.0", features = ["boxed_union"] }
rayon = "1.11.0"
wayland-client = "0.31.11"
wayland-client = "0.31.12"
xshell = "0.2.7"
[build-dependencies]
pkg-config = "0.3.32"
[features]
default = ["dbus", "systemd", "xdp-gnome-screencast"]
# Enables D-Bus support (serve various freedesktop and GNOME interfaces, accessibility tree, power button handling).
dbus = ["dep:zbus", "dep:async-io", "dep:url", "dep:accesskit", "dep:accesskit_unix"]
dbus = ["dep:zbus", "dep:async-io", "dep:accesskit", "dep:accesskit_unix"]
# Enables systemd integration (global environment, apps in transient scopes).
systemd = ["dbus"]
# Enables screencasting support through xdg-desktop-portal-gnome.
xdp-gnome-screencast = ["dbus", "pipewire"]
# Enables the Tracy profiler instrumentation.
profile-with-tracy = ["profiling/profile-with-tracy", "tracy-client/default"]
profile-with-tracy = ["profiling/profile-with-tracy", "tracy-client/default", "smithay/tracy_gpu_profiling"]
# Enables the on-demand Tracy profiler instrumentation.
profile-with-tracy-ondemand = ["profile-with-tracy", "tracy-client/ondemand", "tracy-client/manual-lifetime"]
# Enables Tracy allocation profiling.
+10
View File
@@ -0,0 +1,10 @@
fn main() {
println!("cargo:rustc-check-cfg=cfg(have_libinput_plugin_system)");
if pkg_config::Config::new()
.atleast_version("1.30.0")
.probe("libinput")
.is_ok()
{
println!("cargo:rustc-cfg=have_libinput_plugin_system")
}
}
+1 -1
View File
@@ -9,6 +9,6 @@ dependencies = [
]
# for KDL highlighting support
# TODO: use the official pygments package once https://github.com/pygments/pygments/pull/2936 is merged
# FIXME: use the official pygments package once https://github.com/pygments/pygments/pull/2936 is merged
[tool.uv.sources]
pygments = { git = "https://github.com/chinatsu/pygments", rev = "0f0b0d4da2839e1285881389155bb4605a0a6dc4" }
+16 -2
View File
@@ -2,14 +2,19 @@
Electron-based applications can run directly on Wayland, but it's not the default.
For Electron > 28, you can set an environment variable:
For Electron ≥ 39, you can use the command-line flag if the app does not default to Wayland:
```
--ozone-platform=wayland
```
For Electron < 39, you can set an environment variable:
```kdl
environment {
ELECTRON_OZONE_PLATFORM_HINT "auto"
}
```
For previous versions, you need to pass command-line flags to the target application:
For Electron ≤ 28, you need to pass command-line flags to the target application:
```
--enable-features=UseOzonePlatform --ozone-platform-hint=auto
```
@@ -22,6 +27,12 @@ If you're having issues with some VSCode hotkeys, try starting `Xwayland` and se
That is, still running VSCode with the Wayland backend, but with `DISPLAY` set to a running Xwayland instance.
Apparently, VSCode currently unconditionally queries the X server for a keymap.
### JetBrains IDEs
JetBrains IDEs can run directly on Wayland, but it's not the default.
For JetBrainsRuntime > 17, you can set the flag `-Dawt.toolkit.name=WLToolkit` inside of `help -> edit custom vm options -> add`.
### WezTerm
> [!NOTE]
@@ -63,6 +74,9 @@ environment {
}
```
Note that the niri environment config does not propagate to apps and shells started by systemd, for example to DankMaterialShell and its application launcher.
You can set the variable in your login shell config (i.e. `~/.bash_profile`) instead, though keep in mind that then it will be set for all compositors, not just niri.
### Fullscreen games
Some video games, both Linux-native and on Wine, have various issues when using non-stacking desktop environments.
+14
View File
@@ -17,6 +17,7 @@ debug {
disable-cursor-plane
disable-direct-scanout
restrict-primary-scanout-to-matching-format
force-disable-connectors-on-resume
render-drm-device "/dev/dri/renderD129"
ignore-drm-device "/dev/dri/renderD128"
ignore-drm-device "/dev/dri/renderD130"
@@ -104,6 +105,19 @@ debug {
}
```
### `force-disable-connectors-on-resume`
Force-disables all outputs upon resuming niri (TTY switch or waking up from suspend).
This causes a modeset/screen blank on all outputs.
If niri rendering is corrupted, or monitors don't light up after a TTY switch, you can try this flag.
```kdl
debug {
force-disable-connectors-on-resume
}
```
### `render-drm-device`
Override the DRM device that niri will use for all rendering.
+24
View File
@@ -114,6 +114,30 @@ window-rule {
}
```
### Optional includes
<sup>Since: next release</sup>
By default, including a nonexistent file will cause an error.
You can allow nonexistent includes by setting `optional=true`:
```kdl,must-fail
// Won't fail if this file doesn't exist.
include optional=true "optional-config.kdl"
// Regular include, will fail if the file doesn't exist.
include "required-config.kdl"
```
When an optional include file is missing, niri will emit a warning in the logs on every config reload.
This reminds you that the file is missing while still loading the config successfully.
The optional file is still watched for changes, so if you create it later, the config will automatically reload and apply the new settings.
Note that `optional` only affects whether a missing file causes an error.
If the file exists but contains invalid syntax or other errors, those errors will still cause a parsing failure.
### Merging
Most config sections are merged between includes, meaning that you can set only a few properties, and only those properties will change.
+11
View File
@@ -382,6 +382,17 @@ binds {
}
```
<sup>Since: next release</sup> You can show the mouse pointer on window screenshots with the `show-pointer=true` property.
The pointer will be included only if the window is currently receiving pointer input (usually this means the pointer is on top of the window).
```kdl
binds {
// The pointer will be visible on the screenshot
// if it's on top of the window.
Alt+Print { screenshot-window show-pointer=true; }
}
```
#### `toggle-keyboard-shortcuts-inhibit`
<sup>Since: 25.02</sup>
@@ -141,6 +141,13 @@ environment {
}
```
Note that these variables do not propagate to the systemd global environment, so tools and applications started by systemd do not see them.
In particular, if you start a desktop shell like DankMaterialShell through systemd, then use its built-in application launcher, the apps won't see these environment variables.
If you want all processes to see the environment variables, you can set them in your login shell config instead (i.e. `~/.bash_profile`).
The `niri-session` shell script runs through the login shell and imports all environment variables to systemd before starting niri.
Keep in mind that all compositors will see variables set in the login shell, not just niri.
### `cursor`
Change the theme and size of the cursor as well as set the `XCURSOR_THEME` and `XCURSOR_SIZE` environment variables.
@@ -39,6 +39,9 @@ switch-events {
These events trigger when a convertible laptop goes into or out of tablet mode.
In tablet mode, the keyboard and mouse are usually inaccessible, so you can use these events to activate the on-screen keyboard.
> [!NOTE]
> The commands below are just examples, you will need to provide your own on-screen keyboard, such as [sysboard](https://github.com/System64fumo/sysboard) or [wvkbd](https://github.com/jjsullivan5196/wvkbd).
```kdl
switch-events {
tablet-mode-on { spawn "bash" "-c" "gsettings set org.gnome.desktop.a11y.applications screen-keyboard-enabled true"; }
+1 -1
View File
@@ -45,7 +45,7 @@ hotkey-overlay {
To run X11 apps, you can use [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite).
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`
Keep in mind that you can run many Electron apps such as VSCode or Discord natively on Wayland by passing the right flags, as described [here](./Application-Issues.md#electron-applications).
### Why doesn't niri integrate Xwayland like other compositors?
+4 -2
View File
@@ -12,7 +12,7 @@ systemctl --user add-wants niri.service dms
Arch Linux (via [paru](https://github.com/morganamilo/paru)):
```
sudo pacman -Syu niri xwayland-satellite xdg-desktop-portal-gnome xdg-desktop-portal-gtk alacritty
paru -S dms-shell-bin matugen wl-clipboard cliphist cava qt6-multimedia-ffmpeg
paru -S dms-shell-bin matugen cava qt6-multimedia-ffmpeg
systemctl --user add-wants niri.service dms
```
@@ -29,6 +29,8 @@ Or, if not using a display manager, run `niri-session` on a TTY.
The default niri config will run Waybar, so you might get two bars on screen.
To fix this, stop Waybar with `pkill waybar` command, then open `~/.config/niri/config.kdl` and delete the `spawn-at-startup "waybar"` line.
Check the DankMaterialShell's [compositor setup page](https://danklinux.com/docs/dankmaterialshell/compositors#niri-configuration) to learn how to configure DMS-specific binds and other niri integrations.
## Slower and more considered start
The easiest way to get niri is to install one of the distribution packages.
@@ -223,7 +225,7 @@ This defaults to `/usr/bin/niri`.
| `resources/niri.service` (systemd) | `/etc/systemd/user/` |
| `resources/niri-shutdown.target` (systemd) | `/etc/systemd/user/` |
| `resources/dinit/niri` (dinit) | `/etc/dinit.d/user/` |
| `resources/dinit/niri-shutdown` (dinit) | `/etc/dinit.d/user/` |
| `resources/dinit/niri.target` (dinit) | `/etc/dinit.d/user/` |
[Alacritty]: https://github.com/alacritty/alacritty
[fuzzel]: https://codeberg.org/dnkl/fuzzel
+3
View File
@@ -26,6 +26,9 @@ Note that if you're using the provided `resources/niri-portals.conf`, you also n
If you do not want to install `nautilus` (say you use `nemo` instead), you can set `org.freedesktop.impl.portal.FileChooser=gtk;` in `niri-portals.conf` to use the GTK portal for file chooser dialogues.
> [!WARNING]
> Do not set the `GDK_BACKEND` environment variable globally as this will break the screencast portal.
### Authentication Agent
Required when apps need to ask for root permissions. Something like `plasma-polkit-agent` works fine. Start it [with systemd](./Example-systemd-Setup.md) or with [`spawn-at-startup`](./Configuration:-Miscellaneous.md#spawn-at-startup).
+1 -1
View File
@@ -11,7 +11,7 @@ When this file is present, niri *will not* automatically create a config at `~/.
Keep in mind that we update the default config in new releases, so if you have a custom `/etc/niri/config.kdl`, you likely want to inspect and apply the relevant changes too.
Splitting the niri config file into multiple files, or includes, are not supported yet.
You can split the niri config file into multiple files using [`include`](./Configuration:-Include.md).
### Xwayland
+20 -1
View File
@@ -24,12 +24,31 @@ To do that, put files into the correct directories according to this table.
| `resources/niri.service` (systemd) | `/usr/lib/systemd/user/` |
| `resources/niri-shutdown.target` (systemd) | `/usr/lib/systemd/user/` |
| `resources/dinit/niri` (dinit) | `/usr/lib/dinit.d/user/` |
| `resources/dinit/niri-shutdown` (dinit) | `/usr/lib/dinit.d/user/` |
| `resources/dinit/niri.target` (dinit) | `/usr/lib/dinit.d/user/` |
Doing this will make niri appear in GDM and other display managers.
See the [Integrating niri](./Integrating-niri.md) page for further information on distribution integration.
### Recommended dependencies
First of all, make sure niri depends on `libwayland-server`.
This library is currently loaded dynamically, so it's not picked up as a dependency at niri build time.
Then, the following dependencies are optional, but strongly recommended.
Set them as automatically-installed optional dependencies, if possible.
- `xwayland-satellite`: required to run X11 applications (Steam, Discord, etc.).
- `xdg-desktop-portal-gnome`: required for screencasting.
- `xdg-desktop-portal-gtk`: configured as the fallback portal in `niri-portals.conf`.
(This is in general the standard fallback portal that you want installed.)
- `gnome-keyring`: configured as the Secret portal provider in `niri-portals.conf`.
- Your distro's GPU driver package, such as `mesa-dri-drivers` and `mesa-libEGL`.
Working hardware acceleration is required for running niri.
- Some notification daemon like `mako`, generally required for apps to work correctly.
Finally, you may want to auto-install some of the applications bound in niri's [default configuration file](https://github.com/YaLTeR/niri/blob/main/resources/default-config.kdl) (search for `spawn`), such as `alacritty` and `fuzzel`.
### Running tests
A bulk of our tests spawn niri compositor instances and test Wayland clients.
+2 -1
View File
@@ -64,7 +64,7 @@
postPatch = ''
patchShebangs resources/niri-session
substituteInPlace resources/niri.service \
--replace-fail '/usr/bin' "$out/bin"
--replace-fail 'ExecStart=niri' "ExecStart=$out/bin/niri"
'';
cargoLock = {
@@ -148,6 +148,7 @@
"-Wl,--pop-state"
]
);
NIRI_BUILD_COMMIT = self.shortRev;
};
passthru = {
+2 -2
View File
@@ -9,11 +9,11 @@ repository.workspace = true
[dependencies]
bitflags.workspace = true
csscolorparser = "0.7.2"
csscolorparser = "0.8.1"
knuffel = "3.2.0"
miette = { version = "5.10.0", features = ["fancy-no-backtrace"] }
niri-ipc = { version = "25.11.0", path = "../niri-ipc" }
regex = "1.11.3"
regex = "1.12.2"
smithay = { workspace = true, features = ["backend_libinput"] }
tracing.workspace = true
tracy-client.workspace = true
+9 -1
View File
@@ -132,6 +132,7 @@ pub enum Action {
),
ScreenshotWindow(
#[knuffel(property(name = "write-to-disk"), default = true)] bool,
#[knuffel(property(name = "show-pointer"), default = false)] bool,
// Path; not settable from knuffel
Option<String>,
),
@@ -139,6 +140,7 @@ pub enum Action {
ScreenshotWindowById {
id: u64,
write_to_disk: bool,
show_pointer: bool,
path: Option<String>,
},
ToggleKeyboardShortcutsInhibit,
@@ -354,6 +356,8 @@ pub enum Action {
SetDynamicCastWindowById(u64),
SetDynamicCastMonitor(#[knuffel(argument)] Option<String>),
ClearDynamicCastTarget,
#[knuffel(skip)]
StopCast(u64),
ToggleOverview,
OpenOverview,
CloseOverview,
@@ -407,15 +411,18 @@ impl From<niri_ipc::Action> for Action {
niri_ipc::Action::ScreenshotWindow {
id: None,
write_to_disk,
show_pointer,
path,
} => Self::ScreenshotWindow(write_to_disk, path),
} => Self::ScreenshotWindow(write_to_disk, show_pointer, path),
niri_ipc::Action::ScreenshotWindow {
id: Some(id),
write_to_disk,
show_pointer,
path,
} => Self::ScreenshotWindowById {
id,
write_to_disk,
show_pointer,
path,
},
niri_ipc::Action::ToggleKeyboardShortcutsInhibit {} => {
@@ -685,6 +692,7 @@ impl From<niri_ipc::Action> for Action {
Self::SetDynamicCastMonitor(output)
}
niri_ipc::Action::ClearDynamicCastTarget {} => Self::ClearDynamicCastTarget,
niri_ipc::Action::StopCast { session_id } => Self::StopCast(session_id),
niri_ipc::Action::ToggleOverview {} => Self::ToggleOverview,
niri_ipc::Action::OpenOverview {} => Self::OpenOverview,
niri_ipc::Action::CloseOverview {} => Self::CloseOverview,
+4
View File
@@ -12,6 +12,7 @@ pub struct Debug {
pub disable_direct_scanout: bool,
pub keep_max_bpc_unchanged: bool,
pub restrict_primary_scanout_to_matching_format: bool,
pub force_disable_connectors_on_resume: bool,
pub render_drm_device: Option<PathBuf>,
pub ignored_drm_devices: Vec<PathBuf>,
pub force_pipewire_invalid_modifier: bool,
@@ -44,6 +45,8 @@ pub struct DebugPart {
pub keep_max_bpc_unchanged: Option<Flag>,
#[knuffel(child)]
pub restrict_primary_scanout_to_matching_format: Option<Flag>,
#[knuffel(child)]
pub force_disable_connectors_on_resume: Option<Flag>,
#[knuffel(child, unwrap(argument))]
pub render_drm_device: Option<PathBuf>,
#[knuffel(children(name = "ignore-drm-device"), unwrap(argument))]
@@ -81,6 +84,7 @@ impl MergeWith<DebugPart> for Debug {
disable_direct_scanout,
keep_max_bpc_unchanged,
restrict_primary_scanout_to_matching_format,
force_disable_connectors_on_resume,
force_pipewire_invalid_modifier,
emulate_zero_presentation_time,
disable_resize_throttling,
+56 -5
View File
@@ -291,7 +291,51 @@ where
}
"include" => {
let path: PathBuf = utils::parse_arg_node("include", node, ctx)?;
// Parse the path argument
let mut iter_args = node.arguments.iter();
let path_val = iter_args.next().ok_or_else(|| {
DecodeError::missing(
node,
"additional argument for include path is required",
)
})?;
let path: PathBuf = knuffel::traits::DecodeScalar::decode(path_val, ctx)?;
// Check for extra arguments
if let Some(val) = iter_args.next() {
ctx.emit_error(DecodeError::unexpected(
&val.literal,
"argument",
"unexpected argument",
));
}
// Parse the optional property
let mut optional = false;
for (name, val) in &node.properties {
match &***name {
"optional" => {
optional = knuffel::traits::DecodeScalar::decode(val, ctx)?;
}
name_str => {
ctx.emit_error(DecodeError::unexpected(
name,
"property",
format!("unexpected property `{}`", name_str.escape_default()),
));
}
}
}
// Check for unexpected children
for child in node.children() {
ctx.emit_error(DecodeError::unexpected(
child,
"node",
format!("unexpected node `{}`", child.node_name.escape_default()),
));
}
let base = ctx.get::<BasePath>().unwrap();
let path = base.0.join(path);
@@ -369,10 +413,16 @@ where
}
}
Err(err) => {
ctx.emit_error(DecodeError::missing(
node,
format!("failed to read included config from {path:?}: {err}"),
));
if optional && err.kind() == std::io::ErrorKind::NotFound {
// Warn about missing optional includes
warn!("optional include not found: {path:?}");
} else {
// Report all other errors normally
ctx.emit_error(DecodeError::missing(
node,
format!("failed to read included config from {path:?}: {err}"),
));
}
}
}
}
@@ -2134,6 +2184,7 @@ mod tests {
disable_direct_scanout: false,
keep_max_bpc_unchanged: false,
restrict_primary_scanout_to_matching_format: false,
force_disable_connectors_on_resume: false,
render_drm_device: Some(
"/dev/dri/renderD129",
),
+1 -1
View File
@@ -13,7 +13,7 @@ readme = "README.md"
[dependencies]
clap = { workspace = true, optional = true }
schemars = { version = "1.0.4", optional = true }
schemars = { version = "1.2.0", optional = true }
serde.workspace = true
serde_json.workspace = true
+112 -1
View File
@@ -117,6 +117,8 @@ pub enum Request {
ReturnError,
/// Request information about the overview.
OverviewState,
/// Request information about screencasts.
Casts,
}
/// Reply from niri to client.
@@ -161,6 +163,8 @@ pub enum Response {
OutputConfigChanged(OutputConfigChanged),
/// Information about the overview.
OverviewState(Overview),
/// Information about screencasts.
Casts(Vec<Cast>),
}
/// Overview information.
@@ -264,6 +268,13 @@ pub enum Action {
#[cfg_attr(feature = "clap", arg(short = 'd', long, action = clap::ArgAction::Set, default_value_t = true))]
write_to_disk: bool,
/// Whether to include the mouse pointer in the screenshot.
///
/// The pointer will be included only if the window is currently receiving pointer input
/// (usually this means the pointer is on top of the window).
#[cfg_attr(feature = "clap", arg(short = 'p', long, action = clap::ArgAction::Set, default_value_t = false))]
show_pointer: bool,
/// Path to save the screenshot to.
///
/// The path must be absolute, otherwise an error is returned.
@@ -429,7 +440,7 @@ pub enum Action {
},
/// Consume the window to the right into the focused column.
ConsumeWindowIntoColumn {},
/// Expel the focused window from the column.
/// Expel the bottom window from the focused column.
ExpelWindowFromColumn {},
/// Swap focused window with one to the right.
SwapWindowRight {},
@@ -887,6 +898,16 @@ pub enum Action {
},
/// Clear the dynamic cast target, making it show nothing.
ClearDynamicCastTarget {},
/// Stop a PipeWire screencast.
///
/// wlr-screencopy screencasts cannot currently be stopped via IPC.
StopCast {
/// Session ID of the screencast to stop.
///
/// If the session has multiple screencast streams, this will stop all of them.
#[cfg_attr(feature = "clap", arg(long))]
session_id: u64,
},
/// Toggle (open/close) the Overview.
ToggleOverview {},
/// Open the Overview.
@@ -1466,6 +1487,78 @@ pub struct LayerSurface {
pub keyboard_interactivity: LayerSurfaceKeyboardInteractivity,
}
/// A screencast.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct Cast {
/// Stream ID of the screencast that uniquely identifies it.
pub stream_id: u64,
/// Session ID of the screencast.
///
/// A session can have multiple screencast streams. Then multiple `Cast`s will have the same
/// `session_id`. Though, usually there's only one stream per session.
///
/// Do not confuse `session_id` with [`stream_id`](Self::stream_id).
pub session_id: u64,
/// Kind of this screencast.
pub kind: CastKind,
/// Target being captured.
pub target: CastTarget,
/// Whether this is a Dynamic Cast Target screencast.
///
/// Meaning that actions like `SetDynamicCastWindow` will act on this screencast.
///
/// Keep in mind that the target can change even if this is `false`.
pub is_dynamic_target: bool,
/// Whether the cast is currently streaming frames.
///
/// This can be `false` for example when switching away to a different scene in OBS, which
/// pauses the stream.
pub is_active: bool,
/// Process ID of the screencast consumer, if known.
///
/// Currently, only wlr-screencopy screencasts can have a pid.
pub pid: Option<i32>,
/// PipeWire node ID of the screencast stream.
///
/// This is `None` for wlr-screencopy casts, and also for PipeWire casts before the node is
/// created (when the cast is just starting up).
pub pw_node_id: Option<u32>,
}
/// Kind of screencast.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub enum CastKind {
/// PipeWire screencast, typically via xdg-desktop-portal-gnome.
PipeWire,
/// wlr-screencopy protocol screencast.
///
/// Tools like wf-recorder, and the xdg-desktop-portal-wlr portal.
///
/// Only wlr-screencopy with damage tracking is reported here. Screencopy without damage is
/// treated as a regular screenshot and not reported as a screencast.
WlrScreencopy,
}
/// Target of a screencast.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub enum CastTarget {
/// The target is not yet set, or was cleared.
Nothing {},
/// Casting an output.
Output {
/// Name of the screencasted output.
name: String,
},
/// Casting a window.
Window {
/// ID of the screencasted window.
id: u64,
},
}
/// A compositor event.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
@@ -1588,6 +1681,24 @@ pub enum Event {
/// be converted to a `String` (e.g. contained invalid UTF-8 bytes).
path: Option<String>,
},
/// The screencasts have changed.
CastsChanged {
/// The new screencast information.
///
/// This configuration completely replaces the previous configuration. I.e. if any casts
/// are missing from here, then they were stopped.
casts: Vec<Cast>,
},
/// A screencast started, or an existing cast changed.
CastStartedOrChanged {
/// The cast that started or changed.
cast: Cast,
},
/// A screencast stopped.
CastStopped {
/// Stream ID of the stopped screencast.
stream_id: u64,
},
}
impl From<Duration> for Timestamp {
+37 -1
View File
@@ -9,7 +9,7 @@
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use crate::{Event, KeyboardLayouts, Window, Workspace};
use crate::{Cast, Event, KeyboardLayouts, Window, Workspace};
/// Part of the state communicated via the event stream.
pub trait EventStreamStatePart {
@@ -46,6 +46,9 @@ pub struct EventStreamState {
/// State of the config.
pub config: ConfigState,
/// State of screencasts.
pub casts: CastsState,
}
/// The workspaces state communicated over the event stream.
@@ -83,6 +86,13 @@ pub struct ConfigState {
pub failed: bool,
}
/// The casts state communicated over the event stream.
#[derive(Debug, Default)]
pub struct CastsState {
/// Map from a stream id to the screencast.
pub casts: HashMap<u64, Cast>,
}
impl EventStreamStatePart for EventStreamState {
fn replicate(&self) -> Vec<Event> {
let mut events = Vec::new();
@@ -91,6 +101,7 @@ impl EventStreamStatePart for EventStreamState {
events.extend(self.keyboard_layouts.replicate());
events.extend(self.overview.replicate());
events.extend(self.config.replicate());
events.extend(self.casts.replicate());
events
}
@@ -100,6 +111,7 @@ impl EventStreamStatePart for EventStreamState {
let event = self.keyboard_layouts.apply(event)?;
let event = self.overview.apply(event)?;
let event = self.config.apply(event)?;
let event = self.casts.apply(event)?;
Some(event)
}
}
@@ -285,3 +297,27 @@ impl EventStreamStatePart for ConfigState {
None
}
}
impl EventStreamStatePart for CastsState {
fn replicate(&self) -> Vec<Event> {
let casts = self.casts.values().cloned().collect();
vec![Event::CastsChanged { casts }]
}
fn apply(&mut self, event: Event) -> Option<Event> {
match event {
Event::CastsChanged { casts } => {
self.casts = casts.into_iter().map(|c| (c.stream_id, c)).collect();
}
Event::CastStartedOrChanged { cast } => {
self.casts.insert(cast.stream_id, cast);
}
Event::CastStopped { stream_id } => {
let cast = self.casts.remove(&stream_id);
cast.expect("stopped cast was missing from the map");
}
event => return Some(event),
}
None
}
}
+2 -2
View File
@@ -8,9 +8,9 @@ edition.workspace = true
repository.workspace = true
[dependencies]
adw = { version = "0.7.2", package = "libadwaita", features = ["v1_4"] }
adw = { version = "0.8.1", package = "libadwaita", features = ["v1_4"] }
anyhow.workspace = true
gtk = { version = "0.9.7", package = "gtk4", features = ["v4_12"] }
gtk = { version = "0.10.3", package = "gtk4", features = ["v4_12"] }
niri = { version = "25.11.0", path = ".." }
niri-config = { version = "25.11.0", path = "../niri-config" }
smithay.workspace = true
+2 -5
View File
@@ -89,11 +89,8 @@ impl TestCase for GradientArea {
1.,
1.,
);
rv.extend(
self.border
.render(renderer, g_loc)
.map(|elem| Box::new(elem) as _),
);
self.border
.render(renderer, g_loc, &mut |elem| rv.push(Box::new(elem) as _));
rv.extend(
[BorderRenderElement::new(
+6 -4
View File
@@ -268,12 +268,14 @@ impl TestCase for Layout {
_size: Size<i32, Physical>,
) -> Vec<Box<dyn RenderElement<GlesRenderer>>> {
self.layout.update_render_elements(Some(&self.output));
let mut rv = Vec::new();
self.layout
.monitor_for_output(&self.output)
.unwrap()
.render_elements(renderer, RenderTarget::Output, true)
.flat_map(|(_, _, iter)| iter)
.map(|elem| Box::new(elem) as _)
.collect()
.render_workspaces(renderer, RenderTarget::Output, true, &mut |elem| {
rv.push(Box::new(elem) as _)
});
rv
}
}
+10 -4
View File
@@ -119,9 +119,15 @@ impl TestCase for Tile {
true,
Rectangle::new(Point::from((-location.x, -location.y)), size.to_logical(1.)),
);
self.tile
.render(renderer, location, true, RenderTarget::Output)
.map(|elem| Box::new(elem) as _)
.collect()
let mut rv = Vec::new();
self.tile.render(
renderer,
location,
true,
RenderTarget::Output,
&mut |elem| rv.push(Box::new(elem) as _),
);
rv
}
}
+10 -11
View File
@@ -52,16 +52,15 @@ impl TestCase for Window {
.to_f64()
.downscale(2.);
self.window
.render(
renderer,
location,
Scale::from(1.),
1.,
RenderTarget::Output,
)
.into_iter()
.map(|elem| Box::new(elem) as _)
.collect()
let mut rv = Vec::new();
self.window.render_normal(
renderer,
location,
Scale::from(1.),
1.,
RenderTarget::Output,
&mut |elem| rv.push(Box::new(elem) as _),
);
rv
}
}
+2 -1
View File
@@ -255,7 +255,8 @@ mod imp {
glib::wrapper! {
pub struct SmithayView(ObjectSubclass<imp::SmithayView>)
@extends gtk::Widget;
@extends gtk::Widget,
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
}
impl SmithayView {
+16 -22
View File
@@ -9,7 +9,7 @@ use niri::layout::{
use niri::render_helpers::offscreen::OffscreenData;
use niri::render_helpers::renderer::NiriRenderer;
use niri::render_helpers::solid_color::{SolidColorBuffer, SolidColorRenderElement};
use niri::render_helpers::{RenderTarget, SplitElements};
use niri::render_helpers::RenderTarget;
use niri::utils::transaction::Transaction;
use niri::window::ResolvedWindowRules;
use smithay::backend::renderer::element::Kind;
@@ -149,36 +149,30 @@ impl LayoutElement for TestWindow {
false
}
fn render<R: NiriRenderer>(
fn render_normal<R: NiriRenderer>(
&self,
_renderer: &mut R,
location: Point<f64, Logical>,
_scale: Scale<f64>,
alpha: f32,
_target: RenderTarget,
) -> SplitElements<LayoutElementRenderElement<R>> {
push: &mut dyn FnMut(LayoutElementRenderElement<R>),
) {
let inner = self.inner.borrow();
SplitElements {
normal: vec![
SolidColorRenderElement::from_buffer(
&inner.buffer,
location,
alpha,
Kind::Unspecified,
)
push(
SolidColorRenderElement::from_buffer(&inner.buffer, location, alpha, Kind::Unspecified)
.into(),
SolidColorRenderElement::from_buffer(
&inner.csd_shadow_buffer,
location
- Point::from((inner.csd_shadow_width, inner.csd_shadow_width)).to_f64(),
alpha,
Kind::Unspecified,
)
.into(),
],
popups: vec![],
}
);
push(
SolidColorRenderElement::from_buffer(
&inner.csd_shadow_buffer,
location - Point::from((inner.csd_shadow_width, inner.csd_shadow_width)).to_f64(),
alpha,
Kind::Unspecified,
)
.into(),
);
}
fn request_size(
+3
View File
@@ -85,6 +85,9 @@ BuildRequires: mesa-libEGL
Requires: mesa-dri-drivers
Requires: mesa-libEGL
# Loaded through dlopen
Requires: libwayland-server
# Integrated Xwayland support. Not packaged on EPEL
%if 0%{?fedora}
Requires: xwayland-satellite >= 0.7
+6
View File
@@ -558,6 +558,12 @@ binds {
Mod+F { maximize-column; }
Mod+Shift+F { fullscreen-window; }
// While maximize-column leaves gaps and borders around the window,
// maximize-window-to-edges doesn't: the window expands to the edges of the screen.
// This bind corresponds to normal window maximizing,
// e.g. by double-clicking on the titlebar.
Mod+M { maximize-window-to-edges; }
// Expand the focused column to space not taken up by other fully visible columns.
// Makes the column "fill the rest of the space".
Mod+Ctrl+F { expand-column-to-available-width; }
+7 -8
View File
@@ -1,8 +1,7 @@
type = process
command = niri --session
restart = false
working-dir = $HOME
depends-on = dbus
after = niri-shutdown
chain-to = niri-shutdown
options: always-chain
type = process
command = niri --session
restart = false
working-dir = $HOME
ready-notification = pipevar:NOTIFY_FD
logfile = $HOME/.local/share/niri/niri.log
depends-on: dbus
-3
View File
@@ -1,3 +0,0 @@
type = scripted
command = dinitctl -u setenv WAYLAND_DISPLAY= XDG_SESSION_TYPE= XDG_CURRENT_DESKTOP= NIRI_SOCKET=
restart = false
+6
View File
@@ -0,0 +1,6 @@
type = internal
restart = false
depends-on: niri
waits-for.d: $XDG_CONFIG_HOME/dinit.d/niri.d/
waits-for.d: $HOME/.config/dinit.d/niri.d/
waits-for.d: /etc/dinit.d/user/niri.d/
+26 -2
View File
@@ -59,13 +59,37 @@ elif hash dinitctl >/dev/null 2>&1; then
fi
# Make sure there's no already running session.
if dinitctl --user is-started niri >/dev/null 2>&1; then
if dinitctl --quiet --user is-started niri 2>/dev/null; then
echo 'A niri session is already running.'
exit 1
fi
# Import the login manager environment into dinit
# Might not work correctly for multiline variable names, but
# it is reasonable to assume there are none
awk 'BEGIN{for(v in ENVIRON) if (v != "AWKPATH" && v != "AWKLIBPATH") print v}' 2>/dev/null | xargs dinitctl --quiet --user setenv 2>/dev/null
# Usually the dbus service would start as niri's dependency and inherit
# environment from dinit, but in case it has already started we need
# to update its environment.
if hash dbus-update-activation-environment >/dev/null 2>&1; then
dbus-update-activation-environment --all >/dev/null 2>&1
fi
# Create the directory for the logfile, if doesn't exist
mkdir --parents $HOME/.local/share/niri
# Start niri
dinitctl --user start niri
dinitctl --quiet --user start niri.target 2>&1
# Wait for termination
dinit-monitor --user --initial -c $'sh -c "
if [ "%s" = "stopped" ] || [ "%s" = "failed" ]; then
ppid=$(ps -o ppid= -p $$)
kill $ppid
fi"' niri >/dev/null 2>&1
# Unset environment that we've set.
dinitctl --quiet --user unsetenv WAYLAND_DISPLAY DISPLAY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP NIRI_SOCKET 2>/dev/null
else
echo "No systemd or dinit detected, please use niri --session instead."
fi
+1 -1
View File
@@ -11,4 +11,4 @@ Before=xdg-desktop-autostart.target
[Service]
Slice=session.slice
Type=notify
ExecStart=/usr/bin/niri --session
ExecStart=niri --session
+160 -97
View File
@@ -434,6 +434,7 @@ impl Tty {
.unwrap();
let mut libinput = Libinput::new_with_udev(LibinputSessionInterface::from(session.clone()));
unsafe { init_libinput_plugin_system(&libinput) };
{
let _span = tracy_client::span!("Libinput::udev_assign_seat");
libinput.udev_assign_seat(&seat_name)
@@ -646,7 +647,16 @@ impl Tty {
// It hasn't been removed, update its state as usual.
let device = self.devices.get_mut(&node).unwrap();
if let Err(err) = device.drm.activate(false) {
// Someone on an old device hit what seems to be a driver bug without this:
// https://github.com/YaLTeR/niri/issues/3048
let force_disable = self
.config
.borrow()
.debug
.force_disable_connectors_on_resume;
if let Err(err) = device.drm.activate(force_disable) {
warn!("error activating DRM device: {err:?}");
}
if let Some(lease_state) = &mut device.drm_lease_state {
@@ -1055,6 +1065,7 @@ impl Tty {
if let Err(err) = surface.compositor.reset_state() {
warn!("error resetting DrmCompositor state: {err:?}");
}
surface.compositor.reset_buffers();
}
}
@@ -3324,6 +3335,50 @@ fn make_output_name(
}
}
/// Initializes the libinput plugin system.
///
/// # Safety
///
/// This function must be called before libinput iterates through the devices, i.e. before
/// libinput_udev_assign_seat() or the first call to libinput_path_add_device().
unsafe fn init_libinput_plugin_system(libinput: &Libinput) {
#[cfg(have_libinput_plugin_system)]
unsafe {
use std::ffi::{c_char, c_int, CString};
use std::os::unix::ffi::OsStringExt;
use directories::BaseDirs;
use input::ffi::libinput;
use input::AsRaw as _;
extern "C" {
fn libinput_plugin_system_append_path(libinput: *const libinput, path: *const c_char);
fn libinput_plugin_system_append_default_paths(libinput: *const libinput);
fn libinput_plugin_system_load_plugins(
libinput: *const libinput,
flags: c_int,
) -> c_int;
}
const LIBINPUT_PLUGIN_SYSTEM_FLAG_NONE: c_int = 0;
let libinput = libinput.as_raw();
// Also load plugins from $XDG_CONFIG_HOME/libinput/plugins.
if let Some(dirs) = BaseDirs::new() {
let mut plugins_dir = dirs.config_dir().to_path_buf();
plugins_dir.push("libinput");
plugins_dir.push("plugins");
if let Ok(plugins_dir) = CString::new(plugins_dir.into_os_string().into_vec()) {
libinput_plugin_system_append_path(libinput, plugins_dir.as_ptr());
}
}
libinput_plugin_system_append_default_paths(libinput);
libinput_plugin_system_load_plugins(libinput, LIBINPUT_PLUGIN_SYSTEM_FLAG_NONE);
}
#[cfg(not(have_libinput_plugin_system))]
let _ = libinput;
}
#[cfg(test)]
mod tests {
use insta::assert_debug_snapshot;
@@ -3347,30 +3402,32 @@ mod tests {
hsync_polarity: HSyncPolarity::NHSync,
vsync_polarity: VSyncPolarity::PVSync,
};
assert_debug_snapshot!(calculate_drm_mode_from_modeline(&modeline1).unwrap(), @"Mode {
name: \"1920x1080@59.96\",
clock: 173000,
size: (
1920,
1080,
),
hsync: (
2048,
2248,
2576,
),
vsync: (
1083,
1088,
1120,
),
hskew: 0,
vscan: 0,
vrefresh: 60,
mode_type: ModeTypeFlags(
USERDEF,
),
}");
assert_debug_snapshot!(calculate_drm_mode_from_modeline(&modeline1).unwrap(), @r#"
Mode {
name: "1920x1080@59.96",
clock: 173000,
size: (
1920,
1080,
),
hsync: (
2048,
2248,
2576,
),
vsync: (
1083,
1088,
1120,
),
hskew: 0,
vscan: 0,
vrefresh: 60,
mode_type: ModeTypeFlags(
USERDEF,
),
}
"#);
let modeline2 = Modeline {
clock: 452.5,
hdisplay: 1920,
@@ -3384,82 +3441,88 @@ mod tests {
hsync_polarity: HSyncPolarity::NHSync,
vsync_polarity: VSyncPolarity::PVSync,
};
assert_debug_snapshot!(calculate_drm_mode_from_modeline(&modeline2).unwrap(), @"Mode {
name: \"1920x1080@143.88\",
clock: 452500,
size: (
1920,
1080,
),
hsync: (
2088,
2296,
2672,
),
vsync: (
1083,
1088,
1177,
),
hskew: 0,
vscan: 0,
vrefresh: 144,
mode_type: ModeTypeFlags(
USERDEF,
),
}");
assert_debug_snapshot!(calculate_drm_mode_from_modeline(&modeline2).unwrap(), @r#"
Mode {
name: "1920x1080@143.88",
clock: 452500,
size: (
1920,
1080,
),
hsync: (
2088,
2296,
2672,
),
vsync: (
1083,
1088,
1177,
),
hskew: 0,
vscan: 0,
vrefresh: 144,
mode_type: ModeTypeFlags(
USERDEF,
),
}
"#);
}
#[test]
fn test_calc_cvt() {
// Crosschecked with other calculators like the cvt commandline utility.
assert_debug_snapshot!(calculate_mode_cvt(1920, 1080, 60.0), @"Mode {
name: \"1920x1080@59.96\",
clock: 173000,
size: (
1920,
1080,
),
hsync: (
2048,
2248,
2576,
),
vsync: (
1083,
1088,
1120,
),
hskew: 0,
vscan: 0,
vrefresh: 60,
mode_type: ModeTypeFlags(
USERDEF,
),
}");
assert_debug_snapshot!(calculate_mode_cvt(1920, 1080, 144.0), @"Mode {
name: \"1920x1080@143.88\",
clock: 452500,
size: (
1920,
1080,
),
hsync: (
2088,
2296,
2672,
),
vsync: (
1083,
1088,
1177,
),
hskew: 0,
vscan: 0,
vrefresh: 144,
mode_type: ModeTypeFlags(
USERDEF,
),
}");
assert_debug_snapshot!(calculate_mode_cvt(1920, 1080, 60.0), @r#"
Mode {
name: "1920x1080@59.96",
clock: 173000,
size: (
1920,
1080,
),
hsync: (
2048,
2248,
2576,
),
vsync: (
1083,
1088,
1120,
),
hskew: 0,
vscan: 0,
vrefresh: 60,
mode_type: ModeTypeFlags(
USERDEF,
),
}
"#);
assert_debug_snapshot!(calculate_mode_cvt(1920, 1080, 144.0), @r#"
Mode {
name: "1920x1080@143.88",
clock: 452500,
size: (
1920,
1080,
),
hsync: (
2088,
2296,
2672,
),
vsync: (
1083,
1088,
1177,
),
hskew: 0,
vscan: 0,
vrefresh: 144,
mode_type: ModeTypeFlags(
USERDEF,
),
}
"#);
}
}
+2
View File
@@ -107,6 +107,8 @@ pub enum Msg {
RequestError,
/// Print the overview state.
OverviewState,
/// List screencasts.
Casts,
}
#[derive(Clone, Debug, clap::ValueEnum)]
+18 -20
View File
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use std::mem;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use serde::Deserialize;
@@ -11,6 +11,7 @@ use zbus::{fdo, interface, ObjectServer};
use super::Start;
use crate::backend::IpcOutputMap;
use crate::utils::{CastSessionId, CastStreamId};
#[derive(Clone)]
pub struct ScreenCast {
@@ -22,7 +23,7 @@ pub struct ScreenCast {
#[derive(Clone)]
pub struct Session {
id: usize,
id: CastSessionId,
ipc_outputs: Arc<Mutex<IpcOutputMap>>,
to_niri: calloop::channel::Sender<ScreenCastToNiri>,
#[allow(clippy::type_complexity)]
@@ -30,7 +31,7 @@ pub struct Session {
stopped: Arc<AtomicBool>,
}
#[derive(Debug, Default, Deserialize, Type, Clone, Copy)]
#[derive(Debug, Default, Deserialize, Type, Clone, Copy, PartialEq, Eq)]
pub enum CursorMode {
#[default]
Hidden = 0,
@@ -58,12 +59,10 @@ struct RecordWindowProperties {
_is_recording: Option<bool>,
}
static STREAM_ID: AtomicUsize = AtomicUsize::new(0);
#[derive(Clone)]
pub struct Stream {
id: usize,
session_id: usize,
id: CastStreamId,
session_id: CastSessionId,
target: StreamTarget,
cursor_mode: CursorMode,
was_started: Arc<AtomicBool>,
@@ -94,14 +93,14 @@ struct StreamParameters {
pub enum ScreenCastToNiri {
StartCast {
session_id: usize,
stream_id: usize,
session_id: CastSessionId,
stream_id: CastStreamId,
target: StreamTargetId,
cursor_mode: CursorMode,
signal_ctx: SignalEmitter<'static>,
},
StopCast {
session_id: usize,
session_id: CastSessionId,
},
}
@@ -118,9 +117,8 @@ 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 session_id = CastSessionId::next();
let path = format!("/org/gnome/Mutter/ScreenCast/Session/u{}", session_id.get());
let path = OwnedObjectPath::try_from(path).unwrap();
let session = Session::new(session_id, self.ipc_outputs.clone(), self.to_niri.clone());
@@ -207,8 +205,8 @@ impl Session {
return Err(fdo::Error::Failed("monitor is disabled".to_owned()));
}
let stream_id = STREAM_ID.fetch_add(1, Ordering::SeqCst);
let path = format!("/org/gnome/Mutter/ScreenCast/Stream/u{stream_id}");
let stream_id = CastStreamId::next();
let path = format!("/org/gnome/Mutter/ScreenCast/Stream/u{}", stream_id.get());
let path = OwnedObjectPath::try_from(path).unwrap();
let cursor_mode = properties.cursor_mode.unwrap_or_default();
@@ -244,8 +242,8 @@ impl Session {
) -> fdo::Result<OwnedObjectPath> {
debug!(?properties, "record_window");
let stream_id = STREAM_ID.fetch_add(1, Ordering::SeqCst);
let path = format!("/org/gnome/Mutter/ScreenCast/Stream/u{stream_id}");
let stream_id = CastStreamId::next();
let path = format!("/org/gnome/Mutter/ScreenCast/Stream/u{}", stream_id.get());
let path = OwnedObjectPath::try_from(path).unwrap();
let cursor_mode = properties.cursor_mode.unwrap_or_default();
@@ -337,7 +335,7 @@ impl Start for ScreenCast {
impl Session {
pub fn new(
id: usize,
id: CastSessionId,
ipc_outputs: Arc<Mutex<IpcOutputMap>>,
to_niri: calloop::channel::Sender<ScreenCastToNiri>,
) -> Self {
@@ -361,8 +359,8 @@ impl Drop for Session {
impl Stream {
fn new(
id: usize,
session_id: usize,
id: CastStreamId,
session_id: CastSessionId,
target: StreamTarget,
cursor_mode: CursorMode,
to_niri: calloop::channel::Sender<ScreenCastToNiri>,
-13
View File
@@ -1,13 +0,0 @@
use anyhow::bail;
use smithay::reexports::calloop::LoopHandle;
use crate::niri::State;
pub struct PipeWire;
pub struct Cast;
impl PipeWire {
pub fn new(_event_loop: &LoopHandle<'static, State>) -> anyhow::Result<Self> {
bail!("PipeWire support is disabled (see \"xdp-gnome-screencast\" feature)");
}
}
+4 -4
View File
@@ -62,10 +62,6 @@ impl CompositorHandler for State {
on_commit_buffer_handler::<Self>(surface);
self.backend.early_import(surface);
if is_sync_subsurface(surface) {
return;
}
let mut root_surface = surface.clone();
while let Some(parent) = get_parent(&root_surface) {
root_surface = parent;
@@ -76,6 +72,10 @@ impl CompositorHandler for State {
.root_surface
.insert(surface.clone(), root_surface.clone());
if is_sync_subsurface(surface) {
return;
}
if surface == &root_surface {
// This is a root surface commit. It might have mapped a previously-unmapped toplevel.
if let Entry::Occupied(entry) = self.niri.unmapped_windows.entry(surface.clone()) {
+45 -34
View File
@@ -13,16 +13,16 @@ use smithay::backend::allocator::dmabuf::Dmabuf;
use smithay::backend::drm::DrmNode;
use smithay::backend::input::{InputEvent, TabletToolDescriptor};
use smithay::desktop::{PopupKind, PopupManager};
use smithay::input::pointer::{CursorIcon, CursorImageStatus, PointerHandle};
use smithay::input::dnd::{self, DnDGrab, DndGrabHandler, DndTarget};
use smithay::input::pointer::{CursorIcon, CursorImageStatus, Focus, PointerHandle};
use smithay::input::{keyboard, Seat, SeatHandler, SeatState};
use smithay::output::Output;
use smithay::reexports::rustix::fs::{fcntl_setfl, OFlags};
use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1;
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};
use smithay::utils::{Logical, Point, Rectangle, Serial};
use smithay::wayland::compositor::{get_parent, with_states};
use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier};
use smithay::wayland::drm_lease::{
@@ -41,8 +41,7 @@ use smithay::wayland::security_context::{
SecurityContext, SecurityContextHandler, SecurityContextListenerSource,
};
use smithay::wayland::selection::data_device::{
set_data_device_focus, ClientDndGrabHandler, DataDeviceHandler, DataDeviceState,
ServerDndGrabHandler,
set_data_device_focus, DataDeviceHandler, DataDeviceState, WaylandDndGrabHandler,
};
use smithay::wayland::selection::ext_data_control::{
DataControlHandler as ExtDataControlHandler, DataControlState as ExtDataControlState,
@@ -69,7 +68,7 @@ use smithay::{
delegate_pointer_gestures, delegate_presentation, delegate_primary_selection,
delegate_relative_pointer, delegate_seat, delegate_security_context, delegate_session_lock,
delegate_single_pixel_buffer, delegate_tablet_manager, delegate_text_input_manager,
delegate_viewporter, delegate_virtual_keyboard_manager, delegate_xdg_activation,
delegate_viewporter, delegate_xdg_activation,
};
pub use crate::handlers::xdg_shell::KdeDecorationsModeState;
@@ -280,7 +279,6 @@ impl KeyboardShortcutsInhibitHandler for State {
delegate_input_method_manager!(State);
delegate_keyboard_shortcuts_inhibit!(State);
delegate_virtual_keyboard_manager!(State);
impl SelectionHandler for State {
type SelectionUserData = Arc<[u8]>;
@@ -314,23 +312,51 @@ impl DataDeviceHandler for State {
}
}
impl ClientDndGrabHandler for State {
fn started(
impl WaylandDndGrabHandler for State {
fn dnd_requested<S: dnd::Source>(
&mut self,
_source: Option<WlDataSource>,
source: S,
icon: Option<WlSurface>,
_seat: Seat<Self>,
seat: Seat<Self>,
serial: Serial,
type_: dnd::GrabType,
) {
self.niri.dnd_icon = icon.map(|surface| DndIcon {
surface,
offset: Point::new(0, 0),
});
match type_ {
dnd::GrabType::Pointer => {
let pointer = seat.get_pointer().unwrap();
let start_data = pointer.grab_start_data().unwrap();
let grab =
DnDGrab::new_pointer(&self.niri.display_handle, start_data, source, seat);
pointer.set_grab(self, grab, serial, Focus::Keep);
}
dnd::GrabType::Touch => {
let touch = seat.get_touch().unwrap();
let start_data = touch.grab_start_data().unwrap();
let grab = DnDGrab::new_touch(&self.niri.display_handle, start_data, source, seat);
touch.set_grab(self, grab, serial);
}
}
// FIXME: more granular
self.niri.queue_redraw_all();
}
}
fn dropped(&mut self, target: Option<WlSurface>, validated: bool, _seat: Seat<Self>) {
trace!("client dropped, target: {target:?}, validated: {validated}");
impl DndGrabHandler for State {
fn dropped(
&mut self,
target: Option<DndTarget<'_, Self>>,
validated: bool,
_seat: Seat<Self>,
location: Point<f64, Logical>,
) {
let target: Option<&WlSurface> = target.map(DndTarget::into_inner);
trace!("dnd dropped, target: {target:?}, validated: {validated}");
// End DnD before activating a specific window below so that it takes precedence.
self.niri.layout.dnd_end();
@@ -339,7 +365,7 @@ impl ClientDndGrabHandler for State {
// example. On successful drop, additionally activate the target window.
let mut activate_output = true;
if let Some(target) = validated.then_some(target).flatten() {
let root = self.niri.find_root_shell_surface(&target);
let root = self.niri.find_root_shell_surface(target);
if let Some((mapped, _)) = self.niri.layout.find_window_and_output(&root) {
let window = mapped.window.clone();
self.niri.layout.activate_window(&window);
@@ -349,19 +375,10 @@ impl ClientDndGrabHandler for State {
}
if activate_output {
// Find the output from cursor coordinates.
//
// FIXME: uhhh, we can't actually properly tell if the DnD comes from pointer or touch,
// and if it comes from touch, then what the coordinates are. Need to pass more
// parameters from Smithay I guess.
//
// Assume that hidden pointer means touch DnD.
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 {
self.niri.layout.focus_output(output);
}
// Find the output from drop coordinates.
if let Some((output, _)) = self.niri.output_under(location) {
let output = output.clone();
self.niri.layout.focus_output(&output);
}
}
@@ -371,8 +388,6 @@ impl ClientDndGrabHandler for State {
}
}
impl ServerDndGrabHandler for State {}
delegate_data_device!(State);
impl PrimarySelectionHandler for State {
@@ -608,11 +623,7 @@ impl ScreencopyHandler for State {
// If with_damage then push it onto the queue for redraw of the output,
// otherwise render it immediately.
if screencopy.with_damage() {
let Some(queue) = self.niri.screencopy_state.get_queue_mut(manager) else {
trace!("screencopy manager destroyed already");
return;
};
queue.push(screencopy);
self.niri.screencopy_state.push(manager, screencopy);
} else {
self.backend.with_primary_renderer(|renderer| {
if let Err(err) = self
+40 -33
View File
@@ -24,7 +24,6 @@ use smithay::wayland::compositor::{
};
use smithay::wayland::dmabuf::get_dmabuf;
use smithay::wayland::input_method::InputMethodSeat;
use smithay::wayland::selection::data_device::DnDGrab;
use smithay::wayland::shell::kde::decoration::{KdeDecorationHandler, KdeDecorationState};
use smithay::wayland::shell::wlr_layer::{self, Layer};
use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler;
@@ -85,7 +84,7 @@ impl XdgShellHandler for State {
if focus.id().same_client_as(&wl_surface.id()) {
// Deny move requests from DnD grabs to work around
// https://gitlab.gnome.org/GNOME/gtk/-/issues/7113
let is_dnd_grab = grab.as_any().is::<DnDGrab<Self>>();
let is_dnd_grab = Self::is_dnd_grab(grab.as_any());
if !is_dnd_grab {
grab_start_data =
@@ -105,7 +104,7 @@ impl XdgShellHandler for State {
if focus.id().same_client_as(&wl_surface.id()) {
// Deny move requests from DnD grabs to work around
// https://gitlab.gnome.org/GNOME/gtk/-/issues/7113
let is_dnd_grab = grab.as_any().is::<DnDGrab<Self>>();
let is_dnd_grab = Self::is_dnd_grab(grab.as_any());
if !is_dnd_grab {
grab_start_data =
@@ -134,13 +133,13 @@ impl XdgShellHandler for State {
match &start_data {
PointerOrTouchStartData::Pointer(_) => {
if let Some(grab) = MoveGrab::new(self, start_data, window.clone(), true) {
if let Some(grab) = MoveGrab::new(self, start_data, window.clone(), true, None) {
pointer.set_grab(self, grab, serial, Focus::Clear);
}
}
PointerOrTouchStartData::Touch(_) => {
let touch = self.niri.seat.get_touch().unwrap();
if let Some(grab) = MoveGrab::new(self, start_data, window.clone(), true) {
if let Some(grab) = MoveGrab::new(self, start_data, window.clone(), true, None) {
touch.set_grab(self, grab, serial);
}
}
@@ -268,15 +267,6 @@ impl XdgShellHandler for State {
}
fn grab(&mut self, surface: PopupSurface, _seat: WlSeat, serial: Serial) {
// HACK: ignore grabs (pretend they work without actually grabbing) if the input method has
// a grab. It will likely need refactors in Smithay to support properly since grabs just
// replace each other.
// FIXME: do this properly.
if self.niri.seat.input_method().keyboard_grabbed() {
trace!("ignoring popup grab because IME has keyboard grabbed");
return;
}
let popup = PopupKind::Xdg(surface);
let Ok(root) = find_popup_root_surface(&popup) else {
trace!("ignoring popup grab because no root surface");
@@ -374,25 +364,30 @@ impl XdgShellHandler for State {
let keyboard = seat.get_keyboard().unwrap();
let pointer = seat.get_pointer().unwrap();
let can_receive_keyboard_focus = self
.niri
.layout
.active_output()
.and_then(|output| {
layer_map_for_output(output)
.layer_for_surface(&root, WindowSurfaceType::TOPLEVEL)
.map(|layer_surface| layer_surface.can_receive_keyboard_focus())
})
.unwrap_or(true);
// Smithay cannot do overlapping grabs, so if we have an IME keyboard grab, don't overwrite
// it with a popup keyboard grab. This makes the popup menu work in Telegram while an IME
// is active (otherwise it hits the grab mismatch check below).
//
// The second check is for layer surfaces that can't receive keyboard focus, without it
// popups don't work properly in Waybar (GTK 3).
let can_receive_keyboard_focus = !self.niri.seat.input_method().keyboard_grabbed()
&& self
.niri
.layout
.active_output()
.and_then(|output| {
layer_map_for_output(output)
.layer_for_surface(&root, WindowSurfaceType::TOPLEVEL)
.map(|layer_surface| layer_surface.can_receive_keyboard_focus())
})
.unwrap_or(true);
let keyboard_grab_mismatches = keyboard.is_grabbed()
&& !(keyboard.has_grab(serial)
|| grab
.previous_serial()
.map_or(true, |s| keyboard.has_grab(s)));
|| grab.previous_serial().is_none_or(|s| keyboard.has_grab(s)));
let pointer_grab_mismatches = pointer.is_grabbed()
&& !(pointer.has_grab(serial)
|| grab.previous_serial().map_or(true, |s| pointer.has_grab(s)));
|| grab.previous_serial().is_none_or(|s| pointer.has_grab(s)));
if (can_receive_keyboard_focus && keyboard_grab_mismatches) || pointer_grab_mismatches {
trace!("ignoring popup grab because of current grab mismatch");
grab.ungrab(PopupUngrabStrategy::All);
@@ -1256,7 +1251,7 @@ impl State {
let mut target = self.niri.layout.popup_target_rect(window);
target.loc -= get_popup_toplevel_coords(popup).to_f64();
self.position_popup_within_rect(popup, target);
self.position_popup_within_rect(popup, target, true);
}
pub fn unconstrain_layer_shell_popup(
@@ -1290,14 +1285,26 @@ impl State {
target.loc -= layer_geo.loc;
target.loc -= get_popup_toplevel_coords(popup);
self.position_popup_within_rect(popup, target.to_f64());
// Don't add padding to layer-shell popups. It's not really needed, and it's unexpected.
self.position_popup_within_rect(popup, target.to_f64(), false);
}
fn position_popup_within_rect(&self, popup: &PopupKind, target: Rectangle<f64, Logical>) {
fn position_popup_within_rect(
&self,
popup: &PopupKind,
target: Rectangle<f64, Logical>,
padding: bool,
) {
match popup {
PopupKind::Xdg(popup) => {
popup.with_pending_state(|state| {
state.geometry = unconstrain_with_padding(state.positioner, target);
state.geometry = if padding {
unconstrain_with_padding(state.positioner, target)
} else {
state
.positioner
.get_unconstrained_geometry(target.to_i32_round())
};
});
}
PopupKind::InputMethod(popup) => {
@@ -1466,7 +1473,7 @@ pub fn add_mapped_toplevel_pre_commit_hook(toplevel: &ToplevelSurface) -> HookId
span.record("serial", format!("{serial:?}"));
}
trace!("taking pending transaction");
// trace!("taking pending transaction");
if let Some(transaction) = mapped.take_pending_transaction(serial) {
// Transaction can be already completed if it ran past the deadline.
let disable = state.niri.config.borrow().debug.disable_transactions;
+7
View File
@@ -4,6 +4,7 @@ use smithay::backend::winit::WinitVirtualDevice;
use smithay::output::Output;
use crate::niri::State;
use crate::protocols::virtual_keyboard::VirtualKeyboard;
use crate::protocols::virtual_pointer::VirtualPointer;
pub trait NiriInputBackend: input::InputBackend<Device = Self::NiriDevice> {
@@ -44,6 +45,12 @@ impl NiriInputDevice for WinitVirtualDevice {
}
}
impl NiriInputDevice for VirtualKeyboard {
fn output(&self, _: &State) -> Option<Output> {
None
}
}
impl NiriInputDevice for VirtualPointer {
fn output(&self, _: &State) -> Option<Output> {
self.output().cloned()
+104 -23
View File
@@ -7,7 +7,7 @@ use std::time::Duration;
use calloop::timer::{TimeoutAction, Timer};
use input::event::gesture::GestureEventCoordinates as _;
use niri_config::{
Action, Bind, Binds, Config, Key, ModKey, Modifiers, MruDirection, SwitchBinds, Trigger,
Action, Bind, Binds, Config, Key, ModKey, Modifiers, MruDirection, SwitchBinds, Trigger, Xkb,
};
use niri_ipc::LayoutSwitchTarget;
use smithay::backend::input::{
@@ -19,6 +19,7 @@ use smithay::backend::input::{
TabletToolTipState, TouchEvent,
};
use smithay::backend::libinput::LibinputInputBackend;
use smithay::input::dnd::DnDGrab;
use smithay::input::keyboard::{keysyms, FilterResult, Keysym, Layout, ModifiersState};
use smithay::input::pointer::{
AxisFrame, ButtonEvent, CursorIcon, CursorImageStatus, Focus, GestureHoldBeginEvent,
@@ -31,10 +32,11 @@ use smithay::input::touch::{
};
use smithay::input::SeatHandler;
use smithay::output::Output;
use smithay::reexports::wayland_server::protocol::wl_data_source::WlDataSource;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Point, Rectangle, Transform, SERIAL_COUNTER};
use smithay::wayland::keyboard_shortcuts_inhibit::KeyboardShortcutsInhibitor;
use smithay::wayland::pointer_constraints::{with_pointer_constraint, PointerConstraint};
use smithay::wayland::selection::data_device::DnDGrab;
use smithay::wayland::tablet_manager::{TabletDescriptor, TabletSeatTrait};
use touch_overview_grab::TouchOverviewGrab;
@@ -46,10 +48,11 @@ use crate::dbus::freedesktop_a11y::KbMonBlock;
use crate::layout::scrolling::ScrollDirection;
use crate::layout::{ActivateWindow, LayoutElement as _};
use crate::niri::{CastTarget, PointerVisibility, State};
use crate::protocols::virtual_keyboard::VirtualKeyboard;
use crate::ui::mru::{WindowMru, WindowMruUi};
use crate::ui::screenshot_ui::ScreenshotUi;
use crate::utils::spawning::{spawn, spawn_sh};
use crate::utils::{center, get_monotonic_time, ResizeEdge};
use crate::utils::{center, get_monotonic_time, CastSessionId, ResizeEdge};
pub mod backend_ext;
pub mod move_grab;
@@ -358,11 +361,36 @@ impl State {
.is_some_and(KeyboardShortcutsInhibitor::is_active)
}
fn on_keyboard<I: InputBackend>(
fn on_keyboard<I: InputBackend + 'static>(
&mut self,
event: I::KeyboardKeyEvent,
consumed_by_a11y: &mut bool,
) {
) where
I::Device: 'static,
{
// Reset the keymap when handling a physical keyboard after a virtual one.
if self.niri.reset_keymap {
let device = event.device();
let is_virtual_keyboard = (&device as &dyn Any)
.downcast_ref::<VirtualKeyboard>()
.is_some();
if !is_virtual_keyboard {
self.niri.reset_keymap = false;
let config = self.niri.config.borrow();
let xkb_config = config.input.keyboard.xkb.clone();
std::mem::drop(config);
if xkb_config != Xkb::default() {
self.set_xkb_config(xkb_config.to_xkb_config());
} else {
// Use locale1 settings if xkb config is unset.
let xkb = self.niri.xkb_from_locale1.clone().unwrap_or_default();
self.set_xkb_config(xkb.to_xkb_config());
}
}
}
let mod_key = self.backend.mod_key(&self.niri.config.borrow());
let serial = SERIAL_COUNTER.next_serial();
@@ -741,7 +769,7 @@ impl State {
self.open_screenshot_ui(show_cursor, path);
self.niri.cancel_mru();
}
Action::ScreenshotWindow(write_to_disk, path) => {
Action::ScreenshotWindow(write_to_disk, show_pointer, path) => {
let focus = self.niri.layout.focus_with_output();
if let Some((mapped, output)) = focus {
self.backend.with_primary_renderer(|renderer| {
@@ -750,6 +778,7 @@ impl State {
output,
mapped,
write_to_disk,
show_pointer,
path,
) {
warn!("error taking screenshot: {err:?}");
@@ -760,6 +789,7 @@ impl State {
Action::ScreenshotWindowById {
id,
write_to_disk,
show_pointer,
path,
} => {
let mut windows = self.niri.layout.windows();
@@ -772,6 +802,7 @@ impl State {
output,
mapped,
write_to_disk,
show_pointer,
path,
) {
warn!("error taking screenshot: {err:?}");
@@ -2230,13 +2261,15 @@ impl State {
Some(name) => self.niri.output_by_name_match(&name),
};
if let Some(output) = output {
let output = output.downgrade();
self.set_dynamic_cast_target(CastTarget::Output(output));
self.set_dynamic_cast_target(CastTarget::output(output));
}
}
Action::ClearDynamicCastTarget => {
self.set_dynamic_cast_target(CastTarget::Nothing);
}
Action::StopCast(session_id) => {
self.niri.stop_cast(CastSessionId::from(session_id));
}
Action::ToggleOverview => {
self.niri.layout.toggle_overview();
self.niri.queue_redraw_all();
@@ -2454,6 +2487,35 @@ impl State {
}
}
// Warp pointer across the screen during the spatial movement grabs.
let spatial_grab = pointer.with_grab(|_, grab| {
let grab = grab.as_any();
if let Some(grab) = grab.downcast_ref::<SpatialMovementGrab>() {
if let Some(output) = grab.view_offset_output() {
return Some((output.clone(), true));
} else if let Some(output) = grab.workspace_switch_output() {
return Some((output.clone(), false));
}
} else if let Some(grab) = grab.downcast_ref::<MoveGrab>() {
if let Some(output) = grab.view_offset_output() {
return Some((output.clone(), true));
}
}
None
});
if let Some((output, horizontal)) = spatial_grab.flatten() {
if let Some(geo) = self.niri.global_space.output_geometry(&output) {
let geo = geo.to_f64();
if horizontal {
new_pos.x = (new_pos.x - geo.loc.x).rem_euclid(geo.size.w) + geo.loc.x;
new_pos.y = new_pos.y.clamp(geo.loc.y, geo.loc.y + geo.size.h - 1.);
} else {
new_pos.x = new_pos.x.clamp(geo.loc.x, geo.loc.x + geo.size.w - 1.);
new_pos.y = (new_pos.y - geo.loc.y).rem_euclid(geo.size.h) + geo.loc.y;
}
}
}
if self
.niri
.global_space
@@ -2583,10 +2645,9 @@ impl State {
self.niri.maybe_activate_pointer_constraint();
// Inform the layout of an ongoing DnD operation.
let mut is_dnd_grab = false;
pointer.with_grab(|_, grab| {
is_dnd_grab = grab.as_any().is::<DnDGrab<Self>>();
});
let is_dnd_grab = pointer
.with_grab(|_, grab| Self::is_dnd_grab(grab.as_any()))
.unwrap_or(false);
if is_dnd_grab {
if let Some((output, pos_within_output)) = self.niri.output_under(new_pos) {
let output = output.clone();
@@ -2682,10 +2743,9 @@ impl State {
self.niri.tablet_cursor_location = None;
// Inform the layout of an ongoing DnD operation.
let mut is_dnd_grab = false;
pointer.with_grab(|_, grab| {
is_dnd_grab = grab.as_any().is::<DnDGrab<Self>>();
});
let is_dnd_grab = pointer
.with_grab(|_, grab| Self::is_dnd_grab(grab.as_any()))
.unwrap_or(false);
if is_dnd_grab {
if let Some((output, pos_within_output)) = self.niri.output_under(pos) {
let output = output.clone();
@@ -2857,8 +2917,22 @@ impl State {
location,
};
let start_data = PointerOrTouchStartData::Pointer(start_data);
if let Some(grab) = MoveGrab::new(self, start_data, window.clone(), false) {
let icon = CursorIcon::Grabbing;
if let Some(grab) =
MoveGrab::new(self, start_data, window.clone(), false, Some(icon))
{
pointer.set_grab(self, grab, serial, Focus::Clear);
// Set the cursor to Grabbing right away for Mod+LMB since it doesn't
// do any other gesture.
//
// In the overview, we click to activate window and close the overview,
// in this case setting the cursor right away would be distracting.
if !is_overview_open {
self.niri
.cursor_manager
.set_cursor_image(CursorImageStatus::Named(icon));
}
}
}
}
@@ -3035,7 +3109,7 @@ impl State {
pointer
.current_focus()
.map(|surface| self.niri.find_root_shell_surface(&surface))
.map_or(true, |root| {
.is_none_or(|root| {
!self
.niri
.mapped_layer_surfaces
@@ -4106,7 +4180,8 @@ impl State {
location: pos,
};
let start_data = PointerOrTouchStartData::Touch(start_data);
if let Some(grab) = MoveGrab::new(self, start_data, window.clone(), true) {
if let Some(grab) = MoveGrab::new(self, start_data, window.clone(), true, None)
{
handle.set_grab(self, grab, serial);
}
}
@@ -4197,10 +4272,9 @@ impl State {
);
// Inform the layout of an ongoing DnD operation.
let mut is_dnd_grab = false;
handle.with_grab(|_, grab| {
is_dnd_grab = grab.as_any().is::<DnDGrab<Self>>();
});
let is_dnd_grab = handle
.with_grab(|_, grab| Self::is_dnd_grab(grab.as_any()))
.unwrap_or(false);
if is_dnd_grab {
if let Some((output, pos_within_output)) = self.niri.output_under(pos) {
let output = output.clone();
@@ -4241,6 +4315,13 @@ impl State {
self.do_action(action, true);
}
}
pub fn is_dnd_grab(grab: &dyn Any) -> bool {
// Normal DnD
grab.is::<DnDGrab<Self, WlDataSource, WlSurface>>()
// Null-source DnD: weston-dnd --self-only
|| grab.is::<DnDGrab<Self, WlSurface, WlSurface>>()
}
}
/// Check whether the key should be intercepted and mark intercepted
+75 -30
View File
@@ -14,10 +14,11 @@ use smithay::input::touch::{
};
use smithay::input::SeatHandler;
use smithay::output::Output;
use smithay::utils::{IsAlive, Logical, Point, Serial};
use smithay::utils::{IsAlive, Logical, Point, Serial, SERIAL_COUNTER};
use crate::input::PointerOrTouchStartData;
use crate::niri::State;
use crate::utils::get_monotonic_time;
pub struct MoveGrab {
start_data: PointerOrTouchStartData<State>,
@@ -27,6 +28,12 @@ pub struct MoveGrab {
window: Window,
gesture: GestureState,
enable_view_offset: bool,
move_icon: CursorIcon,
// Accumulated and applied in frame().
new_location: Point<f64, Logical>,
event_timestamp: Option<Duration>,
relative_delta: Option<Point<f64, Logical>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -42,17 +49,24 @@ impl MoveGrab {
start_data: PointerOrTouchStartData<State>,
window: Window,
enable_view_offset: bool,
move_icon: Option<CursorIcon>,
) -> Option<Self> {
let (output, pos_within_output) = state.niri.output_under(start_data.location())?;
let location = start_data.location();
let (output, pos_within_output) = state.niri.output_under(location)?;
Some(Self {
last_location: start_data.location(),
last_location: location,
start_data,
start_output: output.clone(),
start_pos_within_output: pos_within_output,
window,
gesture: GestureState::Recognizing,
enable_view_offset,
// Moving windows by their titlebars uses the default cursor by default.
move_icon: move_icon.unwrap_or(CursorIcon::Default),
new_location: location,
event_timestamp: None,
relative_delta: None,
})
}
@@ -60,6 +74,10 @@ impl MoveGrab {
self.gesture == GestureState::Move
}
pub fn view_offset_output(&self) -> Option<&Output> {
(self.gesture == GestureState::ViewOffset).then_some(&self.start_output)
}
fn on_ungrab(&mut self, data: &mut State) {
let layout = &mut data.niri.layout;
match self.gesture {
@@ -112,7 +130,7 @@ impl MoveGrab {
if self.start_data.is_pointer() {
data.niri
.cursor_manager
.set_cursor_image(CursorImageStatus::Named(CursorIcon::Move));
.set_cursor_image(CursorImageStatus::Named(self.move_icon));
}
true
@@ -120,19 +138,25 @@ impl MoveGrab {
fn begin_view_offset(&mut self, data: &mut State) -> bool {
let layout = &mut data.niri.layout;
let Some((output, ws_idx)) = layout.workspaces().find_map(|(mon, ws_idx, ws)| {
let Some(ws_idx) = layout.workspaces().find_map(|(mon, ws_idx, ws)| {
let ws_idx = ws
.windows()
.any(|w| w.window == self.window)
.then_some(ws_idx)?;
let output = mon?.output().clone();
Some((output, ws_idx))
let output = mon?.output();
// If the window moved to a different output, don't start the gesture.
if *output != self.start_output {
return None;
}
Some(ws_idx)
}) else {
// Can no longer start the gesture.
return false;
};
layout.view_offset_gesture_begin(&output, Some(ws_idx), false);
layout.view_offset_gesture_begin(&self.start_output, Some(ws_idx), false);
self.gesture = GestureState::ViewOffset;
@@ -145,14 +169,14 @@ impl MoveGrab {
true
}
fn on_motion(
&mut self,
data: &mut State,
location: Point<f64, Logical>,
timestamp: Duration,
) -> bool {
let mut delta = location - self.last_location;
self.last_location = location;
fn on_frame(&mut self, data: &mut State) -> bool {
let Some(timestamp) = self.event_timestamp.take() else {
return true;
};
let mut delta = self.new_location - self.last_location;
let mut relative_delta = self.relative_delta.take().unwrap_or(delta);
self.last_location = self.new_location;
// Try to recognize the gesture.
if self.gesture == GestureState::Recognizing {
@@ -162,7 +186,7 @@ impl MoveGrab {
}
// Check if the gesture moved far enough to decide.
let c = location - self.start_data.location();
let c = self.new_location - self.start_data.location();
if c.x * c.x + c.y * c.y >= 8. * 8. {
let is_floating = data
.niri
@@ -189,6 +213,7 @@ impl MoveGrab {
// Apply the whole delta that accumulated during recognizing.
delta = c;
relative_delta = c;
}
}
@@ -201,6 +226,8 @@ impl MoveGrab {
};
let output = output.clone();
// Interactive move always uses absolute delta since the window must remain pinned
// to the cursor even when it's clamped to monitor bounds.
let ongoing = data.niri.layout.interactive_move_update(
&self.window,
delta,
@@ -214,10 +241,11 @@ impl MoveGrab {
}
}
GestureState::ViewOffset => {
let res = data
.niri
.layout
.view_offset_gesture_update(-delta.x, timestamp, false);
let res = data.niri.layout.view_offset_gesture_update(
-relative_delta.x,
timestamp,
false,
);
if let Some(output) = res {
if let Some(output) = output {
data.niri.queue_redraw(&output);
@@ -277,10 +305,11 @@ impl PointerGrab<State> for MoveGrab {
// While the grab is active, no client has pointer focus.
handle.motion(data, None, event);
let timestamp = Duration::from_millis(u64::from(event.time));
if !self.on_motion(data, event.location, timestamp) {
// The gesture is no longer ongoing.
handle.unset_grab(self, data, event.serial, event.time, true);
self.new_location = event.location;
// Relative motion takes precedence over normal motion.
if self.relative_delta.is_none() {
self.event_timestamp = Some(Duration::from_millis(u64::from(event.time)));
}
}
@@ -293,6 +322,9 @@ impl PointerGrab<State> for MoveGrab {
) {
// While the grab is active, no client has pointer focus.
handle.relative_motion(data, None, event);
*self.relative_delta.get_or_insert_default() += event.delta;
self.event_timestamp = Some(Duration::from_micros(event.utime));
}
fn button(
@@ -337,6 +369,17 @@ impl PointerGrab<State> for MoveGrab {
fn frame(&mut self, data: &mut State, handle: &mut PointerInnerHandle<'_, State>) {
handle.frame(data);
if !self.on_frame(data) {
// The gesture is no longer ongoing.
handle.unset_grab(
self,
data,
SERIAL_COUNTER.next_serial(),
get_monotonic_time().as_millis() as u32,
true,
);
}
}
fn gesture_swipe_begin(
@@ -468,15 +511,17 @@ impl TouchGrab<State> for MoveGrab {
return;
}
let timestamp = Duration::from_millis(u64::from(event.time));
if !self.on_motion(data, event.location, timestamp) {
// The gesture is no longer ongoing.
handle.unset_grab(self, data);
}
self.new_location = event.location;
self.event_timestamp = Some(Duration::from_millis(u64::from(event.time)));
}
fn frame(&mut self, data: &mut State, handle: &mut TouchInnerHandle<'_, State>, seq: Serial) {
handle.frame(data, seq);
if !self.on_frame(data) {
// The gesture is no longer ongoing.
handle.unset_grab(self, data);
}
}
fn cancel(&mut self, data: &mut State, handle: &mut TouchInnerHandle<'_, State>, seq: Serial) {
+7 -2
View File
@@ -2,6 +2,7 @@ use niri_ipc::PickedColor;
use smithay::backend::allocator::Fourcc;
use smithay::backend::input::ButtonState;
use smithay::backend::renderer::element::utils::{Relocate, RelocateRenderElement};
use smithay::backend::renderer::ExportMem as _;
use smithay::input::pointer::{
AxisFrame, ButtonEvent, CursorImageStatus, GestureHoldBeginEvent, GestureHoldEndEvent,
GesturePinchBeginEvent, GesturePinchEndEvent, GesturePinchUpdateEvent, GestureSwipeBeginEvent,
@@ -12,7 +13,7 @@ use smithay::input::SeatHandler;
use smithay::utils::{Logical, Physical, Point, Scale, Size, Transform};
use crate::niri::State;
use crate::render_helpers::{render_to_vec, RenderTarget};
use crate::render_helpers::{render_and_download, RenderTarget};
pub struct PickColorGrab {
start_data: PointerGrabStartData<State>,
@@ -56,7 +57,7 @@ impl PickColorGrab {
RenderTarget::Output,
);
let pixels = match render_to_vec(
let mapping = match render_and_download(
renderer,
size,
scale,
@@ -67,6 +68,10 @@ impl PickColorGrab {
RelocateRenderElement::from_element(elem, offset, Relocate::Relative)
}),
) {
Ok(mapping) => mapping,
Err(_) => return None,
};
let pixels = match renderer.map_texture(&mapping) {
Ok(pixels) => pixels,
Err(_) => return None,
};
+84 -37
View File
@@ -8,10 +8,11 @@ use smithay::input::pointer::{
};
use smithay::input::SeatHandler;
use smithay::output::Output;
use smithay::utils::{Logical, Point};
use smithay::utils::{Logical, Point, SERIAL_COUNTER};
use crate::layout::workspace::WorkspaceId;
use crate::niri::State;
use crate::utils::get_monotonic_time;
pub struct SpatialMovementGrab {
start_data: PointerGrabStartData<State>,
@@ -19,9 +20,14 @@ pub struct SpatialMovementGrab {
output: Output,
workspace_id: WorkspaceId,
gesture: GestureState,
// Accumulated and applied in frame().
new_location: Point<f64, Logical>,
event_timestamp: Option<Duration>,
relative_delta: Option<Point<f64, Logical>>,
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GestureState {
Recognizing,
ViewOffset,
@@ -35,6 +41,7 @@ impl SpatialMovementGrab {
workspace_id: WorkspaceId,
is_view_offset: bool,
) -> Self {
let location = start_data.location;
let gesture = if is_view_offset {
GestureState::ViewOffset
} else {
@@ -42,52 +49,40 @@ impl SpatialMovementGrab {
};
Self {
last_location: start_data.location,
last_location: location,
start_data,
output,
workspace_id,
gesture,
new_location: location,
event_timestamp: None,
relative_delta: None,
}
}
fn on_ungrab(&mut self, state: &mut State) {
let layout = &mut state.niri.layout;
let res = match self.gesture {
GestureState::Recognizing => None,
GestureState::ViewOffset => layout.view_offset_gesture_end(Some(false)),
GestureState::WorkspaceSwitch => layout.workspace_switch_gesture_end(Some(false)),
pub fn view_offset_output(&self) -> Option<&Output> {
(self.gesture == GestureState::ViewOffset).then_some(&self.output)
}
pub fn workspace_switch_output(&self) -> Option<&Output> {
(self.gesture == GestureState::WorkspaceSwitch).then_some(&self.output)
}
fn on_frame(&mut self, data: &mut State) -> bool {
let Some(timestamp) = self.event_timestamp.take() else {
return true;
};
if let Some(output) = res {
state.niri.queue_redraw(&output);
}
state
.niri
.cursor_manager
.set_cursor_image(CursorImageStatus::default_named());
}
}
impl PointerGrab<State> for SpatialMovementGrab {
fn motion(
&mut self,
data: &mut State,
handle: &mut PointerInnerHandle<'_, State>,
_focus: Option<(<State as SeatHandler>::PointerFocus, Point<f64, Logical>)>,
event: &MotionEvent,
) {
// While the grab is active, no client has pointer focus.
handle.motion(data, None, event);
let timestamp = Duration::from_millis(u64::from(event.time));
let delta = event.location - self.last_location;
self.last_location = event.location;
let delta = self
.relative_delta
.take()
.unwrap_or(self.new_location - self.last_location);
self.last_location = self.new_location;
let layout = &mut data.niri.layout;
let res = match self.gesture {
GestureState::Recognizing => {
let c = event.location - self.start_data.location;
let c = self.new_location - self.start_data.location;
// Check if the gesture moved far enough to decide. Threshold copied from GTK 4.
if c.x * c.x + c.y * c.y >= 8. * 8. {
@@ -124,9 +119,47 @@ impl PointerGrab<State> for SpatialMovementGrab {
if let Some(output) = output {
data.niri.queue_redraw(&output);
}
true
} else {
// The move is no longer ongoing.
handle.unset_grab(self, data, event.serial, event.time, true);
false
}
}
fn on_ungrab(&mut self, state: &mut State) {
let layout = &mut state.niri.layout;
let res = match self.gesture {
GestureState::Recognizing => None,
GestureState::ViewOffset => layout.view_offset_gesture_end(Some(false)),
GestureState::WorkspaceSwitch => layout.workspace_switch_gesture_end(Some(false)),
};
if let Some(output) = res {
state.niri.queue_redraw(&output);
}
state
.niri
.cursor_manager
.set_cursor_image(CursorImageStatus::default_named());
}
}
impl PointerGrab<State> for SpatialMovementGrab {
fn motion(
&mut self,
data: &mut State,
handle: &mut PointerInnerHandle<'_, State>,
_focus: Option<(<State as SeatHandler>::PointerFocus, Point<f64, Logical>)>,
event: &MotionEvent,
) {
// While the grab is active, no client has pointer focus.
handle.motion(data, None, event);
self.new_location = event.location;
// Relative motion takes precedence over normal motion.
if self.relative_delta.is_none() {
self.event_timestamp = Some(Duration::from_millis(u64::from(event.time)));
}
}
@@ -139,6 +172,9 @@ impl PointerGrab<State> for SpatialMovementGrab {
) {
// While the grab is active, no client has pointer focus.
handle.relative_motion(data, None, event);
*self.relative_delta.get_or_insert_default() += event.delta;
self.event_timestamp = Some(Duration::from_micros(event.utime));
}
fn button(
@@ -166,6 +202,17 @@ impl PointerGrab<State> for SpatialMovementGrab {
fn frame(&mut self, data: &mut State, handle: &mut PointerInnerHandle<'_, State>) {
handle.frame(data);
if !self.on_frame(data) {
// The gesture is no longer ongoing.
handle.unset_grab(
self,
data,
SERIAL_COUNTER.next_serial(),
get_monotonic_time().as_millis() as u32,
true,
);
}
}
fn gesture_swipe_begin(
+70 -2
View File
@@ -7,8 +7,8 @@ use anyhow::{anyhow, bail, Context};
use niri_config::OutputName;
use niri_ipc::socket::Socket;
use niri_ipc::{
Action, Event, KeyboardLayouts, LogicalOutput, Mode, Output, OutputConfigChanged, Overview,
Request, Response, Transform, Window, WindowLayout,
Action, Cast, CastKind, CastTarget, Event, KeyboardLayouts, LogicalOutput, Mode, Output,
OutputConfigChanged, Overview, Request, Response, Transform, Window, WindowLayout,
};
use serde_json::json;
@@ -48,6 +48,7 @@ pub fn handle_msg(mut msg: Msg, json: bool) -> anyhow::Result<()> {
Msg::EventStream => Request::EventStream,
Msg::RequestError => Request::ReturnError,
Msg::OverviewState => Request::OverviewState,
Msg::Casts => Request::Casts,
};
let mut socket = Socket::connect().context("error connecting to the niri socket")?;
@@ -496,6 +497,15 @@ pub fn handle_msg(mut msg: Msg, json: bool) -> anyhow::Result<()> {
let description = parts.join(" and ");
println!("Screenshot captured: {description}");
}
Event::CastsChanged { casts } => {
println!("Casts changed: {casts:?}");
}
Event::CastStartedOrChanged { cast } => {
println!("Cast started or changed: {cast:?}");
}
Event::CastStopped { stream_id } => {
println!("Cast stopped: stream id {stream_id}");
}
}
}
}
@@ -518,6 +528,28 @@ pub fn handle_msg(mut msg: Msg, json: bool) -> anyhow::Result<()> {
println!("Overview is closed.");
}
}
Msg::Casts => {
let Response::Casts(mut casts) = response else {
bail!("unexpected response: expected Casts, got {response:?}");
};
if json {
let casts = serde_json::to_string(&casts).context("error formatting response")?;
println!("{casts}");
return Ok(());
}
if casts.is_empty() {
println!("No screencasts.");
return Ok(());
}
casts.sort_by_key(|c| (c.session_id, c.stream_id));
for cast in casts {
print_cast(&cast);
println!();
}
}
}
Ok(())
@@ -706,6 +738,42 @@ fn print_window(window: &Window) {
);
}
fn print_cast(cast: &Cast) {
let active = if cast.is_active { "" } else { " (inactive)" };
println!("Cast stream ID {}:{active}", cast.stream_id);
println!(" Session ID: {}", cast.session_id);
let kind = match cast.kind {
CastKind::PipeWire => "PipeWire",
CastKind::WlrScreencopy => "wlr-screencopy",
};
println!(" Kind: {kind}");
match &cast.target {
CastTarget::Nothing {} => {
println!(" Target: nothing (cleared)");
}
CastTarget::Output { name } => {
println!(" Target: output \"{name}\"");
}
CastTarget::Window { id } => {
println!(" Target: window {id}");
}
}
if cast.is_dynamic_target {
println!(" Dynamic cast target");
}
if let Some(pid) = cast.pid {
println!(" PID: {pid}");
}
if let Some(node_id) = cast.pw_node_id {
println!(" PipeWire node ID: {node_id}");
}
}
fn fmt_rounded(x: f64) -> String {
let r = x.round();
if (r - x).abs() <= 0.005 {
+120
View File
@@ -450,6 +450,11 @@ async fn process(ctx: &ClientCtx, request: Request) -> Reply {
let is_open = state.overview.is_open;
Response::OverviewState(Overview { is_open })
}
Request::Casts => {
let state = ctx.event_stream_state.borrow();
let casts = state.casts.casts.values().cloned().collect();
Response::Casts(casts)
}
};
Ok(response)
@@ -793,6 +798,121 @@ impl State {
server.send_event(event);
}
pub fn ipc_refresh_casts(&mut self) {
let Some(server) = &self.niri.ipc_server else {
return;
};
let _span = tracy_client::span!("State::ipc_refresh_casts");
let mut state = server.event_stream_state.borrow_mut();
let state = &mut state.casts;
let mut events = Vec::new();
let mut seen = HashSet::new();
// Check PipeWire screencasts.
#[cfg(feature = "xdp-gnome-screencast")]
{
// Check pending dynamic casts.
for pending in &self.niri.casting.pending_dynamic_casts {
let stream_id = pending.stream_id.get();
seen.insert(stream_id);
// Pending dynamic casts don't change any properties, so we only need to check if
// it's missing from the state.
if !state.casts.contains_key(&stream_id) {
let cast = niri_ipc::Cast {
session_id: pending.session_id.get(),
stream_id,
kind: niri_ipc::CastKind::PipeWire,
target: niri_ipc::CastTarget::Nothing {},
is_dynamic_target: true,
is_active: false,
pid: None,
pw_node_id: None,
};
events.push(Event::CastStartedOrChanged { cast });
}
}
// Check active casts.
for cast in &self.niri.casting.casts {
let stream_id = cast.stream_id.get();
seen.insert(stream_id);
let pw_node_id = cast.node_id();
if state.casts.get(&stream_id).is_none_or(|existing| {
// Only these properties can change.
existing.is_active != cast.is_active()
|| !cast.target.matches(&existing.target)
|| existing.pw_node_id != pw_node_id
}) {
let cast = niri_ipc::Cast {
session_id: cast.session_id.get(),
stream_id,
kind: niri_ipc::CastKind::PipeWire,
target: cast.target.make_ipc(),
is_dynamic_target: cast.dynamic_target,
is_active: cast.is_active(),
pid: None,
pw_node_id,
};
events.push(Event::CastStartedOrChanged { cast });
}
}
}
// Check screencopy casts.
//
// First, clear expired casts. Ideally we'd have a deadline timer, but our 1 second frame
// callback timer calls refresh regularly, so that's fine as is.
self.niri.screencopy_state.clear_expired_casts();
for queue in self.niri.screencopy_state.queues() {
if let Some(cast_info) = queue.cast() {
let stream_id = cast_info.stream_id.get();
seen.insert(stream_id);
if state.casts.get(&stream_id).is_none_or(|existing| {
// Only this property can change.
match &existing.target {
niri_ipc::CastTarget::Output { name } => *name != cast_info.output_name,
_ => true,
}
}) {
let cast = niri_ipc::Cast {
session_id: cast_info.session_id.get(),
stream_id,
kind: niri_ipc::CastKind::WlrScreencopy,
target: niri_ipc::CastTarget::Output {
name: cast_info.output_name.clone(),
},
is_dynamic_target: false,
is_active: true,
pid: queue.credentials().map(|creds| creds.pid),
pw_node_id: None,
};
events.push(Event::CastStartedOrChanged { cast });
}
}
}
// Check for stopped casts.
for stream_id in state.casts.keys() {
if !seen.contains(stream_id) {
events.push(Event::CastStopped {
stream_id: *stream_id,
});
}
}
for event in events {
state.apply(event.clone());
server.send_event(event);
}
}
pub fn ipc_config_loaded(&mut self, failed: bool) {
let Some(server) = &self.niri.ipc_server else {
return;
+45 -27
View File
@@ -1,8 +1,6 @@
use niri_config::utils::MergeWith as _;
use niri_config::{Config, LayerRule};
use smithay::backend::renderer::element::surface::{
render_elements_from_surface_tree, WaylandSurfaceRenderElement,
};
use smithay::backend::renderer::element::surface::WaylandSurfaceRenderElement;
use smithay::backend::renderer::element::Kind;
use smithay::desktop::{LayerSurface, PopupManager};
use smithay::utils::{Logical, Point, Scale, Size};
@@ -15,7 +13,8 @@ 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::render_helpers::surface::push_elements_from_surface_tree;
use crate::render_helpers::RenderTarget;
use crate::utils::{baba_is_float_offset, round_logical_in_physical};
#[derive(Debug)]
@@ -156,14 +155,13 @@ impl MappedLayer {
Point::from((0., y))
}
pub fn render<R: NiriRenderer>(
pub fn render_normal<R: NiriRenderer>(
&self,
renderer: &mut R,
location: Point<f64, Logical>,
target: RenderTarget,
) -> SplitElements<LayerSurfaceRenderElement<R>> {
let mut rv = SplitElements::default();
push: &mut dyn FnMut(LayerSurfaceRenderElement<R>),
) {
let scale = Scale::from(self.scale);
let alpha = self.rules.opacity.unwrap_or(1.).clamp(0., 1.);
let location = location + self.bob_offset();
@@ -179,40 +177,60 @@ impl MappedLayer {
alpha,
Kind::Unspecified,
);
rv.normal.push(elem.into());
push(elem.into());
} else {
// Layer surfaces don't have extra geometry like windows.
let buf_pos = location;
let surface = self.surface.wl_surface();
for (popup, popup_offset) in PopupManager::popups_for_surface(surface) {
// Layer surfaces don't have extra geometry like windows.
let offset = popup_offset - popup.geometry().loc;
rv.popups.extend(render_elements_from_surface_tree(
renderer,
popup.wl_surface(),
(buf_pos + offset.to_f64()).to_physical_precise_round(scale),
scale,
alpha,
Kind::ScanoutCandidate,
));
}
rv.normal = render_elements_from_surface_tree(
push_elements_from_surface_tree(
renderer,
surface,
buf_pos.to_physical_precise_round(scale),
scale,
alpha,
Kind::ScanoutCandidate,
&mut |elem| push(elem.into()),
);
}
let location = location.to_physical_precise_round(scale).to_logical(scale);
rv.normal
.extend(self.shadow.render(renderer, location).map(Into::into));
self.shadow
.render(renderer, location, &mut |elem| push(elem.into()));
}
rv
pub fn render_popups<R: NiriRenderer>(
&self,
renderer: &mut R,
location: Point<f64, Logical>,
target: RenderTarget,
push: &mut dyn FnMut(LayerSurfaceRenderElement<R>),
) {
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) {
return;
}
// Layer surfaces don't have extra geometry like windows.
let buf_pos = location;
let surface = self.surface.wl_surface();
for (popup, popup_offset) in PopupManager::popups_for_surface(surface) {
// Layer surfaces don't have extra geometry like windows.
let offset = popup_offset - popup.geometry().loc;
push_elements_from_surface_tree(
renderer,
popup.wl_surface(),
(buf_pos + offset.to_f64()).to_physical_precise_round(scale),
scale,
alpha,
Kind::ScanoutCandidate,
&mut |elem| push(elem.into()),
);
}
}
}
+2 -22
View File
@@ -7,7 +7,7 @@ pub mod mapped;
pub use mapped::MappedLayer;
/// Rules fully resolved for a layer-shell surface.
#[derive(Debug, PartialEq)]
#[derive(Debug, Default, PartialEq)]
pub struct ResolvedLayerRules {
/// Extra opacity to draw this layer surface with.
pub opacity: Option<f32>,
@@ -29,30 +29,10 @@ pub struct ResolvedLayerRules {
}
impl ResolvedLayerRules {
pub const fn empty() -> Self {
Self {
opacity: None,
block_out_from: None,
shadow: ShadowRule {
off: false,
on: false,
offset: None,
softness: None,
spread: None,
draw_behind_window: None,
color: None,
inactive_color: None,
},
geometry_corner_radius: None,
place_within_backdrop: false,
baba_is_float: false,
}
}
pub fn compute(rules: &[LayerRule], surface: &LayerSurface, is_at_startup: bool) -> Self {
let _span = tracy_client::span!("ResolvedLayerRules::compute");
let mut resolved = ResolvedLayerRules::empty();
let mut resolved = ResolvedLayerRules::default();
for rule in rules {
let matches = |m: &Match| {
+3 -2
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::rc::Rc;
use anyhow::Context as _;
use glam::{Mat3, Vec2};
@@ -229,14 +230,14 @@ impl ClosingWindow {
None,
scale.x as f32,
1.,
vec![
Rc::new([
mat3_uniform("niri_input_to_geo", input_to_geo),
Uniform::new("niri_geo_size", geo_size.to_array()),
mat3_uniform("niri_geo_to_tex", geo_to_tex),
Uniform::new("niri_progress", progress as f32),
Uniform::new("niri_clamped_progress", clamped_progress as f32),
Uniform::new("niri_random_seed", self.random_seed),
],
]),
HashMap::from([(String::from("niri_tex"), buffer.texture().clone())]),
Kind::Unspecified,
)
+7 -11
View File
@@ -1053,15 +1053,14 @@ impl<W: LayoutElement> FloatingSpace<W> {
true
}
pub fn render_elements<R: NiriRenderer>(
pub fn render<R: NiriRenderer>(
&self,
renderer: &mut R,
view_rect: Rectangle<f64, Logical>,
target: RenderTarget,
focus_ring: bool,
) -> Vec<FloatingSpaceRenderElement<R>> {
let mut rv = Vec::new();
push: &mut dyn FnMut(FloatingSpaceRenderElement<R>),
) {
let scale = Scale::from(self.scale);
// Draw the closing windows on top of the other windows.
@@ -1069,7 +1068,7 @@ impl<W: LayoutElement> FloatingSpace<W> {
// FIXME: I guess this should rather preserve the stacking order when the window is closed.
for closing in self.closing_windows.iter().rev() {
let elem = closing.render(renderer.as_gles_renderer(), view_rect, scale, target);
rv.push(elem.into());
push(elem.into());
}
let active = self.active_window_id.clone();
@@ -1077,13 +1076,10 @@ impl<W: LayoutElement> FloatingSpace<W> {
// For the active tile, draw the focus ring.
let focus_ring = focus_ring && Some(tile.window().id()) == active.as_ref();
rv.extend(
tile.render(renderer, tile_pos, focus_ring, target)
.map(Into::into),
);
tile.render(renderer, tile_pos, focus_ring, target, &mut |elem| {
push(elem.into())
});
}
rv
}
pub fn interactive_resize_begin(&mut self, window: W::Id, edges: ResizeEdge) -> bool {
+5 -9
View File
@@ -1,6 +1,5 @@
use std::iter::zip;
use arrayvec::ArrayVec;
use niri_config::{CornerRadius, Gradient, GradientRelativeTo};
use smithay::backend::renderer::element::{Element as _, Kind};
use smithay::utils::{Logical, Point, Rectangle, Size};
@@ -220,18 +219,17 @@ impl FocusRing {
&self,
renderer: &mut impl NiriRenderer,
location: Point<f64, Logical>,
) -> impl Iterator<Item = FocusRingRenderElement> {
let mut rv = ArrayVec::<_, 8>::new();
push: &mut dyn FnMut(FocusRingRenderElement),
) {
if self.config.off {
return rv.into_iter();
return;
}
let border_width = -self.locations[0].y;
// If drawing as a border with width = 0, then there's nothing to draw.
if self.is_border && border_width == 0. {
return rv.into_iter();
return;
}
let has_border_shader = BorderRenderElement::has_shader(renderer);
@@ -244,7 +242,7 @@ impl FocusRing {
SolidColorRenderElement::from_buffer(buffer, location, alpha, Kind::Unspecified)
.into()
};
rv.push(elem);
push(elem);
};
if self.is_border {
@@ -258,8 +256,6 @@ impl FocusRing {
location + self.locations[0],
);
}
rv.into_iter()
}
pub fn width(&self) -> f64 {
+3 -2
View File
@@ -59,7 +59,8 @@ impl InsertHintElement {
&self,
renderer: &mut impl NiriRenderer,
location: Point<f64, Logical>,
) -> impl Iterator<Item = FocusRingRenderElement> {
self.inner.render(renderer, location)
push: &mut dyn FnMut(FocusRingRenderElement),
) {
self.inner.render(renderer, location, push)
}
}
+46 -37
View File
@@ -64,7 +64,7 @@ use crate::render_helpers::renderer::NiriRenderer;
use crate::render_helpers::snapshot::RenderSnapshot;
use crate::render_helpers::solid_color::{SolidColorBuffer, SolidColorRenderElement};
use crate::render_helpers::texture::TextureBuffer;
use crate::render_helpers::{BakedBuffer, RenderTarget, SplitElements};
use crate::render_helpers::{BakedBuffer, RenderTarget};
use crate::rubber_band::RubberBand;
use crate::utils::transaction::{Transaction, TransactionBlocker};
use crate::utils::{
@@ -159,7 +159,11 @@ pub trait LayoutElement {
scale: Scale<f64>,
alpha: f32,
target: RenderTarget,
) -> SplitElements<LayoutElementRenderElement<R>>;
push: &mut dyn FnMut(LayoutElementRenderElement<R>),
) {
self.render_popups(renderer, location, scale, alpha, target, push);
self.render_normal(renderer, location, scale, alpha, target, push);
}
/// Renders the non-popup parts of the element.
fn render_normal<R: NiriRenderer>(
@@ -169,8 +173,9 @@ pub trait LayoutElement {
scale: Scale<f64>,
alpha: f32,
target: RenderTarget,
) -> Vec<LayoutElementRenderElement<R>> {
self.render(renderer, location, scale, alpha, target).normal
push: &mut dyn FnMut(LayoutElementRenderElement<R>),
) {
let _ = (renderer, location, scale, alpha, target, push);
}
/// Renders the popups of the element.
@@ -181,8 +186,9 @@ pub trait LayoutElement {
scale: Scale<f64>,
alpha: f32,
target: RenderTarget,
) -> Vec<LayoutElementRenderElement<R>> {
self.render(renderer, location, scale, alpha, target).popups
push: &mut dyn FnMut(LayoutElementRenderElement<R>),
) {
let _ = (renderer, location, scale, alpha, target, push);
}
/// Requests the element to change its size.
@@ -498,6 +504,7 @@ pub enum HitType {
enum OverviewProgress {
Animation(Animation),
Gesture(OverviewGesture),
Open,
}
#[derive(Debug)]
@@ -628,6 +635,7 @@ impl OverviewProgress {
match self {
OverviewProgress::Animation(anim) => anim.value(),
OverviewProgress::Gesture(gesture) => gesture.value,
OverviewProgress::Open => 1.,
}
}
@@ -2648,9 +2656,11 @@ impl<W: LayoutElement> Layout<W> {
}
}
if !self.overview_open {
if let Some(OverviewProgress::Animation(anim)) = &mut self.overview_progress {
if anim.is_done() {
if let Some(OverviewProgress::Animation(anim)) = &mut self.overview_progress {
if anim.is_done() {
if self.overview_open {
self.overview_progress = Some(OverviewProgress::Open);
} else {
self.overview_progress = None;
}
}
@@ -2674,19 +2684,19 @@ impl<W: LayoutElement> Layout<W> {
pub fn are_animations_ongoing(&self, output: Option<&Output>) -> bool {
// Keep advancing animations if we might need to scroll the view.
if let Some(dnd) = &self.dnd {
if output.map_or(true, |output| *output == dnd.output) {
if output.is_none_or(|output| *output == dnd.output) {
return true;
}
}
if let Some(InteractiveMoveState::Moving(move_)) = &self.interactive_move {
if output.map_or(true, |output| *output == move_.output) {
if output.is_none_or(|output| *output == move_.output) {
if move_.tile.are_animations_ongoing() {
return true;
}
// Keep advancing animations if we might need to scroll the view.
if !move_.is_floating {
if !move_.is_floating || self.overview_open {
return true;
}
}
@@ -2720,7 +2730,7 @@ impl<W: LayoutElement> Layout<W> {
let zoom = self.overview_zoom();
if let Some(InteractiveMoveState::Moving(move_)) = &mut self.interactive_move {
if output.map_or(true, |output| move_.output == *output) {
if output.is_none_or(|output| move_.output == *output) {
let pos_within_output = move_.tile_render_location(zoom);
let view_rect =
Rectangle::new(pos_within_output.upscale(-1.), output_size(&move_.output));
@@ -2741,7 +2751,7 @@ impl<W: LayoutElement> Layout<W> {
};
for (idx, mon) in monitors.iter_mut().enumerate() {
if output.map_or(true, |output| mon.output == *output) {
if output.is_none_or(|output| mon.output == *output) {
let is_active = self.is_active
&& idx == *active_monitor_idx
&& !matches!(self.interactive_move, Some(InteractiveMoveState::Moving(_)));
@@ -3271,7 +3281,7 @@ impl<W: LayoutElement> Layout<W> {
let mon = &mut monitors[mon_idx];
let activate = activate.map_smart(|| {
window.map_or(true, |win| {
window.is_none_or(|win| {
mon_idx == *active_monitor_idx
&& mon.active_window().map(|win| win.id()) == Some(win)
})
@@ -4709,38 +4719,37 @@ impl<W: LayoutElement> Layout<W> {
}
}
pub fn render_interactive_move_for_output<'a, R: NiriRenderer + 'a>(
&'a self,
pub fn render_interactive_move_for_output<R: NiriRenderer>(
&self,
renderer: &mut R,
output: &Output,
target: RenderTarget,
) -> impl Iterator<Item = RescaleRenderElement<TileRenderElement<R>>> + 'a {
push: &mut dyn FnMut(RescaleRenderElement<TileRenderElement<R>>),
) {
if self.update_render_elements_time != self.clock.now() {
error!("clock moved between updating render elements and rendering");
}
let mut rv = None;
let Some(InteractiveMoveState::Moving(move_)) = &self.interactive_move else {
return;
};
if let Some(InteractiveMoveState::Moving(move_)) = &self.interactive_move {
if &move_.output == output {
let scale = Scale::from(move_.output.current_scale().fractional_scale());
let zoom = self.overview_zoom();
let location = move_.tile_render_location(zoom);
let iter = move_
.tile
.render(renderer, location, true, target)
.map(move |elem| {
RescaleRenderElement::from_element(
elem,
location.to_physical_precise_round(scale),
zoom,
)
});
rv = Some(iter);
}
if &move_.output != output {
return;
}
rv.into_iter().flatten()
let scale = Scale::from(move_.output.current_scale().fractional_scale());
let zoom = self.overview_zoom();
let location = move_.tile_render_location(zoom);
move_
.tile
.render(renderer, location, true, target, &mut |elem| {
push(RescaleRenderElement::from_element(
elem,
location.to_physical_precise_round(scale),
zoom,
));
});
}
pub fn refresh(&mut self, is_active: bool) {
+94 -110
View File
@@ -282,6 +282,7 @@ impl From<&super::OverviewProgress> for OverviewProgress {
match value {
super::OverviewProgress::Animation(anim) => Self::Animation(anim.clone()),
super::OverviewProgress::Gesture(gesture) => Self::Value(gesture.value),
super::OverviewProgress::Open => Self::Value(1.),
}
}
}
@@ -870,9 +871,7 @@ impl<W: LayoutElement> Monitor<W> {
let new_id = self.workspaces[new_idx].id();
let activate = activate.map_smart(|| {
window.map_or(true, |win| {
self.active_window().map(|win| win.id()) == Some(win)
})
window.is_none_or(|win| self.active_window().map(|win| win.id()) == Some(win))
});
let workspace = &mut self.workspaces[source_workspace_idx];
@@ -1491,6 +1490,13 @@ impl<W: LayoutElement> Monitor<W> {
(0..=self.workspaces.len()).map(move |idx| {
let y = first_ws_y + idx as f64 * ws_height_with_gap;
let loc = Point::from((0., y)) + static_offset;
// Even though all components that go into loc are rounded to physical pixels, the
// floating point addition may lose precision. This can result for example in the
// current workspace having y = 0.0000000000002 and thus missing pointer hits at the
// monitor edge with y = 0. So, post-round the location too.
let loc = loc.to_physical_precise_round(scale).to_logical(scale);
Rectangle::new(loc, ws_size)
})
}
@@ -1639,40 +1645,36 @@ impl<W: LayoutElement> Monitor<W> {
pub fn render_insert_hint_between_workspaces<R: NiriRenderer>(
&self,
renderer: &mut R,
) -> impl Iterator<Item = MonitorRenderElement<R>> {
let mut rv = None;
if !self.options.layout.insert_hint.off {
if let Some(render_loc) = self.insert_hint_render_loc {
if let InsertWorkspace::NewAt(_) = render_loc.workspace {
let iter = self
.insert_hint_element
.render(renderer, render_loc.location)
.map(MonitorInnerRenderElement::UncroppedInsertHint);
rv = Some(iter);
}
}
push: &mut dyn FnMut(MonitorRenderElement<R>),
) {
if self.options.layout.insert_hint.off {
return;
}
let Some(render_loc) = self.insert_hint_render_loc else {
return;
};
let InsertWorkspace::NewAt(_) = render_loc.workspace else {
return;
};
rv.into_iter().flatten().map(|elem| {
let elem = RescaleRenderElement::from_element(elem, Point::default(), 1.);
RelocateRenderElement::from_element(elem, Point::default(), Relocate::Relative)
})
self.insert_hint_element
.render(renderer, render_loc.location, &mut |elem| {
let elem = MonitorInnerRenderElement::UncroppedInsertHint(elem);
let elem = RescaleRenderElement::from_element(elem, Point::default(), 1.);
let elem =
RelocateRenderElement::from_element(elem, Point::default(), Relocate::Relative);
push(elem);
});
}
pub fn render_elements<'a, R: NiriRenderer>(
&'a self,
renderer: &'a mut R,
pub fn render_workspaces<R: NiriRenderer>(
&self,
renderer: &mut R,
target: RenderTarget,
focus_ring: bool,
) -> impl Iterator<
Item = (
Rectangle<f64, Logical>,
MonitorRenderElement<R>,
impl Iterator<Item = MonitorRenderElement<R>> + 'a,
),
> {
let _span = tracy_client::span!("Monitor::render_elements");
push: &mut dyn FnMut(MonitorRenderElement<R>),
) {
let _span = tracy_client::span!("Monitor::render_workspaces");
let scale = self.scale.fractional_scale();
// Ceil the height in physical pixels.
@@ -1702,95 +1704,77 @@ impl<W: LayoutElement> Monitor<W> {
let zoom = self.overview_zoom();
// Draw the insert hint.
let mut insert_hint = None;
if !self.options.layout.insert_hint.off {
if let Some(render_loc) = self.insert_hint_render_loc {
if let InsertWorkspace::Existing(workspace_id) = render_loc.workspace {
insert_hint = Some((
workspace_id,
self.insert_hint_element
.render(renderer, render_loc.location),
));
let insert_hint_render_loc = self
.insert_hint_render_loc
.filter(|_| !self.options.layout.insert_hint.off);
let scale_relocate = move |geo: Rectangle<f64, Logical>, elem| {
let elem = RescaleRenderElement::from_element(elem, Point::from((0, 0)), zoom);
RelocateRenderElement::from_element(
elem,
// The offset we get from workspaces_with_render_geo() is already
// rounded to physical pixels, but it's in the logical coordinate
// space, so we need to convert it to physical.
geo.loc.to_physical_precise_round(scale),
Relocate::Relative,
)
};
for (ws, geo) in self.workspaces_with_render_geo() {
// Macro instead of closure because ws and insert hint have different elem types.
macro_rules! push {
() => {{
&mut |elem| {
let elem = CropRenderElement::from_element(elem, scale, crop_bounds);
if let Some(elem) = elem {
let elem = MonitorInnerRenderElement::from(elem);
push(scale_relocate(geo, elem));
}
}
}};
}
ws.render_floating(renderer, target, focus_ring, push!());
if let Some(loc) = insert_hint_render_loc {
if loc.workspace == InsertWorkspace::Existing(ws.id()) {
self.insert_hint_element
.render(renderer, loc.location, push!());
}
}
ws.render_scrolling(renderer, target, focus_ring, push!());
}
self.workspaces_with_render_geo().map(move |(ws, geo)| {
let map_ws_contents = move |elem: WorkspaceRenderElement<R>| {
let elem = CropRenderElement::from_element(elem, scale, crop_bounds)?;
let elem = MonitorInnerRenderElement::Workspace(elem);
Some(elem)
};
let (floating, scrolling) = ws.render_elements(renderer, target, focus_ring);
let floating = floating.filter_map(map_ws_contents);
let scrolling = scrolling.filter_map(map_ws_contents);
let hint = if matches!(insert_hint, Some((hint_ws_id, _)) if hint_ws_id == ws.id()) {
let iter = insert_hint.take().unwrap().1;
let iter = iter.filter_map(move |elem| {
let elem = CropRenderElement::from_element(elem, scale, crop_bounds)?;
let elem = MonitorInnerRenderElement::InsertHint(elem);
Some(elem)
});
Some(iter)
} else {
None
};
let hint = hint.into_iter().flatten();
let iter = floating.chain(hint).chain(scrolling);
let scale_relocate = move |elem| {
let elem = RescaleRenderElement::from_element(elem, Point::from((0, 0)), zoom);
RelocateRenderElement::from_element(
elem,
// The offset we get from workspaces_with_render_positions() is already
// rounded to physical pixels, but it's in the logical coordinate
// space, so we need to convert it to physical.
geo.loc.to_physical_precise_round(scale),
Relocate::Relative,
)
};
let iter = iter.map(scale_relocate);
let background = ws.render_background();
let background = scale_relocate(MonitorInnerRenderElement::SolidColor(background));
(geo, background, iter)
})
}
pub fn render_workspace_shadows<'a, R: NiriRenderer>(
&'a self,
renderer: &'a mut R,
) -> impl Iterator<Item = MonitorRenderElement<R>> + 'a {
pub fn render_workspace_shadows<R: NiriRenderer>(
&self,
renderer: &mut R,
push: &mut dyn FnMut(MonitorRenderElement<R>),
) {
let Some(progress) = self.overview_progress.as_ref().map(|p| p.clamped_value()) else {
return;
};
let alpha = progress.clamp(0., 1.) as f32;
let _span = tracy_client::span!("Monitor::render_workspace_shadows");
let scale = self.scale.fractional_scale();
let zoom = self.overview_zoom();
let overview_clamped_progress = self.overview_progress.as_ref().map(|p| p.clamped_value());
self.workspaces_with_render_geo()
.flat_map(move |(ws, geo)| {
let shadow = overview_clamped_progress.map(|value| {
ws.render_shadow(renderer)
.map(move |elem| elem.with_alpha(value.clamp(0., 1.) as f32))
.map(MonitorInnerRenderElement::Shadow)
});
let iter = shadow.into_iter().flatten();
iter.map(move |elem| {
let elem = RescaleRenderElement::from_element(elem, Point::from((0, 0)), zoom);
RelocateRenderElement::from_element(
elem,
geo.loc.to_physical_precise_round(scale),
Relocate::Relative,
)
})
})
for (ws, geo) in self.workspaces_with_render_geo() {
ws.render_shadow(renderer, &mut |elem| {
let elem = elem.with_alpha(alpha);
let elem = MonitorInnerRenderElement::Shadow(elem);
let elem = RescaleRenderElement::from_element(elem, Point::from((0, 0)), zoom);
let elem = RelocateRenderElement::from_element(
elem,
geo.loc.to_physical_precise_round(scale),
Relocate::Relative,
);
push(elem);
});
}
}
pub fn workspace_switch_gesture_begin(&mut self, is_touchpad: bool) {
+3 -4
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::rc::Rc;
use anyhow::Context as _;
use glam::{Mat3, Vec2};
@@ -39,8 +40,6 @@ impl OpenAnimation {
}
}
pub fn advance_animations(&mut self) {}
pub fn is_done(&self) -> bool {
self.anim.is_done()
}
@@ -104,14 +103,14 @@ impl OpenAnimation {
None,
scale.x as f32,
alpha,
vec![
Rc::new([
mat3_uniform("niri_input_to_geo", input_to_geo),
Uniform::new("niri_geo_size", geo_size.to_array()),
mat3_uniform("niri_geo_to_tex", geo_to_tex),
Uniform::new("niri_progress", progress as f32),
Uniform::new("niri_clamped_progress", clamped_progress as f32),
Uniform::new("niri_random_seed", self.random_seed),
],
]),
HashMap::from([(String::from("niri_tex"), texture.clone())]),
Kind::Unspecified,
)
+52 -37
View File
@@ -336,8 +336,8 @@ impl<W: LayoutElement> ScrollingSpace<W> {
}
pub fn update_shaders(&mut self) {
for tile in self.tiles_mut() {
tile.update_shaders();
for col in &mut self.columns {
col.update_shaders();
}
}
@@ -986,6 +986,23 @@ impl<W: LayoutElement> ScrollingSpace<W> {
self.data.insert(idx, ColumnData::new(&column));
self.columns.insert(idx, column);
if !was_empty && idx <= self.active_column_idx {
self.active_column_idx += 1;
}
// Animate movement of other columns.
let offset = self.column_x(idx + 1) - self.column_x(idx);
let config = anim_config.unwrap_or(self.options.animations.window_movement.0);
if self.active_column_idx <= idx {
for col in &mut self.columns[idx + 1..] {
col.animate_move_from_with_config(-offset, config);
}
} else {
for col in &mut self.columns[..idx] {
col.animate_move_from_with_config(offset, config);
}
}
if activate {
// If this is the first window on an empty workspace, remove the effect of whatever
// view_offset was left over and skip the animation.
@@ -1002,21 +1019,6 @@ impl<W: LayoutElement> ScrollingSpace<W> {
anim_config.unwrap_or(self.options.animations.horizontal_view_movement.0);
self.activate_column_with_anim_config(idx, anim_config);
self.activate_prev_column_on_removal = prev_offset;
} else if !was_empty && idx <= self.active_column_idx {
self.active_column_idx += 1;
}
// Animate movement of other columns.
let offset = self.column_x(idx + 1) - self.column_x(idx);
let config = anim_config.unwrap_or(self.options.animations.window_movement.0);
if self.active_column_idx <= idx {
for col in &mut self.columns[idx + 1..] {
col.animate_move_from_with_config(-offset, config);
}
} else {
for col in &mut self.columns[..idx] {
col.animate_move_from_with_config(offset, config);
}
}
}
@@ -1384,11 +1386,6 @@ impl<W: LayoutElement> ScrollingSpace<W> {
// 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.
// This is especially important for always-centered view.
let config = if ongoing_resize_anim {
@@ -1397,6 +1394,11 @@ impl<W: LayoutElement> ScrollingSpace<W> {
self.options.animations.horizontal_view_movement.0
};
// Restore the view offset upon unfullscreening if needed.
if let Some(prev_offset) = unfullscreen_offset {
self.animate_view_offset_with_config(col_idx, prev_offset, config);
}
// FIXME: we will want to skip the animation in some cases here to make continuously
// resizing windows not look janky.
self.animate_view_offset_to_column_with_config(None, col_idx, None, config);
@@ -1856,7 +1858,7 @@ impl<W: LayoutElement> ScrollingSpace<W> {
self.activate_prev_column_on_removal = None;
}
if target_column_idx < self.active_column_idx {
if target_column_idx <= self.active_column_idx {
// Tiles to the left animate from the following column.
offset.x += self.column_x(target_column_idx + 1) - self.column_x(target_column_idx);
}
@@ -2895,25 +2897,24 @@ impl<W: LayoutElement> ScrollingSpace<W> {
.is_fullscreen()
}
pub fn render_elements<R: NiriRenderer>(
pub fn render<R: NiriRenderer>(
&self,
renderer: &mut R,
target: RenderTarget,
focus_ring: bool,
) -> Vec<ScrollingSpaceRenderElement<R>> {
let mut rv = vec![];
push: &mut dyn FnMut(ScrollingSpaceRenderElement<R>),
) {
let scale = Scale::from(self.scale);
// 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() {
let elem = closing.render(renderer.as_gles_renderer(), view_rect, scale, target);
rv.push(elem.into());
push(elem.into());
}
if self.columns.is_empty() {
return rv;
return;
}
let mut first = true;
@@ -2928,7 +2929,8 @@ impl<W: LayoutElement> ScrollingSpace<W> {
{
let pos = view_off + col_off + col_render_off;
let pos = pos.to_physical_precise_round(scale).to_logical(scale);
rv.extend(col.tab_indicator.render(renderer, pos).map(Into::into));
col.tab_indicator
.render(renderer, pos, &mut |elem| push(elem.into()));
}
for (tile, tile_off, visible) in col.tiles_in_render_order() {
@@ -2953,14 +2955,11 @@ impl<W: LayoutElement> ScrollingSpace<W> {
continue;
}
rv.extend(
tile.render(renderer, tile_pos, focus_ring, target)
.map(Into::into),
);
tile.render(renderer, tile_pos, focus_ring, target, &mut |elem| {
push(elem.into())
});
}
}
rv
}
pub fn window_under(&self, pos: Point<f64, Logical>) -> Option<(&W, HitType)> {
@@ -3493,7 +3492,15 @@ impl<W: LayoutElement> ScrollingSpace<W> {
if gesture.dnd_last_event_time.is_some() && gesture.tracker.pos() == 0. {
// DnD didn't scroll anything, so preserve the current view position (rather than
// snapping the window).
self.view_offset = ViewOffset::Static(gesture.delta_from_tracker);
// If there's an ongoing animation within the gesture (e.g. from a window being removed
// during DnD), preserve it.
if let Some(mut anim) = gesture.animation.take() {
anim.offset(gesture.current_view_offset);
self.view_offset = ViewOffset::Animation(anim);
} else {
self.view_offset = ViewOffset::Static(gesture.delta_from_tracker);
}
if !self.columns.is_empty() {
// Just in case, make sure the active window remains on screen.
@@ -4054,6 +4061,14 @@ impl<W: LayoutElement> Column<W> {
}
}
pub fn update_shaders(&mut self) {
for tile in &mut self.tiles {
tile.update_shaders();
}
self.tab_indicator.update_shaders();
}
pub fn advance_animations(&mut self) {
if let Some(move_) = &mut self.move_animation {
if move_.anim.is_done() {
+7 -7
View File
@@ -166,19 +166,19 @@ impl Shadow {
&self,
renderer: &mut impl NiriRenderer,
location: Point<f64, Logical>,
) -> impl Iterator<Item = ShadowRenderElement> + '_ {
push: &mut dyn FnMut(ShadowRenderElement),
) {
if !self.config.on {
return None.into_iter().flatten();
return;
}
let has_shadow_shader = ShadowRenderElement::has_shader(renderer);
if !has_shadow_shader {
return None.into_iter().flatten();
return;
}
let rv = zip(&self.shaders, &self.shader_rects)
.map(move |(shader, rect)| shader.clone().with_location(location + rect.loc));
Some(rv).into_iter().flatten()
for (shader, rect) in zip(&self.shaders, &self.shader_rects) {
push(shader.clone().with_location(location + rect.loc));
}
}
}
+7 -7
View File
@@ -294,17 +294,17 @@ impl TabIndicator {
&self,
renderer: &mut impl NiriRenderer,
pos: Point<f64, Logical>,
) -> impl Iterator<Item = TabIndicatorRenderElement> + '_ {
push: &mut dyn FnMut(TabIndicatorRenderElement),
) {
let has_border_shader = BorderRenderElement::has_shader(renderer);
if !has_border_shader {
return None.into_iter().flatten();
return;
}
let rv = zip(&self.shaders, &self.shader_locs)
.map(move |(shader, loc)| shader.clone().with_location(pos + *loc))
.map(TabIndicatorRenderElement::from);
Some(rv).into_iter().flatten()
for (shader, loc) in zip(&self.shaders, &self.shader_locs) {
let elem = shader.clone().with_location(pos + *loc);
push(TabIndicatorRenderElement::from(elem));
}
}
/// Extra size occupied by the tab indicator.
+63 -11
View File
@@ -166,17 +166,6 @@ impl LayoutElement for TestWindow {
false
}
fn render<R: NiriRenderer>(
&self,
_renderer: &mut R,
_location: Point<f64, Logical>,
_scale: Scale<f64>,
_alpha: f32,
_target: RenderTarget,
) -> SplitElements<LayoutElementRenderElement<R>> {
SplitElements::default()
}
fn request_size(
&mut self,
size: Size<i32, Logical>,
@@ -3674,6 +3663,69 @@ fn tabs_with_different_border() {
check_ops_with_options(options, ops);
}
#[test]
fn expel_pending_left_from_fullscreen_tabbed_column() {
let ops = [
Op::AddOutput(1),
Op::AddWindow {
params: TestWindowParams::new(1),
},
Op::FullscreenWindow(1),
Op::Communicate(1),
// 1 is now fullscreen, view_offset_to_restore is set.
Op::ToggleColumnTabbedDisplay,
Op::AddWindow {
params: TestWindowParams::new(2),
},
Op::ConsumeOrExpelWindowLeft { id: Some(2) },
// 2 is consumed into a fullscreen column, fullscreen is requested but not applied.
//
// Now, get it back out while keeping it focused.
//
// Importantly, we expel it *left*, which results in adding a new column with the exact
// same active_column_idx.
Op::FocusWindow(2),
Op::ConsumeOrExpelWindowLeft { id: None },
];
check_ops(ops);
}
#[test]
fn workspace_render_geo_at_fractional_scale() {
let ops = [
Op::AddScaledOutput {
id: 1,
scale: 1.1,
layout_config: None,
},
Op::AddWindow {
params: TestWindowParams::new(1),
},
Op::FocusWorkspaceDown,
Op::CompleteAnimations,
];
let layout = check_ops(ops);
let MonitorSet::Normal { monitors, .. } = &layout.monitor_set else {
unreachable!()
};
let mon = &monitors[0];
let mut iter = mon.workspaces_with_render_geo();
let (_ws, geo) = iter.next().unwrap();
assert!(
iter.next().is_none(),
"animations are completed, only one workspace should be visible"
);
assert_eq!(
geo.loc.y, 0.,
"active workspace must be at y = 0 exactly, \
otherwise a pointer against the screen edge at y = 0 won't hit it"
);
}
fn parent_id_causes_loop(layout: &Layout<TestWindow>, id: usize, mut parent_id: usize) -> bool {
if parent_id == id {
return true;
+59
View File
@@ -338,6 +338,65 @@ fn interactive_move_unfullscreen_to_floating_stops_dnd_scroll() {
check_ops(ops);
}
#[test]
fn interactive_move_restore_to_floating_animates_view_offset() {
let ops = [
Op::AddOutput(1),
Op::AddWindow {
params: TestWindowParams::new(1),
},
Op::AddWindow {
params: TestWindowParams::new(2),
},
// Toggle window 1 to floating.
Op::FocusWindow(1),
Op::ToggleWindowFloating { id: None },
// Fullscreen window 1 - it moves to scrolling with restore_to_floating = true.
Op::FullscreenWindow(1),
Op::Communicate(1),
Op::CompleteAnimations,
];
let mut layout = check_ops(ops);
// Verify window 1 is in scrolling and has restore_to_floating = true.
let scrolling = layout.active_workspace().unwrap().scrolling();
let tile1 = scrolling.tiles().find(|t| *t.window().id() == 1).unwrap();
assert!(
tile1.restore_to_floating,
"window 1 should have restore_to_floating = true"
);
let ops = [
// Start interactive move on window 1.
Op::InteractiveMoveBegin {
window: 1,
output_idx: 1,
px: 100.,
py: 100.,
},
// Update with a large delta to trigger the unmaximize.
Op::InteractiveMoveUpdate {
window: 1,
dx: 1000.,
dy: 1000.,
output_idx: 1,
px: 0.,
py: 0.,
},
];
check_ops_on_layout(&mut layout, ops);
// Window 1 should now be removed from the workspace (in the interactive move state).
// Window 2 should be the only window in the scrolling space.
let scrolling = layout.active_workspace().unwrap().scrolling();
assert_eq!(scrolling.tiles().count(), 1);
assert!(scrolling.tiles().next().unwrap().window().id() == &2);
// The view offset should be animating to show window 2.
assert!(scrolling.view_offset().is_animation_ongoing());
}
#[test]
fn unfullscreen_view_offset_not_reset_during_dnd_gesture() {
let ops = [
+110 -97
View File
@@ -1007,13 +1007,14 @@ impl<W: LayoutElement> Tile<W> {
Point::from((0., y))
}
fn render_inner<'a, R: NiriRenderer + 'a>(
&'a self,
fn render_inner<R: NiriRenderer>(
&self,
renderer: &mut R,
location: Point<f64, Logical>,
focus_ring: bool,
target: RenderTarget,
) -> impl Iterator<Item = TileRenderElement<R>> + 'a {
push: &mut dyn FnMut(TileRenderElement<R>),
) {
let _span = tracy_client::span!("Tile::render_inner");
let scale = Scale::from(self.scale);
@@ -1041,7 +1042,7 @@ impl<W: LayoutElement> Tile<W> {
let location = location + self.bob_offset();
let window_loc = self.window_loc();
let window_size = self.window_size().to_f64();
let window_size = self.window_size();
let animated_window_size = self.animated_window_size();
let window_render_loc = location + window_loc;
let area = Rectangle::new(window_render_loc, animated_window_size);
@@ -1056,29 +1057,31 @@ impl<W: LayoutElement> Tile<W> {
.unwrap_or_default()
.scaled_by(1. - expanded_progress as f32);
// Popups go on top, whether it's resize or not.
self.window.render_popups(
renderer,
window_render_loc,
scale,
win_alpha,
target,
&mut |elem| push(elem.into()),
);
// If we're resizing, try to render a shader, or a fallback.
let mut resize_shader = None;
let mut resize_popups = None;
let mut resize_fallback = None;
let mut pushed_resize = false;
if let Some(resize) = &self.resize_animation {
resize_popups = Some(
self.window
.render_popups(renderer, window_render_loc, scale, win_alpha, target)
.into_iter()
.map(Into::into),
);
if ResizeRenderElement::has_shader(renderer) {
let gles_renderer = renderer.as_gles_renderer();
if let Some(texture_from) = resize.snapshot.texture(gles_renderer, scale, target) {
let window_elements = self.window.render_normal(
let mut window_elements = Vec::new();
self.window.render_normal(
gles_renderer,
Point::from((0., 0.)),
scale,
1.,
target,
&mut |elem| window_elements.push(elem),
);
let current = resize
@@ -1125,46 +1128,33 @@ impl<W: LayoutElement> Tile<W> {
// This is not a problem for split popups as the code will look for them by
// original id when it doesn't find them on the offscreen.
self.window.set_offscreen_data(Some(data));
resize_shader = Some(elem.into());
push(elem.into());
pushed_resize = true;
}
}
}
if resize_shader.is_none() {
if !pushed_resize {
let fallback_buffer = SolidColorBuffer::new(area.size, [1., 0., 0., 1.]);
resize_fallback = Some(
SolidColorRenderElement::from_buffer(
&fallback_buffer,
area.loc,
win_alpha,
Kind::Unspecified,
)
.into(),
let elem = SolidColorRenderElement::from_buffer(
&fallback_buffer,
area.loc,
win_alpha,
Kind::Unspecified,
);
push(elem.into());
pushed_resize = true;
}
}
// If we're not resizing, render the window itself.
let mut window_surface = None;
let mut window_popups = None;
let mut rounded_corner_damage = None;
let has_border_shader = BorderRenderElement::has_shader(renderer);
if resize_shader.is_none() && resize_fallback.is_none() {
let window = self
.window
.render(renderer, window_render_loc, scale, win_alpha, target);
if !pushed_resize {
let geo = Rectangle::new(window_render_loc, window_size);
let radius = radius.fit_to(window_size.w as f32, window_size.h as f32);
let clip_shader = ClippedSurfaceRenderElement::shader(renderer).cloned();
if clip_to_geometry && clip_shader.is_some() {
let damage = self.rounded_corner_damage.element();
rounded_corner_damage = Some(damage.with_location(window_render_loc).into());
}
window_surface = Some(window.normal.into_iter().map(move |elem| match elem {
let clip = |elem| match elem {
LayoutElementRenderElement::Wayland(elem) => {
// If we should clip to geometry, render a clipped window.
if clip_to_geometry {
@@ -1213,21 +1203,24 @@ impl<W: LayoutElement> Tile<W> {
// Otherwise, render the solid color as is.
LayoutElementRenderElement::SolidColor(elem).into()
}
}));
};
window_popups = Some(window.popups.into_iter().map(Into::into));
if clip_to_geometry && clip_shader.is_some() {
let damage = self.rounded_corner_damage.element();
push(damage.with_location(window_render_loc).into());
}
self.window.render_normal(
renderer,
window_render_loc,
scale,
win_alpha,
target,
&mut |elem| push(clip(elem)),
);
}
let rv = resize_popups
.into_iter()
.flatten()
.chain(resize_shader)
.chain(resize_fallback)
.chain(window_popups.into_iter().flatten())
.chain(rounded_corner_damage)
.chain(window_surface.into_iter().flatten());
let elem = (fullscreen_progress > 0.).then(|| {
if fullscreen_progress > 0. {
let alpha = fullscreen_progress as f32;
// During the un/fullscreen animation, render a border element in order to use the
@@ -1243,7 +1236,7 @@ impl<W: LayoutElement> Tile<W> {
let size = self.fullscreen_backdrop.size();
let color = self.fullscreen_backdrop.color();
BorderRenderElement::new(
let elem = BorderRenderElement::new(
size,
Rectangle::from_size(size),
GradientInterpolation::default(),
@@ -1256,47 +1249,50 @@ impl<W: LayoutElement> Tile<W> {
scale.x as f32,
alpha,
)
.with_location(location)
.into()
.with_location(location);
push(elem.into());
} else {
SolidColorRenderElement::from_buffer(
let elem = SolidColorRenderElement::from_buffer(
&self.fullscreen_backdrop,
location,
alpha,
Kind::Unspecified,
)
.into()
);
push(elem.into());
}
});
let rv = rv.chain(elem);
}
let elem = self.visual_border_width().map(|width| {
self.border
.render(renderer, location + Point::from((width, width)))
.map(Into::into)
});
let rv = rv.chain(elem.into_iter().flatten());
if let Some(width) = self.visual_border_width() {
self.border.render(
renderer,
location + Point::from((width, width)),
&mut |elem| push(elem.into()),
);
}
// Hide the focus ring when maximized/fullscreened. It's not normally visible anyway due to
// being outside the monitor or obscured by a solid colored bar, but it is visible under
// semitransparent bars in maximized state (which is a bit weird) and in the overview (also
// a bit weird).
let elem = (focus_ring && expanded_progress < 1.)
.then(|| self.focus_ring.render(renderer, location).map(Into::into));
let rv = rv.chain(elem.into_iter().flatten());
if focus_ring && expanded_progress < 1. {
self.focus_ring
.render(renderer, location, &mut |elem| push(elem.into()));
}
let elem = (expanded_progress < 1.)
.then(|| self.shadow.render(renderer, location).map(Into::into));
rv.chain(elem.into_iter().flatten())
if expanded_progress < 1. {
self.shadow
.render(renderer, location, &mut |elem| push(elem.into()));
}
}
pub fn render<'a, R: NiriRenderer + 'a>(
&'a self,
pub fn render<R: NiriRenderer>(
&self,
renderer: &mut R,
location: Point<f64, Logical>,
focus_ring: bool,
target: RenderTarget,
) -> impl Iterator<Item = TileRenderElement<R>> + 'a {
push: &mut dyn FnMut(TileRenderElement<R>),
) {
let _span = tracy_client::span!("Tile::render");
let scale = Scale::from(self.scale);
@@ -1306,16 +1302,19 @@ impl<W: LayoutElement> Tile<W> {
.as_ref()
.map_or(1., |alpha| alpha.anim.clamped_value()) as f32;
let mut open_anim_elem = None;
let mut alpha_anim_elem = None;
let mut window_elems = None;
let mut pushed = false;
self.window().set_offscreen_data(None);
if let Some(open) = &self.open_animation {
let renderer = renderer.as_gles_renderer();
let elements = self.render_inner(renderer, Point::from((0., 0.)), focus_ring, target);
let elements = elements.collect::<Vec<TileRenderElement<_>>>();
let mut elements = Vec::new();
self.render_inner(
renderer,
Point::from((0., 0.)),
focus_ring,
target,
&mut |elem| elements.push(elem),
);
match open.render(
renderer,
&elements,
@@ -1326,7 +1325,8 @@ impl<W: LayoutElement> Tile<W> {
) {
Ok((elem, data)) => {
self.window().set_offscreen_data(Some(data));
open_anim_elem = Some(elem.into());
push(elem.into());
pushed = true;
}
Err(err) => {
warn!("error rendering window opening animation: {err:?}");
@@ -1334,15 +1334,22 @@ impl<W: LayoutElement> Tile<W> {
}
} else if let Some(alpha) = &self.alpha_animation {
let renderer = renderer.as_gles_renderer();
let elements = self.render_inner(renderer, Point::from((0., 0.)), focus_ring, target);
let elements = elements.collect::<Vec<TileRenderElement<_>>>();
let mut elements = Vec::new();
self.render_inner(
renderer,
Point::from((0., 0.)),
focus_ring,
target,
&mut |elem| elements.push(elem),
);
match alpha.offscreen.render(renderer, scale, &elements) {
Ok((elem, _sync, data)) => {
let offset = elem.offset();
let elem = elem.with_alpha(tile_alpha).with_offset(location + offset);
self.window().set_offscreen_data(Some(data));
alpha_anim_elem = Some(elem.into());
push(elem.into());
pushed = true;
}
Err(err) => {
warn!("error rendering tile to offscreen for alpha animation: {err:?}");
@@ -1350,14 +1357,11 @@ impl<W: LayoutElement> Tile<W> {
}
}
if open_anim_elem.is_none() && alpha_anim_elem.is_none() {
window_elems = Some(self.render_inner(renderer, location, focus_ring, target));
if !pushed {
self.render_inner(renderer, location, focus_ring, target, &mut |elem| {
push(elem)
});
}
open_anim_elem
.into_iter()
.chain(alpha_anim_elem)
.chain(window_elems.into_iter().flatten())
}
pub fn store_unmap_snapshot_if_empty(&mut self, renderer: &mut GlesRenderer) {
@@ -1371,19 +1375,28 @@ impl<W: LayoutElement> Tile<W> {
fn render_snapshot(&self, renderer: &mut GlesRenderer) -> TileRenderSnapshot {
let _span = tracy_client::span!("Tile::render_snapshot");
let contents = self.render(renderer, Point::from((0., 0.)), false, RenderTarget::Output);
let mut contents = Vec::new();
self.render(
renderer,
Point::from((0., 0.)),
false,
RenderTarget::Output,
&mut |elem| contents.push(elem),
);
// A bit of a hack to render blocked out as for screencast, but I think it's fine here.
let blocked_out_contents = self.render(
let mut blocked_out_contents = Vec::new();
self.render(
renderer,
Point::from((0., 0.)),
false,
RenderTarget::Screencast,
&mut |elem| blocked_out_contents.push(elem),
);
RenderSnapshot {
contents: contents.collect(),
blocked_out_contents: blocked_out_contents.collect(),
contents,
blocked_out_contents,
block_out_from: self.window.rules().block_out_from,
size: self.animated_tile_size(),
texture: Default::default(),
+30 -21
View File
@@ -1386,7 +1386,7 @@ impl<W: LayoutElement> Workspace<W> {
pub fn toggle_window_floating(&mut self, id: Option<&W::Id>) {
let active_id = self.active_window().map(|win| win.id().clone());
let target_is_active = id.map_or(true, |id| Some(id) == active_id.as_ref());
let target_is_active = id.is_none_or(|id| Some(id) == active_id.as_ref());
let Some(id) = id.cloned().or(active_id) else {
return;
};
@@ -1624,39 +1624,48 @@ impl<W: LayoutElement> Workspace<W> {
}
}
pub fn render_elements<R: NiriRenderer>(
pub fn render_scrolling<R: NiriRenderer>(
&self,
renderer: &mut R,
target: RenderTarget,
focus_ring: bool,
) -> (
impl Iterator<Item = WorkspaceRenderElement<R>>,
impl Iterator<Item = WorkspaceRenderElement<R>>,
push: &mut dyn FnMut(WorkspaceRenderElement<R>),
) {
let scrolling_focus_ring = focus_ring && !self.floating_is_active();
let scrolling = self
.scrolling
.render_elements(renderer, target, scrolling_focus_ring);
let scrolling = scrolling.into_iter().map(WorkspaceRenderElement::from);
self.scrolling
.render(renderer, target, scrolling_focus_ring, &mut |elem| {
push(elem.into())
});
}
pub fn render_floating<R: NiriRenderer>(
&self,
renderer: &mut R,
target: RenderTarget,
focus_ring: bool,
push: &mut dyn FnMut(WorkspaceRenderElement<R>),
) {
if !self.is_floating_visible() {
return;
}
let view_rect = Rectangle::from_size(self.view_size);
let floating_focus_ring = focus_ring && self.floating_is_active();
let floating = self.is_floating_visible().then(|| {
let view_rect = Rectangle::from_size(self.view_size);
let floating =
self.floating
.render_elements(renderer, view_rect, target, floating_focus_ring);
floating.into_iter().map(WorkspaceRenderElement::from)
});
let floating = floating.into_iter().flatten();
(floating, scrolling)
self.floating.render(
renderer,
view_rect,
target,
floating_focus_ring,
&mut |elem| push(elem.into()),
);
}
pub fn render_shadow<R: NiriRenderer>(
&self,
renderer: &mut R,
) -> impl Iterator<Item = ShadowRenderElement> + '_ {
self.shadow.render(renderer, Point::from((0., 0.)))
push: &mut dyn FnMut(ShadowRenderElement),
) {
self.shadow.render(renderer, Point::from((0., 0.)), push);
}
pub fn render_background(&self) -> SolidColorRenderElement {
+2 -8
View File
@@ -19,17 +19,11 @@ pub mod niri;
pub mod protocols;
pub mod render_helpers;
pub mod rubber_band;
#[cfg(feature = "xdp-gnome-screencast")]
pub mod screencasting;
pub mod ui;
pub mod utils;
pub mod window;
#[cfg(not(feature = "xdp-gnome-screencast"))]
pub mod dummy_pw_utils;
#[cfg(feature = "xdp-gnome-screencast")]
pub mod pw_utils;
#[cfg(not(feature = "xdp-gnome-screencast"))]
pub use dummy_pw_utils as pw_utils;
#[cfg(test)]
mod tests;
+2 -2
View File
@@ -7,6 +7,7 @@ use std::io::{self, Write};
use std::os::fd::FromRawFd;
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::Ordering;
use std::{env, mem};
use calloop::EventLoop;
@@ -26,7 +27,6 @@ use niri::utils::spawning::{
use niri::utils::{cause_panic, version, watcher, xwayland, IS_SYSTEMD_SERVICE};
use niri_config::{Config, ConfigPath};
use niri_ipc::socket::SOCKET_PATH_ENV;
use portable_atomic::Ordering;
use sd_notify::NotifyState;
use smithay::reexports::wayland_server::Display;
use tracing_subscriber::EnvFilter;
@@ -228,7 +228,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
state.niri.a11y.start();
}
if env::var_os("NIRI_DISABLE_SYSTEM_MANAGER_NOTIFY").map_or(true, |x| x != "1") {
if env::var_os("NIRI_DISABLE_SYSTEM_MANAGER_NOTIFY").is_none_or(|x| x != "1") {
// Notify systemd we're ready.
if let Err(err) = sd_notify::notify(true, &[NotifyState::Ready]) {
warn!("error notifying systemd: {err:?}");
+361 -782
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -4,6 +4,7 @@ pub mod gamma_control;
pub mod mutter_x11_interop;
pub mod output_management;
pub mod screencopy;
pub mod virtual_keyboard;
pub mod virtual_pointer;
pub mod raw;
+261 -27
View File
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
@@ -9,11 +9,7 @@ use smithay::backend::allocator::dmabuf::Dmabuf;
use smithay::backend::allocator::{Buffer, Fourcc};
use smithay::backend::renderer::damage::OutputDamageTracker;
use smithay::backend::renderer::sync::SyncPoint;
use smithay::output::Output;
use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1::{
Flags, ZwlrScreencopyFrameV1,
};
use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1;
use smithay::output::{Output, WeakOutput};
use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::{
zwlr_screencopy_frame_v1, zwlr_screencopy_manager_v1,
};
@@ -24,49 +20,181 @@ use smithay::reexports::wayland_server::{
};
use smithay::utils::{Physical, Point, Rectangle, Size, Transform};
use smithay::wayland::{dmabuf, shm};
use wayland_backend::server::Credentials;
use zwlr_screencopy_frame_v1::{Flags, ZwlrScreencopyFrameV1};
use zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1;
use crate::utils::get_monotonic_time;
use crate::utils::{get_credentials_for_client, get_monotonic_time, CastSessionId, CastStreamId};
const VERSION: u32 = 3;
/// Inactivity timeout for considering a screencopy cast as stopped.
///
/// xdg-desktop-portal-wlr keeps the screencopy manager alive across casts, so there's no way to
/// tell that a screencast had stopped. So we use a timeout: if no new with_damage frames are
/// requested for this timeout, consider the screencast finished.
const CAST_TIMEOUT: Duration = Duration::from_secs(10);
pub struct ScreencopyQueue {
/// Credentials of this wlr-screencopy client, if known.
credentials: Option<Credentials>,
damage_tracker: OutputDamageTracker,
/// Frames waiting for the client to call copy or destroy.
pending_frames: HashSet<ZwlrScreencopyFrameV1>,
/// Queue of screencopies waiting for a corresponding output redraw with damage.
screencopies: Vec<Screencopy>,
/// Cast tracking, set when the first with_damage request arrives.
cast: Option<ScreencopyCast>,
}
impl Default for ScreencopyQueue {
fn default() -> Self {
Self::new()
pub struct ScreencopyCast {
pub session_id: CastSessionId,
pub stream_id: CastStreamId,
/// Output being captured.
///
/// Generally equal to the front entry in the queue, and persisted here when the queue becomes
/// empty.
pub output: WeakOutput,
/// Cached name of the output.
pub output_name: String,
/// Deadline after which this cast is considered stopped if no new frames arrive.
pub deadline: Duration,
}
impl ScreencopyCast {
fn new(output: &Output) -> Self {
Self {
session_id: CastSessionId::next(),
stream_id: CastStreamId::next(),
output: output.downgrade(),
output_name: output.name(),
deadline: get_monotonic_time() + CAST_TIMEOUT,
}
}
fn update_deadline(&mut self) {
self.deadline = get_monotonic_time() + CAST_TIMEOUT;
}
fn update_output(&mut self, output: &Output) {
// Only allocate a new name when the output differs.
let weak = output.downgrade();
if self.output != weak {
self.output = weak;
self.output_name = output.name();
}
}
}
impl ScreencopyQueue {
pub fn new() -> Self {
pub fn new(credentials: Option<Credentials>) -> Self {
Self {
damage_tracker: OutputDamageTracker::new((0, 0), 1.0, Transform::Normal),
pending_frames: HashSet::new(),
screencopies: Vec::new(),
cast: None,
credentials,
}
}
pub fn is_empty(&self) -> bool {
self.pending_frames.is_empty() && self.screencopies.is_empty()
}
/// Get the cast tracking info, if this queue is tracking a cast.
pub fn cast(&self) -> Option<&ScreencopyCast> {
self.cast.as_ref()
}
pub fn credentials(&self) -> Option<Credentials> {
self.credentials
}
pub fn split(&mut self) -> (&mut OutputDamageTracker, Option<&Screencopy>) {
let ScreencopyQueue {
damage_tracker,
screencopies,
..
} = self;
(damage_tracker, screencopies.first())
}
pub fn push(&mut self, screencopy: Screencopy) {
// Screencopy without damage is rendered immediately without the queue.
if !screencopy.with_damage() {
error!("only screencopy with damage can be pushed in the queue");
}
if let Some(cast) = &mut self.cast {
// Update cast output when pushing a new front screencopy.
if self.screencopies.is_empty() {
cast.update_output(screencopy.output());
}
} else {
// First with_damage request, mark this as a screencast.
let output = screencopy.output();
self.cast = Some(ScreencopyCast::new(output));
}
self.screencopies.push(screencopy);
}
pub fn pop(&mut self) -> Screencopy {
self.screencopies.pop().unwrap()
let rv = self.screencopies.remove(0);
let cast = self.cast.as_mut().unwrap();
if let Some(first) = self.screencopies.first() {
// Update cast output (most of the time we expect this to be the same).
cast.update_output(first.output());
} else {
// Queue became empty, update deadline for considering the cast stopped.
cast.update_deadline();
}
rv
}
pub fn remove_output(&mut self, output: &Output) {
pub fn clear_expired_cast(&mut self) {
if let Some(cast) = &self.cast {
// Check deadline if there are no in-flight frames.
if self.screencopies.is_empty() && cast.deadline <= get_monotonic_time() {
self.cast = None;
}
}
}
fn remove_output(&mut self, output: &Output) {
if self.screencopies.is_empty() {
return;
}
self.screencopies
.retain(|screencopy| screencopy.output() != output);
if let Some(cast) = &mut self.cast {
if self.screencopies.is_empty() {
// Queue became empty, update deadline for considering the cast stopped.
cast.update_deadline();
}
}
}
fn remove_frame(&mut self, frame: &ZwlrScreencopyFrameV1) {
self.pending_frames.remove(frame);
if self.screencopies.is_empty() {
return;
}
self.screencopies
.retain(|screencopy| screencopy.frame != *frame);
if let Some(cast) = &mut self.cast {
if self.screencopies.is_empty() {
// Queue became empty, update deadline for considering the cast stopped.
cast.update_deadline();
}
}
}
}
@@ -99,23 +227,54 @@ impl ScreencopyManagerState {
}
}
pub fn bind(&mut self, manager: &ZwlrScreencopyManagerV1) {
// Clean up all entries if its manager is dead and its queue is empty.
self.queues
.retain(|k, v| k.is_alive() || !v.screencopies.is_empty());
pub fn push(&mut self, manager: &ZwlrScreencopyManagerV1, screencopy: Screencopy) {
let Some(queue) = self.queues.get_mut(manager) else {
// Destroying the manager does not invalidate existing frames, so the queue should
// keep existing.
error!("screencopy queue must not be deleted as long as frames exist");
return;
};
self.queues.insert(manager.clone(), ScreencopyQueue::new());
queue.push(screencopy);
}
pub fn get_queue_mut(
pub fn damage_tracker(
&mut self,
manager: &ZwlrScreencopyManagerV1,
) -> Option<&mut ScreencopyQueue> {
self.queues.get_mut(manager)
) -> Option<&mut OutputDamageTracker> {
let queue = self.queues.get_mut(manager)?;
Some(&mut queue.damage_tracker)
}
pub fn queues_mut(&mut self) -> impl Iterator<Item = &mut ScreencopyQueue> {
self.queues.values_mut()
pub fn remove_output(&mut self, output: &Output) {
for queue in self.queues.values_mut() {
queue.remove_output(output);
}
self.cleanup_queues();
}
pub fn queues(&self) -> impl Iterator<Item = &ScreencopyQueue> {
self.queues.values()
}
pub fn with_queues_mut(&mut self, mut f: impl FnMut(&mut ScreencopyQueue)) {
for queue in self.queues.values_mut() {
f(queue);
}
self.cleanup_queues();
}
fn cleanup_queues(&mut self) {
self.queues
.retain(|manager, queue| manager.is_alive() || !queue.is_empty());
}
pub fn clear_expired_casts(&mut self) {
for queue in self.queues.values_mut() {
queue.clear_expired_cast();
}
}
}
@@ -130,14 +289,18 @@ where
{
fn bind(
state: &mut D,
_display: &DisplayHandle,
_client: &Client,
dh: &DisplayHandle,
client: &Client,
manager: New<ZwlrScreencopyManagerV1>,
_manager_state: &ScreencopyManagerGlobalData,
data_init: &mut DataInit<'_, D>,
) {
let manager = data_init.init(manager, ());
state.screencopy_state().bind(&manager);
let state = state.screencopy_state();
let credentials = get_credentials_for_client(dh, client);
let queue = ScreencopyQueue::new(credentials);
state.queues.insert(manager.clone(), queue);
}
fn can_view(client: Client, global_data: &ScreencopyManagerGlobalData) -> bool {
@@ -154,7 +317,7 @@ where
D: 'static,
{
fn request(
_state: &mut D,
state: &mut D,
_client: &Client,
manager: &ZwlrScreencopyManagerV1,
request: zwlr_screencopy_manager_v1::Request,
@@ -273,13 +436,50 @@ where
// Notify client that all supported buffers were enumerated.
frame.buffer_done();
}
let state = state.screencopy_state();
let queue = state.queues.get_mut(manager).unwrap();
queue.pending_frames.insert(frame);
}
fn destroyed(
state: &mut D,
_client: wayland_backend::server::ClientId,
manager: &ZwlrScreencopyManagerV1,
_data: &(),
) {
let state = state.screencopy_state();
let Some(queue) = state.queues.get_mut(manager) else {
// This happened once. I'm really not sure how exactly though.
//
// I've dug into wayland-server and wayland-backend, and apparently there are a bunch
// of places where calling destroyed() is delayed (even on a +1 ms timer). Then, it's
// quite possible for some code to run cleanup_queues() *before* this destroyed()
// handler, and delete the queue because the manager is no longer .is_alive() by then.
// Then, queue will be None here.
//
// My attempts to reproduce this in a test have failed though. Perhaps it requires a
// tricky timing condition where the client disconnects at some precise spot inside our
// State::refresh_and_flush_clients() call.
return;
};
// Clean up the queue if this was the last object.
if queue.is_empty() {
state.queues.remove(manager);
}
}
}
/// Handler trait for wlr-screencopy.
pub trait ScreencopyHandler {
/// Handle new screencopy request.
///
/// The handler must synchronously either ready/fail the screencopy, or submit it to the
/// manager queue.
fn frame(&mut self, manager: &ZwlrScreencopyManagerV1, screencopy: Screencopy);
fn screencopy_state(&mut self) -> &mut ScreencopyManagerState;
}
@@ -405,6 +605,40 @@ where
submitted: false,
},
);
// By this point the frame should've been either copied or failed or pushed to the queue,
// so remove it from pending frames.
let state = state.screencopy_state();
let queue = state.queues.get_mut(manager).unwrap();
queue.pending_frames.remove(frame);
if queue.is_empty() && !manager.is_alive() {
state.queues.remove(manager);
}
}
fn destroyed(
state: &mut D,
_client: wayland_backend::server::ClientId,
frame: &ZwlrScreencopyFrameV1,
data: &ScreencopyFrameState,
) {
let ScreencopyFrameState::Pending { manager, .. } = data else {
return;
};
let state = state.screencopy_state();
let Some(queue) = state.queues.get_mut(manager) else {
// I think this can happen when we post_error() on a pending frame? Either way better
// safe than sorry.
return;
};
queue.remove_frame(frame);
// Clean up the queue if this was the last object.
if queue.is_empty() && !manager.is_alive() {
state.queues.remove(manager);
}
}
}
+132
View File
@@ -0,0 +1,132 @@
use smithay::backend::input::{
Device, DeviceCapability, Event, InputBackend, InputEvent, KeyState, KeyboardKeyEvent, Keycode,
UnusedEvent,
};
use smithay::delegate_virtual_keyboard_manager;
use smithay::input::keyboard::xkb::ModMask;
use smithay::input::keyboard::KeyboardHandle;
use smithay::wayland::virtual_keyboard::VirtualKeyboardHandler;
use crate::niri::State;
pub struct VirtualKeyboardInputBackend;
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
pub struct VirtualKeyboard;
impl Device for VirtualKeyboard {
fn id(&self) -> String {
String::from("virtual keyboard")
}
fn name(&self) -> String {
String::from("virtual keyboard")
}
fn has_capability(&self, capability: DeviceCapability) -> bool {
matches!(capability, DeviceCapability::Keyboard)
}
fn usb_id(&self) -> Option<(u32, u32)> {
None
}
fn syspath(&self) -> Option<std::path::PathBuf> {
None
}
}
pub struct VirtualKeyboardKeyEvent {
pub keycode: Keycode,
pub state: KeyState,
pub time: u32,
}
impl Event<VirtualKeyboardInputBackend> for VirtualKeyboardKeyEvent {
fn time(&self) -> u64 {
self.time as u64 * 1000 // millis to micros
}
fn device(&self) -> VirtualKeyboard {
VirtualKeyboard
}
}
impl KeyboardKeyEvent<VirtualKeyboardInputBackend> for VirtualKeyboardKeyEvent {
fn key_code(&self) -> Keycode {
self.keycode
}
fn state(&self) -> KeyState {
self.state
}
fn count(&self) -> u32 {
0 // Not used by niri
}
}
impl InputBackend for VirtualKeyboardInputBackend {
type Device = VirtualKeyboard;
type KeyboardKeyEvent = VirtualKeyboardKeyEvent;
type PointerAxisEvent = UnusedEvent;
type PointerButtonEvent = UnusedEvent;
type PointerMotionEvent = UnusedEvent;
type PointerMotionAbsoluteEvent = UnusedEvent;
type GestureSwipeBeginEvent = UnusedEvent;
type GestureSwipeUpdateEvent = UnusedEvent;
type GestureSwipeEndEvent = UnusedEvent;
type GesturePinchBeginEvent = UnusedEvent;
type GesturePinchUpdateEvent = UnusedEvent;
type GesturePinchEndEvent = UnusedEvent;
type GestureHoldBeginEvent = UnusedEvent;
type GestureHoldEndEvent = UnusedEvent;
type TouchDownEvent = UnusedEvent;
type TouchUpEvent = UnusedEvent;
type TouchMotionEvent = UnusedEvent;
type TouchCancelEvent = UnusedEvent;
type TouchFrameEvent = UnusedEvent;
type TabletToolAxisEvent = UnusedEvent;
type TabletToolProximityEvent = UnusedEvent;
type TabletToolTipEvent = UnusedEvent;
type TabletToolButtonEvent = UnusedEvent;
type SwitchToggleEvent = UnusedEvent;
type SpecialEvent = UnusedEvent;
}
impl VirtualKeyboardHandler for State {
fn on_keyboard_event(
&mut self,
keycode: Keycode,
state: KeyState,
time: u32,
_keyboard: KeyboardHandle<Self>,
) {
// The virtual keyboard impl in Smithay changes the keymap, so we'll need to reset it on
// the next real keyboard event.
self.niri.reset_keymap = true;
let event = VirtualKeyboardKeyEvent {
keycode,
state,
time,
};
self.process_input_event(InputEvent::<VirtualKeyboardInputBackend>::Keyboard { event });
}
// We handle modifiers when the key event is sent.
fn on_keyboard_modifiers(
&mut self,
_depressed_mods: ModMask,
_latched_mods: ModMask,
_locked_mods: ModMask,
_keyboard: KeyboardHandle<Self>,
) {
}
}
delegate_virtual_keyboard_manager!(State);
+19 -4
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::rc::Rc;
use glam::{Mat3, Vec2};
use niri_config::{
@@ -7,12 +8,14 @@ use niri_config::{
use smithay::backend::renderer::element::{Element, Id, Kind, RenderElement, UnderlyingStorage};
use smithay::backend::renderer::gles::{GlesError, GlesFrame, GlesRenderer, Uniform};
use smithay::backend::renderer::utils::{CommitCounter, DamageSet, OpaqueRegions};
use smithay::gpu_span_location;
use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Scale, Size, Transform};
use super::renderer::NiriRenderer;
use super::shader_element::ShaderRenderElement;
use super::shaders::{mat3_uniform, ProgramType, Shaders};
use crate::backend::tty::{TtyFrame, TtyRenderer, TtyRendererError};
use crate::render_helpers::renderer::AsGlesFrame as _;
/// Renders a wide variety of borders and border parts.
///
@@ -197,7 +200,7 @@ impl BorderRenderElement {
None,
scale,
alpha,
vec![
Rc::new([
Uniform::new("colorspace", colorspace),
Uniform::new("hue_interpolation", hue_interpolation),
Uniform::new("color_from", color_from.to_array_unpremul()),
@@ -209,7 +212,7 @@ impl BorderRenderElement {
Uniform::new("geo_size", geo_size.to_array()),
Uniform::new("outer_radius", <[f32; 4]>::from(corner_radius)),
Uniform::new("border_width", border_width),
],
]),
HashMap::new(),
);
}
@@ -283,7 +286,17 @@ impl RenderElement<GlesRenderer> for BorderRenderElement {
damage: &[Rectangle<i32, Physical>],
opaque_regions: &[Rectangle<i32, Physical>],
) -> Result<(), GlesError> {
RenderElement::<GlesRenderer>::draw(&self.inner, frame, src, dst, damage, opaque_regions)
let _span = tracy_client::span!("BorderRenderElement::draw");
frame.with_gpu_span(gpu_span_location!("BorderRenderElement::draw"), |frame| {
RenderElement::<GlesRenderer>::draw(
&self.inner,
frame,
src,
dst,
damage,
opaque_regions,
)
})
}
fn underlying_storage(&self, renderer: &mut GlesRenderer) -> Option<UnderlyingStorage<'_>> {
@@ -300,7 +313,9 @@ impl<'render> RenderElement<TtyRenderer<'render>> for BorderRenderElement {
damage: &[Rectangle<i32, Physical>],
opaque_regions: &[Rectangle<i32, Physical>],
) -> Result<(), TtyRendererError<'render>> {
RenderElement::<TtyRenderer<'_>>::draw(&self.inner, frame, src, dst, damage, opaque_regions)
let frame = frame.as_gles_frame();
RenderElement::<GlesRenderer>::draw(self, frame, src, dst, damage, opaque_regions)?;
Ok(())
}
fn underlying_storage(
+26 -21
View File
@@ -19,7 +19,7 @@ pub struct ClippedSurfaceRenderElement<R: NiriRenderer> {
program: GlesTexProgram,
corner_radius: CornerRadius,
geometry: Rectangle<f64, Logical>,
uniforms: Vec<Uniform<'static>>,
scale: f32,
}
#[derive(Debug, Default, Clone)]
@@ -36,23 +36,34 @@ impl<R: NiriRenderer> ClippedSurfaceRenderElement<R> {
program: GlesTexProgram,
corner_radius: CornerRadius,
) -> Self {
let elem_geo = elem.geometry(scale);
Self {
inner: elem,
program,
corner_radius,
geometry,
scale: scale.x as f32,
}
}
fn compute_uniforms(&self) -> Vec<Uniform<'static>> {
let scale = Scale::from(f64::from(self.scale));
let elem_geo = self.inner.geometry(scale);
let elem_geo_loc = Vec2::new(elem_geo.loc.x as f32, elem_geo.loc.y as f32);
let elem_geo_size = Vec2::new(elem_geo.size.w as f32, elem_geo.size.h as f32);
let geo = geometry.to_physical_precise_round(scale);
let geo = self.geometry.to_physical_precise_round(scale);
let geo_loc = Vec2::new(geo.loc.x, geo.loc.y);
let geo_size = Vec2::new(geo.size.w, geo.size.h);
let buf_size = elem.buffer_size();
let buf_size = self.inner.buffer_size();
let buf_size = Vec2::new(buf_size.w as f32, buf_size.h as f32);
let view = elem.view();
let view = self.inner.view();
let src_loc = Vec2::new(view.src.loc.x as f32, view.src.loc.y as f32);
let src_size = Vec2::new(view.src.size.w as f32, view.src.size.h as f32);
let transform = elem.transform();
let transform = self.inner.transform();
// HACK: ??? for some reason flipped ones are fine.
let transform = match transform {
Transform::_90 => Transform::_270,
@@ -70,20 +81,14 @@ impl<R: NiriRenderer> ClippedSurfaceRenderElement<R> {
* Mat3::from_scale(buf_size / src_size)
* Mat3::from_translation(-src_loc / buf_size);
let uniforms = vec![
Uniform::new("niri_scale", scale.x as f32),
Uniform::new("geo_size", (geometry.size.w as f32, geometry.size.h as f32)),
Uniform::new("corner_radius", <[f32; 4]>::from(corner_radius)),
mat3_uniform("input_to_geo", input_to_geo),
];
let geo_size = (self.geometry.size.w as f32, self.geometry.size.h as f32);
Self {
inner: elem,
program,
corner_radius,
geometry,
uniforms,
}
vec![
Uniform::new("niri_scale", self.scale),
Uniform::new("geo_size", geo_size),
Uniform::new("corner_radius", <[f32; 4]>::from(self.corner_radius)),
mat3_uniform("input_to_geo", input_to_geo),
]
}
pub fn shader(renderer: &mut R) -> Option<&GlesTexProgram> {
@@ -224,7 +229,7 @@ impl RenderElement<GlesRenderer> for ClippedSurfaceRenderElement<GlesRenderer> {
damage: &[Rectangle<i32, Physical>],
opaque_regions: &[Rectangle<i32, Physical>],
) -> Result<(), GlesError> {
frame.override_default_tex_program(self.program.clone(), self.uniforms.clone());
frame.override_default_tex_program(self.program.clone(), self.compute_uniforms());
RenderElement::<GlesRenderer>::draw(&self.inner, frame, src, dst, damage, opaque_regions)?;
frame.clear_tex_program_override();
Ok(())
@@ -250,7 +255,7 @@ impl<'render> RenderElement<TtyRenderer<'render>>
) -> Result<(), TtyRendererError<'render>> {
frame
.as_gles_frame()
.override_default_tex_program(self.program.clone(), self.uniforms.clone());
.override_default_tex_program(self.program.clone(), self.compute_uniforms());
RenderElement::draw(&self.inner, frame, src, dst, damage, opaque_regions)?;
frame.as_gles_frame().clear_tex_program_override();
Ok(())
+32 -41
View File
@@ -8,54 +8,45 @@ use super::renderer::NiriRenderer;
use super::solid_color::SolidColorRenderElement;
use crate::niri::OutputRenderElements;
pub fn draw_opaque_regions<R: NiriRenderer>(
elements: &mut Vec<OutputRenderElements<R>>,
pub fn push_opaque_regions<R: NiriRenderer>(
elem: &OutputRenderElements<R>,
scale: Scale<f64>,
push: &mut dyn FnMut(OutputRenderElements<R>),
) {
let _span = tracy_client::span!("draw_opaque_regions");
// HACK
if format!("{elem:?}").contains("ExtraDamage") {
return;
}
let mut i = 0;
while i < elements.len() {
let elem = &elements[i];
i += 1;
let geo = elem.geometry(scale);
let mut opaque = elem.opaque_regions(scale).to_vec();
// HACK
if format!("{elem:?}").contains("ExtraDamage") {
continue;
}
for rect in &mut opaque {
rect.loc += geo.loc;
}
let geo = elem.geometry(scale);
let mut opaque = elem.opaque_regions(scale).to_vec();
let semitransparent = geo.subtract_rects(opaque.iter().copied());
for rect in &mut opaque {
rect.loc += geo.loc;
}
for rect in opaque {
let color = SolidColorRenderElement::new(
Id::new(),
rect.to_f64().to_logical(scale),
CommitCounter::default(),
Color32F::from([0., 0., 0.2, 0.2]),
Kind::Unspecified,
);
push(color.into());
}
let semitransparent = geo.subtract_rects(opaque.iter().copied());
for rect in opaque {
let color = SolidColorRenderElement::new(
Id::new(),
rect.to_f64().to_logical(scale),
CommitCounter::default(),
Color32F::from([0., 0., 0.2, 0.2]),
Kind::Unspecified,
);
elements.insert(i - 1, OutputRenderElements::SolidColor(color));
i += 1;
}
for rect in semitransparent {
let color = SolidColorRenderElement::new(
Id::new(),
rect.to_f64().to_logical(scale),
CommitCounter::default(),
Color32F::from([0.3, 0., 0., 0.3]),
Kind::Unspecified,
);
elements.insert(i - 1, OutputRenderElements::SolidColor(color));
i += 1;
}
for rect in semitransparent {
let color = SolidColorRenderElement::new(
Id::new(),
rect.to_f64().to_logical(scale),
CommitCounter::default(),
Color32F::from([0.3, 0., 0., 0.3]),
Kind::Unspecified,
);
push(color.into());
}
}
+4 -4
View File
@@ -14,7 +14,7 @@ use crate::render_helpers::shaders::Shaders;
pub struct GradientFadeTextureRenderElement {
inner: TextureRenderElement<GlesTexture>,
program: GradientFadeShader,
uniforms: Vec<Uniform<'static>>,
cutoff: (f32, f32),
}
#[derive(Debug, Clone)]
@@ -33,11 +33,10 @@ impl GradientFadeTextureRenderElement {
// Texture is displayed full-size, no cutoff necessary.
(1., 1.)
};
let uniforms = vec![Uniform::new("cutoff", cutoff)];
Self {
inner: texture,
program,
uniforms,
cutoff,
}
}
@@ -98,7 +97,8 @@ impl RenderElement<GlesRenderer> for GradientFadeTextureRenderElement {
damage: &[Rectangle<i32, Physical>],
opaque_regions: &[Rectangle<i32, Physical>],
) -> Result<(), GlesError> {
frame.override_default_tex_program(self.program.0.clone(), self.uniforms.clone());
let uniforms = vec![Uniform::new("cutoff", self.cutoff)];
frame.override_default_tex_program(self.program.0.clone(), uniforms);
RenderElement::<GlesRenderer>::draw(&self.inner, frame, src, dst, damage, opaque_regions)?;
frame.clear_tex_program_override();
Ok(())
+2 -44
View File
@@ -5,7 +5,7 @@ use niri_config::BlockOutFrom;
use smithay::backend::allocator::dmabuf::Dmabuf;
use smithay::backend::allocator::{Buffer, Fourcc};
use smithay::backend::renderer::element::utils::{Relocate, RelocateRenderElement};
use smithay::backend::renderer::element::{Kind, RenderElement};
use smithay::backend::renderer::element::{Element, Kind, RenderElement};
use smithay::backend::renderer::gles::{GlesMapping, GlesRenderer, GlesTarget, GlesTexture};
use smithay::backend::renderer::sync::SyncPoint;
use smithay::backend::renderer::{Bind, Color32F, ExportMem, Frame, Offscreen, Renderer};
@@ -58,13 +58,6 @@ pub struct BakedBuffer<B> {
pub dst: Option<Size<i32, Logical>>,
}
/// Render elements split into normal and popup.
#[derive(Debug)]
pub struct SplitElements<E> {
pub normal: Vec<E>,
pub popups: Vec<E>,
}
pub trait ToRenderElement {
type RenderElement;
@@ -87,41 +80,6 @@ impl RenderTarget {
}
}
impl<E> Default for SplitElements<E> {
fn default() -> Self {
Self {
normal: Vec::new(),
popups: Vec::new(),
}
}
}
impl<E> IntoIterator for SplitElements<E> {
type Item = E;
type IntoIter = std::iter::Chain<std::vec::IntoIter<E>, std::vec::IntoIter<E>>;
fn into_iter(self) -> Self::IntoIter {
self.popups.into_iter().chain(self.normal)
}
}
impl<E> SplitElements<E> {
pub fn iter(&self) -> std::iter::Chain<std::slice::Iter<'_, E>, std::slice::Iter<'_, E>> {
self.popups.iter().chain(&self.normal)
}
pub fn into_vec(self) -> Vec<E> {
let Self { normal, mut popups } = self;
popups.extend(normal);
popups
}
pub fn extend(&mut self, other: SplitElements<E>) {
self.popups.extend(other.popups);
self.normal.extend(other.normal);
}
}
impl ToRenderElement for BakedBuffer<TextureBuffer<GlesTexture>> {
type RenderElement = PrimaryGpuTextureRenderElement;
@@ -160,7 +118,7 @@ impl ToRenderElement for BakedBuffer<SolidColorBuffer> {
pub fn encompassing_geo(
scale: Scale<f64>,
elements: impl Iterator<Item = impl RenderElement<GlesRenderer>>,
elements: impl Iterator<Item = impl Element>,
) -> Rectangle<i32, Physical> {
elements
.map(|ele| ele.geometry(scale))
+10 -6
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::rc::Rc;
use glam::{Mat3, Vec2};
use niri_config::CornerRadius;
@@ -6,6 +7,7 @@ use smithay::backend::renderer::element::{Element, Id, Kind, RenderElement, Unde
use smithay::backend::renderer::gles::{GlesError, GlesFrame, GlesRenderer, GlesTexture, Uniform};
use smithay::backend::renderer::utils::{CommitCounter, DamageSet, OpaqueRegions};
use smithay::backend::renderer::Texture as _;
use smithay::gpu_span_location;
use smithay::utils::{Buffer, Logical, Physical, Rectangle, Scale, Size, Transform};
use super::renderer::{AsGlesFrame, NiriRenderer};
@@ -90,7 +92,7 @@ impl ResizeRenderElement {
None,
scale.x,
result_alpha,
vec![
Rc::new([
mat3_uniform("niri_input_to_curr_geo", input_to_curr_geo),
mat3_uniform("niri_curr_geo_to_prev_geo", curr_geo_to_prev_geo),
mat3_uniform("niri_curr_geo_to_next_geo", curr_geo_to_next_geo),
@@ -101,7 +103,7 @@ impl ResizeRenderElement {
Uniform::new("niri_clamped_progress", clamped_progress),
Uniform::new("niri_corner_radius", <[f32; 4]>::from(corner_radius)),
Uniform::new("niri_clip_to_geometry", clip_to_geometry),
],
]),
HashMap::from([
(String::from("niri_tex_prev"), texture_prev),
(String::from("niri_tex_next"), texture_next),
@@ -170,8 +172,10 @@ impl RenderElement<GlesRenderer> for ResizeRenderElement {
damage: &[Rectangle<i32, Physical>],
opaque_regions: &[Rectangle<i32, Physical>],
) -> Result<(), GlesError> {
RenderElement::<GlesRenderer>::draw(&self.0, frame, src, dst, damage, opaque_regions)?;
Ok(())
let _span = tracy_client::span!("ResizeRenderElement::draw");
frame.with_gpu_span(gpu_span_location!("ResizeRenderElement::draw"), |frame| {
RenderElement::<GlesRenderer>::draw(&self.0, frame, src, dst, damage, opaque_regions)
})
}
fn underlying_storage(&self, renderer: &mut GlesRenderer) -> Option<UnderlyingStorage<'_>> {
@@ -188,8 +192,8 @@ impl<'render> RenderElement<TtyRenderer<'render>> for ResizeRenderElement {
damage: &[Rectangle<i32, Physical>],
opaque_regions: &[Rectangle<i32, Physical>],
) -> Result<(), TtyRendererError<'render>> {
let gles_frame = frame.as_gles_frame();
RenderElement::<GlesRenderer>::draw(&self.0, gles_frame, src, dst, damage, opaque_regions)?;
let frame = frame.as_gles_frame();
RenderElement::<GlesRenderer>::draw(self, frame, src, dst, damage, opaque_regions)?;
Ok(())
}
+9 -6
View File
@@ -28,7 +28,7 @@ pub struct ShaderRenderElement {
// Should only be used for visual improvements, i.e. corner radius anti-aliasing.
scale: f32,
alpha: f32,
additional_uniforms: Vec<Uniform<'static>>,
additional_uniforms: Rc<[Uniform<'static>]>,
textures: HashMap<String, GlesTexture>,
kind: Kind,
}
@@ -185,7 +185,7 @@ impl ShaderRenderElement {
// Should only be used for visual improvements, i.e. corner radius anti-aliasing.
scale: f32,
alpha: f32,
additional_uniforms: Vec<Uniform<'static>>,
additional_uniforms: Rc<[Uniform<'static>]>,
textures: HashMap<String, GlesTexture>,
kind: Kind,
) -> Self {
@@ -212,7 +212,7 @@ impl ShaderRenderElement {
opaque_regions: vec![],
scale: 1.,
alpha: 1.,
additional_uniforms: vec![],
additional_uniforms: Rc::new([]),
textures: HashMap::new(),
kind,
}
@@ -228,7 +228,7 @@ impl ShaderRenderElement {
opaque_regions: Option<Vec<Rectangle<f64, Logical>>>,
scale: f32,
alpha: f32,
uniforms: Vec<Uniform<'static>>,
uniforms: Rc<[Uniform<'static>]>,
textures: HashMap<String, GlesTexture>,
) {
self.area.size = size;
@@ -294,6 +294,8 @@ impl RenderElement<GlesRenderer> for ShaderRenderElement {
damage: &[Rectangle<i32, Physical>],
_opaque_regions: &[Rectangle<i32, Physical>],
) -> Result<(), GlesError> {
let _span = tracy_client::span!("ShaderRenderElement::draw");
let frame = frame.as_gles_frame();
let Some(shader) = Shaders::get_from_frame(frame).program(self.program) else {
@@ -373,7 +375,8 @@ impl RenderElement<GlesRenderer> for ShaderRenderElement {
let has_tint = frame.debug_flags().contains(DebugFlags::TINT);
// render
frame.with_context(move |gl| -> Result<(), GlesError> {
let span_loc = smithay::gpu_span_location!("draw shader");
frame.with_profiled_context(span_loc, move |gl| -> Result<(), GlesError> {
let program = if has_debug {
&shader.0.debug
} else {
@@ -425,7 +428,7 @@ impl RenderElement<GlesRenderer> for ShaderRenderElement {
gl.Uniform1f(shader.0.uniform_tint, tint);
}
for uniform in &self.additional_uniforms {
for uniform in &*self.additional_uniforms {
let desc =
program
.additional_uniforms
+19 -4
View File
@@ -1,16 +1,19 @@
use std::collections::HashMap;
use std::rc::Rc;
use glam::{Mat3, Vec2};
use niri_config::{Color, CornerRadius};
use smithay::backend::renderer::element::{Element, Id, Kind, RenderElement, UnderlyingStorage};
use smithay::backend::renderer::gles::{GlesError, GlesFrame, GlesRenderer, Uniform};
use smithay::backend::renderer::utils::{CommitCounter, DamageSet, OpaqueRegions};
use smithay::gpu_span_location;
use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Scale, Size, Transform};
use super::renderer::NiriRenderer;
use super::shader_element::ShaderRenderElement;
use super::shaders::{mat3_uniform, ProgramType, Shaders};
use crate::backend::tty::{TtyFrame, TtyRenderer, TtyRendererError};
use crate::render_helpers::renderer::AsGlesFrame as _;
/// Renders a rounded rectangle shadow.
#[derive(Debug, Clone)]
@@ -153,7 +156,7 @@ impl ShadowRenderElement {
None,
scale,
alpha,
vec![
Rc::new([
Uniform::new("shadow_color", color.to_array_premul()),
Uniform::new("sigma", sigma),
mat3_uniform("input_to_geo", input_to_geo),
@@ -165,7 +168,7 @@ impl ShadowRenderElement {
"window_corner_radius",
<[f32; 4]>::from(window_corner_radius),
),
],
]),
HashMap::new(),
);
}
@@ -244,7 +247,17 @@ impl RenderElement<GlesRenderer> for ShadowRenderElement {
damage: &[Rectangle<i32, Physical>],
opaque_regions: &[Rectangle<i32, Physical>],
) -> Result<(), GlesError> {
RenderElement::<GlesRenderer>::draw(&self.inner, frame, src, dst, damage, opaque_regions)
let _span = tracy_client::span!("ShadowRenderElement::draw");
frame.with_gpu_span(gpu_span_location!("ShadowRenderElement::draw"), |frame| {
RenderElement::<GlesRenderer>::draw(
&self.inner,
frame,
src,
dst,
damage,
opaque_regions,
)
})
}
fn underlying_storage(&self, renderer: &mut GlesRenderer) -> Option<UnderlyingStorage<'_>> {
@@ -261,7 +274,9 @@ impl<'render> RenderElement<TtyRenderer<'render>> for ShadowRenderElement {
damage: &[Rectangle<i32, Physical>],
opaque_regions: &[Rectangle<i32, Physical>],
) -> Result<(), TtyRendererError<'render>> {
RenderElement::<TtyRenderer<'_>>::draw(&self.inner, frame, src, dst, damage, opaque_regions)
let frame = frame.as_gles_frame();
RenderElement::<GlesRenderer>::draw(self, frame, src, dst, damage, opaque_regions)?;
Ok(())
}
fn underlying_storage(
+2 -2
View File
@@ -49,7 +49,7 @@ where
) -> Option<&(GlesTexture, Rectangle<i32, Physical>)> {
if target.should_block_out(self.block_out_from) {
self.blocked_out_texture.get_or_init(|| {
let _span = tracy_client::span!("RenderSnapshot::Texture");
let _span = tracy_client::span!("RenderSnapshot::texture");
let elements: Vec<_> = self
.blocked_out_contents
@@ -75,7 +75,7 @@ where
})
} else {
self.texture.get_or_init(|| {
let _span = tracy_client::span!("RenderSnapshot::Texture");
let _span = tracy_client::span!("RenderSnapshot::texture");
let elements: Vec<_> = self
.contents
+68 -2
View File
@@ -1,8 +1,10 @@
use smithay::backend::renderer::element::surface::WaylandSurfaceRenderElement;
use smithay::backend::renderer::element::Kind;
use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture};
use smithay::backend::renderer::utils::{import_surface, RendererSurfaceStateUserData};
use smithay::backend::renderer::Renderer as _;
use smithay::backend::renderer::{ImportAll, Renderer};
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Point};
use smithay::utils::{Logical, Physical, Point, Scale};
use smithay::wayland::compositor::{with_surface_tree_downward, TraversalAction};
use super::texture::TextureBuffer;
@@ -78,3 +80,67 @@ pub fn render_snapshot_from_surface_tree(
|_, _, _| true,
);
}
pub fn push_elements_from_surface_tree<R>(
renderer: &mut R,
surface: &WlSurface,
// Fractional scale expects surface buffers to be aligned to physical pixels.
location: Point<i32, Physical>,
scale: Scale<f64>,
alpha: f32,
kind: Kind,
push: &mut dyn FnMut(WaylandSurfaceRenderElement<R>),
) where
R: Renderer + ImportAll,
R::TextureId: Clone + 'static,
{
let _span = tracy_client::span!("push_elements_from_surface_tree");
let location = location.to_f64();
with_surface_tree_downward(
surface,
location,
|_, states, location| {
let mut location = *location;
let data = states.data_map.get::<RendererSurfaceStateUserData>();
if let Some(data) = data {
if let Some(view) = data.lock().unwrap().view() {
location += view.offset.to_f64().to_physical(scale);
TraversalAction::DoChildren(location)
} else {
TraversalAction::SkipChildren
}
} else {
TraversalAction::SkipChildren
}
},
|surface, states, location| {
let mut location = *location;
let data = states.data_map.get::<RendererSurfaceStateUserData>();
if let Some(data) = data {
let has_view = if let Some(view) = data.lock().unwrap().view() {
location += view.offset.to_f64().to_physical(scale);
true
} else {
false
};
if has_view {
match WaylandSurfaceRenderElement::from_surface(
renderer, surface, states, location, alpha, kind,
) {
Ok(Some(surface)) => push(surface),
Ok(None) => {} // surface is not mapped
Err(err) => {
warn!("failed to import surface: {}", err);
}
};
}
}
},
|_, _, _| true,
);
}
+798
View File
@@ -0,0 +1,798 @@
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::mem;
use std::time::Duration;
use anyhow::Context as _;
use calloop::LoopHandle;
use smithay::backend::allocator::format::FormatSet;
use smithay::backend::allocator::gbm::GbmDevice;
use smithay::backend::drm::DrmDeviceFd;
use smithay::backend::renderer::element::utils::{Relocate, RelocateRenderElement};
use smithay::backend::renderer::gles::GlesRenderer;
use smithay::desktop::Window;
use smithay::output::Output;
use smithay::reexports::gbm::Modifier;
use smithay::utils::{Physical, Point, Scale, Size};
use zbus::object_server::SignalEmitter;
use crate::dbus::mutter_screen_cast::{self, CursorMode, ScreenCastToNiri, StreamTargetId};
use crate::niri::{CastTarget, Niri, OutputRenderElements, PointerRenderElements, State};
use crate::niri_render_elements;
use crate::render_helpers::RenderTarget;
use crate::utils::{get_monotonic_time, CastSessionId, CastStreamId};
use crate::window::mapped::{MappedId, WindowCastRenderElements};
mod pw_utils;
use pw_utils::{Cast, CastSizeChange, CursorData, PipeWire, PwToNiri};
pub struct Screencasting {
pub casts: Vec<Cast>,
/// Dynamic-target casts waiting for their first target to start.
pub pending_dynamic_casts: Vec<PendingCast>,
pub pw_to_niri: calloop::channel::Sender<PwToNiri>,
/// Screencast output for each mapped window.
pub mapped_cast_output: HashMap<Window, Output>,
/// Window ID for the "dynamic cast" special window for the xdp-gnome picker.
pub dynamic_cast_id_for_portal: MappedId,
// Drop PipeWire last, and specifically after casts, to prevent a double-free (yay).
pub pipewire: Option<PipeWire>,
}
/// A screencast request that hasn't been started yet.
pub struct PendingCast {
pub session_id: CastSessionId,
pub stream_id: CastStreamId,
pub cursor_mode: CursorMode,
pub signal_ctx: SignalEmitter<'static>,
}
impl Screencasting {
pub fn new(event_loop: &LoopHandle<'static, State>) -> Self {
let pw_to_niri = {
let (pw_to_niri, from_pipewire) = calloop::channel::channel();
event_loop
.insert_source(from_pipewire, move |event, _, state| match event {
calloop::channel::Event::Msg(msg) => state.on_pw_msg(msg),
calloop::channel::Event::Closed => (),
})
.unwrap();
pw_to_niri
};
Self {
casts: vec![],
pending_dynamic_casts: vec![],
pw_to_niri,
mapped_cast_output: HashMap::new(),
dynamic_cast_id_for_portal: MappedId::next(),
pipewire: None,
}
}
}
impl State {
fn prepare_pw_cast(&mut self) -> anyhow::Result<(GbmDevice<DrmDeviceFd>, FormatSet)> {
let gbm = self
.backend
.gbm_device()
.context("no GBM device available")?;
// Ensure PipeWire is initialized.
if self.niri.casting.pipewire.is_none() {
let pw = PipeWire::new(
self.niri.event_loop.clone(),
self.niri.casting.pw_to_niri.clone(),
)
.context("error initializing PipeWire")?;
self.niri.casting.pipewire = Some(pw);
}
let mut render_formats = self
.backend
.with_primary_renderer(|renderer| {
renderer.egl_context().dmabuf_render_formats().clone()
})
.unwrap_or_default();
{
let config = self.niri.config.borrow();
if config.debug.force_pipewire_invalid_modifier {
render_formats = render_formats
.into_iter()
.filter(|f| f.modifier == Modifier::Invalid)
.collect();
}
}
Ok((gbm, render_formats))
}
pub fn on_pw_msg(&mut self, msg: PwToNiri) {
match msg {
PwToNiri::StopCast { session_id } => self.niri.stop_cast(session_id),
PwToNiri::Redraw { stream_id } => self.redraw_cast(stream_id),
PwToNiri::FatalError => {
warn!("stopping PipeWire due to fatal error");
let casting = &mut self.niri.casting;
if let Some(pw) = casting.pipewire.take() {
let mut ids = HashSet::new();
for cast in &casting.pending_dynamic_casts {
ids.insert(cast.session_id);
}
for cast in &casting.casts {
ids.insert(cast.session_id);
}
for id in ids {
self.niri.stop_cast(id);
}
self.niri.event_loop.remove(pw.token);
}
}
}
}
fn redraw_cast(&mut self, stream_id: CastStreamId) {
let _span = tracy_client::span!("State::redraw_cast");
let casts = &mut self.niri.casting.casts;
let Some(idx) = casts.iter().position(|cast| cast.stream_id == stream_id) else {
warn!("cast to redraw is missing");
return;
};
let cast = &mut casts[idx];
let id = match &cast.target {
CastTarget::Nothing => {
self.backend.with_primary_renderer(|renderer| {
if cast.dequeue_buffer_and_clear(renderer) {
cast.last_frame_time = get_monotonic_time();
}
});
return;
}
CastTarget::Output { output, .. } => {
if let Some(output) = output.upgrade() {
self.niri.queue_redraw(&output);
}
return;
}
CastTarget::Window { id } => *id,
};
// Lack of partial borrowing strikes again...
let mut casts = mem::take(&mut self.niri.casting.casts);
let cast = &mut casts[idx];
let mut stop = false;
// Use a loop {} so we can break instead of early-return.
#[allow(clippy::never_loop)]
loop {
let mut windows = self.niri.layout.windows();
let Some((_, mapped)) = windows.find(|(_, mapped)| mapped.id().get() == id) else {
break;
};
// Use the cached output since it will be present even if the output was
// currently disconnected.
let Some(output) = self.niri.casting.mapped_cast_output.get(&mapped.window) else {
break;
};
let scale = Scale::from(output.current_scale().fractional_scale());
let bbox = mapped
.window
.bbox_with_popups()
.to_physical_precise_up(scale);
match cast.ensure_size(bbox.size) {
Ok(CastSizeChange::Ready) => (),
Ok(CastSizeChange::Pending) => break,
Err(err) => {
warn!("error updating stream size, stopping screencast: {err:?}");
stop = true;
break;
}
}
self.backend.with_primary_renderer(|renderer| {
let mut elements = Vec::new();
mapped.render_for_screen_cast(renderer, scale, &mut |elem| {
elements.push(CastRenderElement::from(elem))
});
let mut pointer_elements = Vec::new();
let mut pointer_location = Point::default();
if self.niri.pointer_visibility.is_visible() {
if let Some((pointer_pos, win_pos)) =
self.niri.pointer_pos_for_window_cast(mapped)
{
// Pointer location must be relative to the screencast buffer.
// - win_pos is the position of the main window surface in output-local
// coordinates
// - bbox.loc moves us relative to the screencast buffer
let buf_pos = win_pos + bbox.loc.to_f64().to_logical(scale);
let output_pos =
self.niri.global_space.output_geometry(output).unwrap().loc;
pointer_location = pointer_pos - output_pos.to_f64() - buf_pos;
let pos = buf_pos.to_physical_precise_round(scale).upscale(-1);
self.niri.render_pointer(renderer, output, &mut |elem| {
let elem =
RelocateRenderElement::from_element(elem, pos, Relocate::Relative);
pointer_elements.push(CastRenderElement::from(elem));
});
}
}
let cursor_data = CursorData::compute(&pointer_elements, pointer_location, scale);
if cast.dequeue_buffer_and_render(
renderer,
&elements,
&cursor_data,
bbox.size,
scale,
) {
cast.last_frame_time = get_monotonic_time();
}
});
break;
}
let session_id = cast.session_id;
self.niri.casting.casts = casts;
if stop {
self.niri.stop_cast(session_id);
}
}
pub fn set_dynamic_cast_target(&mut self, target: CastTarget) {
let _span = tracy_client::span!("State::set_dynamic_cast_target");
let mut refresh = None;
match &target {
// Leave refresh as is when clearing. Chances are, the next refresh will match it,
// then we'll avoid reconfiguring.
CastTarget::Nothing => (),
CastTarget::Output { output, .. } => {
if let Some(output) = output.upgrade() {
refresh = Some(output.current_mode().unwrap().refresh as u32);
}
}
CastTarget::Window { id } => {
let mut windows = self.niri.layout.windows();
if let Some((_, mapped)) = windows.find(|(_, mapped)| mapped.id().get() == *id) {
if let Some(output) = self.niri.casting.mapped_cast_output.get(&mapped.window) {
refresh = Some(output.current_mode().unwrap().refresh as u32);
}
}
}
}
let mut to_redraw = Vec::new();
let mut to_stop = Vec::new();
for cast in &mut self.niri.casting.casts {
if !cast.dynamic_target {
continue;
}
if let Some(refresh) = refresh {
if let Err(err) = cast.set_refresh(refresh) {
warn!("error changing cast FPS: {err:?}");
to_stop.push(cast.session_id);
continue;
}
}
cast.target = target.clone();
to_redraw.push(cast.stream_id);
}
for id in to_redraw {
self.redraw_cast(id);
}
// Start any pending dynamic casts if we have a real target.
if !matches!(target, CastTarget::Nothing) {
self.start_pending_dynamic_casts(&target);
}
}
fn start_pending_dynamic_casts(&mut self, target: &CastTarget) {
let pending = &self.niri.casting.pending_dynamic_casts;
if pending.is_empty() {
return;
}
debug!("starting {} pending dynamic cast(s)", pending.len());
let _span = tracy_client::span!("State::start_pending_dynamic_casts");
// We don't stop dynamic casts on missing output/window.
let (size, refresh) = match target {
CastTarget::Nothing => panic!("dynamic cast starting target must not be Nothing"),
CastTarget::Output { output, .. } => {
let Some(output) = output.upgrade() else {
return;
};
cast_params_for_output(&output)
}
CastTarget::Window { id } => {
let Some((size, refresh)) = self.niri.cast_params_for_window(*id) else {
return;
};
(size, refresh)
}
};
let (gbm, render_formats) = match self.prepare_pw_cast() {
Ok(x) => x,
Err(err) => {
warn!("error starting pending screencasts: {err:?}");
let mut ids = HashSet::new();
for pending in self.niri.casting.pending_dynamic_casts.drain(..) {
ids.insert(pending.session_id);
}
for id in ids {
self.niri.stop_cast(id);
}
return;
}
};
let pw = self.niri.casting.pipewire.as_ref().unwrap();
// Alpha is always true since the dynamic target can change between window & output.
let alpha = true;
// Start each pending cast.
let mut to_stop = HashSet::new();
for pending in self.niri.casting.pending_dynamic_casts.drain(..) {
let res = pw.start_cast(
gbm.clone(),
render_formats.clone(),
pending.session_id,
pending.stream_id,
target.clone(),
size,
refresh,
alpha,
pending.cursor_mode,
pending.signal_ctx,
);
match res {
Ok(mut cast) => {
cast.dynamic_target = true;
self.niri.casting.casts.push(cast);
}
Err(err) => {
warn!("error starting pending screencast: {err:?}");
to_stop.insert(pending.session_id);
}
}
}
for session_id in to_stop {
self.niri.stop_cast(session_id);
}
}
pub fn on_screen_cast_msg(&mut self, msg: ScreenCastToNiri) {
match msg {
ScreenCastToNiri::StartCast {
session_id,
stream_id,
target,
cursor_mode,
signal_ctx,
} => {
let _span = tracy_client::span!("StartCast");
let _span = debug_span!("StartCast", %session_id, %stream_id).entered();
let (target, size, refresh, alpha) = match target {
StreamTargetId::Output { name } => {
let global_space = &self.niri.global_space;
let output = global_space.outputs().find(|out| out.name() == name);
let Some(output) = output else {
warn!("error starting screencast: requested output is missing");
self.niri.stop_cast(session_id);
return;
};
let (size, refresh) = cast_params_for_output(output);
(CastTarget::output(output), size, refresh, false)
}
StreamTargetId::Window { id }
if id == self.niri.casting.dynamic_cast_id_for_portal.get() =>
{
debug!("delaying dynamic cast until target is set");
self.niri.casting.pending_dynamic_casts.push(PendingCast {
session_id,
stream_id,
cursor_mode,
signal_ctx,
});
return;
}
StreamTargetId::Window { id } => {
let Some((size, refresh)) = self.niri.cast_params_for_window(id) else {
warn!("error starting screencast: requested window is missing");
self.niri.stop_cast(session_id);
return;
};
(CastTarget::Window { id }, size, refresh, true)
}
};
let (gbm, render_formats) = match self.prepare_pw_cast() {
Ok(x) => x,
Err(err) => {
warn!("error starting screencast: {err:?}");
self.niri.stop_cast(session_id);
return;
}
};
let pw = self.niri.casting.pipewire.as_ref().unwrap();
let res = pw.start_cast(
gbm,
render_formats,
session_id,
stream_id,
target,
size,
refresh,
alpha,
cursor_mode,
signal_ctx,
);
match res {
Ok(cast) => {
self.niri.casting.casts.push(cast);
}
Err(err) => {
warn!("error starting screencast: {err:?}");
self.niri.stop_cast(session_id);
}
}
}
ScreenCastToNiri::StopCast { session_id } => self.niri.stop_cast(session_id),
}
}
}
impl Niri {
pub fn refresh_mapped_cast_window_rules(&mut self) {
// O(N^2) but should be fine since there aren't many casts usually.
self.layout.with_windows_mut(|mapped, _| {
let id = mapped.id().get();
// Find regardless of cast.is_active.
let value = self
.casting
.casts
.iter()
.any(|cast| cast.target == (CastTarget::Window { id }));
mapped.set_is_window_cast_target(value);
});
}
pub fn refresh_mapped_cast_outputs(&mut self) {
let mut seen = HashSet::new();
let mut output_changed = vec![];
self.layout.with_windows(|mapped, output, _, _| {
seen.insert(mapped.window.clone());
let Some(output) = output else {
return;
};
match self.casting.mapped_cast_output.entry(mapped.window.clone()) {
Entry::Occupied(mut entry) => {
if entry.get() != output {
entry.insert(output.clone());
output_changed.push((mapped.id(), output.clone()));
}
}
Entry::Vacant(entry) => {
entry.insert(output.clone());
}
}
});
self.casting
.mapped_cast_output
.retain(|win, _| seen.contains(win));
let mut to_stop = vec![];
for (id, out) in output_changed {
let refresh = out.current_mode().unwrap().refresh as u32;
let target = CastTarget::Window { id: id.get() };
for cast in self
.casting
.casts
.iter_mut()
.filter(|cast| cast.target == target)
{
if let Err(err) = cast.set_refresh(refresh) {
warn!("error changing cast FPS: {err:?}");
to_stop.push(cast.session_id);
};
}
}
for session_id in to_stop {
self.stop_cast(session_id);
}
}
pub fn render_for_screen_cast(
&mut self,
renderer: &mut GlesRenderer,
output: &Output,
target_presentation_time: Duration,
) {
let _span = tracy_client::span!("Niri::render_for_screen_cast");
let weak = output.downgrade();
let size = output.current_mode().unwrap().size;
let transform = output.current_transform();
let size = transform.transform_size(size);
let scale = Scale::from(output.current_scale().fractional_scale());
let mut elements = Vec::new();
let mut pointer = Vec::new();
let mut cursor_data = None;
let mut casts_to_stop = vec![];
let mut casts = mem::take(&mut self.casting.casts);
for cast in &mut casts {
if !cast.is_active() {
continue;
}
if !cast.target.matches_output(&weak) {
continue;
}
match cast.ensure_size(size) {
Ok(CastSizeChange::Ready) => (),
Ok(CastSizeChange::Pending) => continue,
Err(err) => {
warn!("error updating stream size, stopping screencast: {err:?}");
casts_to_stop.push(cast.session_id);
}
}
if cast.check_time_and_schedule(output, target_presentation_time) {
continue;
}
if cursor_data.is_none() {
// FIXME: support debug draw opaque regions.
self.render_inner(
renderer,
output,
false,
RenderTarget::Screencast,
&mut |elem| elements.push(elem.into()),
);
let mut pointer_pos = Point::default();
if self.pointer_visibility.is_visible() {
let output_geo = self.global_space.output_geometry(output).unwrap().to_f64();
let pointer_loc = self
.tablet_cursor_location
.unwrap_or_else(|| self.seat.get_pointer().unwrap().current_location());
// Only render when the pointer is within the output. Otherwise, it will
// happily appear anywhere outside the output video source in OBS.
if output_geo.contains(pointer_loc) {
pointer_pos = pointer_loc - output_geo.loc;
self.render_pointer(renderer, output, &mut |elem| {
pointer.push(elem.into())
});
}
}
cursor_data = Some(CursorData::compute(&pointer, pointer_pos, scale));
}
let cursor_data = cursor_data.as_ref().unwrap();
if cast.dequeue_buffer_and_render(renderer, &elements, cursor_data, size, scale) {
cast.last_frame_time = target_presentation_time;
}
}
self.casting.casts = casts;
for id in casts_to_stop {
self.stop_cast(id);
}
}
pub fn render_windows_for_screen_cast(
&mut self,
renderer: &mut GlesRenderer,
output: &Output,
target_presentation_time: Duration,
) {
let _span = tracy_client::span!("Niri::render_windows_for_screen_cast");
let scale = Scale::from(output.current_scale().fractional_scale());
let mut casts_to_stop = vec![];
let mut casts = mem::take(&mut self.casting.casts);
for cast in &mut casts {
if !cast.is_active() {
continue;
}
let CastTarget::Window { id } = cast.target else {
continue;
};
let mut windows = self.layout.windows_for_output(output);
let Some(mapped) = windows.find(|win| win.id().get() == id) else {
continue;
};
let bbox = mapped
.window
.bbox_with_popups()
.to_physical_precise_up(scale);
match cast.ensure_size(bbox.size) {
Ok(CastSizeChange::Ready) => (),
Ok(CastSizeChange::Pending) => continue,
Err(err) => {
warn!("error updating stream size, stopping screencast: {err:?}");
casts_to_stop.push(cast.session_id);
}
}
if cast.check_time_and_schedule(output, target_presentation_time) {
continue;
}
let mut elements = Vec::new();
mapped.render_for_screen_cast(renderer, scale, &mut |elem| {
elements.push(CastRenderElement::from(elem))
});
let mut pointer_elements = Vec::new();
let mut pointer_location = Point::default();
if self.pointer_visibility.is_visible() {
if let Some((pointer_pos, win_pos)) = self.pointer_pos_for_window_cast(mapped) {
// Pointer location must be relative to the screencast buffer.
// - win_pos is the position of the main window surface in output-local
// coordinates
// - bbox.loc moves us relative to the screencast buffer
let buf_pos = win_pos + bbox.loc.to_f64().to_logical(scale);
let output_pos = self.global_space.output_geometry(output).unwrap().loc;
pointer_location = pointer_pos - output_pos.to_f64() - buf_pos;
let pos = buf_pos.to_physical_precise_round(scale).upscale(-1);
self.render_pointer(renderer, output, &mut |elem| {
let elem =
RelocateRenderElement::from_element(elem, pos, Relocate::Relative);
pointer_elements.push(CastRenderElement::from(elem));
});
}
}
let cursor_data = CursorData::compute(&pointer_elements, pointer_location, scale);
if cast.dequeue_buffer_and_render(renderer, &elements, &cursor_data, bbox.size, scale) {
cast.last_frame_time = target_presentation_time;
}
}
self.casting.casts = casts;
for id in casts_to_stop {
self.stop_cast(id);
}
}
pub fn stop_cast(&mut self, session_id: CastSessionId) {
let _span = tracy_client::span!("Niri::stop_cast");
let _span = debug_span!("stop_cast", %session_id).entered();
self.casting
.pending_dynamic_casts
.retain(|p| p.session_id != session_id);
for i in (0..self.casting.casts.len()).rev() {
let cast = &self.casting.casts[i];
if cast.session_id != session_id {
continue;
}
let cast = self.casting.casts.swap_remove(i);
if let Err(err) = cast.stream.disconnect() {
warn!("error disconnecting stream: {err:?}");
}
}
let dbus = &self.dbus.as_ref().unwrap();
let server = dbus.conn_screen_cast.as_ref().unwrap().object_server();
let path = format!("/org/gnome/Mutter/ScreenCast/Session/u{}", session_id.get());
if let Ok(iface) = server.interface::<_, mutter_screen_cast::Session>(path) {
let _span = tracy_client::span!("invoking Session::stop");
async_io::block_on(async move {
iface
.get()
.stop(server.inner(), iface.signal_emitter().clone())
.await
});
}
}
pub fn stop_casts_for_target(&mut self, target: CastTarget) {
let _span = tracy_client::span!("Niri::stop_casts_for_target");
// This is O(N^2) but it shouldn't be a problem I think.
let mut saw_dynamic = false;
let mut ids = Vec::new();
for cast in &self.casting.casts {
if cast.target != target {
continue;
}
if cast.dynamic_target {
saw_dynamic = true;
continue;
}
ids.push(cast.session_id);
}
for id in ids {
self.stop_cast(id);
}
// We don't stop dynamic casts, instead we switch them to Nothing.
if saw_dynamic {
self.event_loop
.insert_idle(|state| state.set_dynamic_cast_target(CastTarget::Nothing));
}
}
fn cast_params_for_window(&self, window_id: u64) -> Option<(Size<i32, Physical>, u32)> {
let (_, mapped) = self
.layout
.windows()
.find(|(_, m)| m.id().get() == window_id)?;
let output = self.casting.mapped_cast_output.get(&mapped.window)?;
let scale = Scale::from(output.current_scale().fractional_scale());
let bbox = mapped
.window
.bbox_with_popups()
.to_physical_precise_up(scale);
let refresh = output.current_mode().unwrap().refresh as u32;
Some((bbox.size, refresh))
}
}
fn cast_params_for_output(output: &Output) -> (Size<i32, Physical>, u32) {
let mode = output.current_mode().unwrap();
let transform = output.current_transform();
let size = transform.transform_size(mode.size);
let refresh = mode.refresh as u32;
(size, refresh)
}
niri_render_elements! {
CastRenderElement<R> => {
Output = OutputRenderElements<R>,
Window = WindowCastRenderElements<R>,
Pointer = PointerRenderElements<R>,
RelocatedPointer = RelocateRenderElement<PointerRenderElements<R>>,
}
}
+363 -70
View File
@@ -1,12 +1,13 @@
use std::cell::RefCell;
use std::cmp::min;
use std::collections::HashMap;
use std::io::Cursor;
use std::iter::zip;
use std::mem;
use std::os::fd::{AsFd, AsRawFd, BorrowedFd};
use std::ptr::NonNull;
use std::rc::Rc;
use std::time::Duration;
use std::{mem, slice};
use anyhow::Context as _;
use calloop::timer::{TimeoutAction, Timer};
@@ -29,31 +30,46 @@ use pipewire::spa::utils::{
};
use pipewire::spa::{self};
use pipewire::stream::{Stream, StreamFlags, StreamListener, StreamRc, StreamState};
use pipewire::sys::{pw_buffer, pw_stream_queue_buffer};
use pipewire::sys::{pw_buffer, pw_check_library_version, 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};
use smithay::backend::allocator::{Format, Fourcc};
use smithay::backend::drm::DrmDeviceFd;
use smithay::backend::renderer::damage::OutputDamageTracker;
use smithay::backend::renderer::element::RenderElement;
use smithay::backend::renderer::element::utils::{Relocate, RelocateRenderElement};
use smithay::backend::renderer::element::{Element, RenderElement};
use smithay::backend::renderer::gles::GlesRenderer;
use smithay::backend::renderer::sync::SyncPoint;
use smithay::backend::renderer::ExportMem;
use smithay::output::{Output, OutputModeSource};
use smithay::reexports::calloop::generic::Generic;
use smithay::reexports::calloop::{Interest, LoopHandle, Mode, PostAction};
use smithay::reexports::gbm::Modifier;
use smithay::utils::{Physical, Scale, Size, Transform};
use smithay::utils::{Logical, Physical, Point, Scale, Size, Transform};
use zbus::object_server::SignalEmitter;
use crate::dbus::mutter_screen_cast::{self, CursorMode};
use crate::niri::{CastTarget, State};
use crate::render_helpers::{clear_dmabuf, render_to_dmabuf};
use crate::utils::get_monotonic_time;
use crate::render_helpers::{
clear_dmabuf, encompassing_geo, render_and_download, render_to_dmabuf,
};
use crate::screencasting::CastRenderElement;
use crate::utils::{get_monotonic_time, CastSessionId, CastStreamId};
// Give a 0.1 ms allowance for presentation time errors.
const CAST_DELAY_ALLOWANCE: Duration = Duration::from_micros(100);
const CURSOR_FORMAT: spa_video_format = SPA_VIDEO_FORMAT_BGRA;
const CURSOR_BPP: u32 = 4;
const CURSOR_WIDTH: u32 = 384;
const CURSOR_HEIGHT: u32 = 384;
const CURSOR_BITMAP_SIZE: usize = (CURSOR_WIDTH * CURSOR_HEIGHT * CURSOR_BPP) as usize;
const CURSOR_META_SIZE: usize =
mem::size_of::<spa_meta_cursor>() + mem::size_of::<spa_meta_bitmap>() + CURSOR_BITMAP_SIZE;
const BITMAP_META_OFFSET: usize = mem::size_of::<spa_meta_cursor>();
const BITMAP_DATA_OFFSET: usize = mem::size_of::<spa_meta_bitmap>();
pub struct PipeWire {
_context: ContextRc,
pub core: CoreRc,
@@ -63,22 +79,23 @@ pub struct PipeWire {
}
pub enum PwToNiri {
StopCast { session_id: usize },
Redraw { stream_id: usize },
StopCast { session_id: CastSessionId },
Redraw { stream_id: CastStreamId },
FatalError,
}
pub struct Cast {
event_loop: LoopHandle<'static, State>,
pub session_id: usize,
pub stream_id: usize,
pub stream: StreamRc,
pub session_id: CastSessionId,
pub stream_id: CastStreamId,
// Listener is dropped before Stream to prevent a use-after-free.
_listener: StreamListener<()>,
pub stream: StreamRc,
pub target: CastTarget,
pub dynamic_target: bool,
formats: FormatSet,
offer_alpha: bool,
pub cursor_mode: CursorMode,
cursor_mode: CursorMode,
pub last_frame_time: Duration,
scheduled_redraw: Option<RegistrationToken>,
// Incremented once per successful frame, stored in buffer meta.
@@ -123,6 +140,8 @@ enum CastState {
plane_count: i32,
// Lazily-initialized to keep the initialization to a single place.
damage_tracker: Option<OutputDamageTracker>,
cursor_damage_tracker: Option<OutputDamageTracker>,
last_cursor_location: Option<Point<i32, Physical>>,
},
}
@@ -132,6 +151,49 @@ pub enum CastSizeChange {
Pending,
}
/// Data for drawing a cursor either as metadata or embedded.
///
/// We have weird borrowed references here in order to support both metadata and embedded cases.
/// The cursor damage tracker needs a slice of impl Element at (0, 0), so we pass it `relocated`
/// (luckily, &impl Element also impls Element). Then, if we need to embed the cursor, we chain the
/// elements to the main video buffer elements, so we need the same type. We use `original` for
/// this; `E` is expected to match the type of the main video buffer elements.
#[derive(Debug)]
pub struct CursorData<'a, E> {
/// Cursor elements at their original location.
original: &'a [E],
/// Cursor elements relocated to (0, 0).
relocated: Vec<RelocateRenderElement<&'a E>>,
/// Location of the cursor's hotspot in the video buffer.
location: Point<i32, Physical>,
/// Location of the cursor's hotspot on the cursor bitmap.
hotspot: Point<i32, Physical>,
/// Size of the elements' encompassing geo.
size: Size<i32, Physical>,
/// Scale the elements should be rendered at.
scale: Scale<f64>,
}
impl<'a, E: Element> CursorData<'a, E> {
pub fn compute(elements: &'a [E], location: Point<f64, Logical>, scale: Scale<f64>) -> Self {
let location = location.to_physical_precise_round(scale);
let geo = encompassing_geo(scale, elements.iter());
let relocated = Vec::from_iter(elements.iter().map(|elem| {
RelocateRenderElement::from_element(elem, geo.loc.upscale(-1), Relocate::Relative)
}));
Self {
original: elements,
relocated,
location,
hotspot: location - geo.loc,
size: geo.size,
scale,
}
}
}
macro_rules! make_params {
($params:ident, $formats:expr, $size:expr, $refresh:expr, $alpha:expr) => {
let mut b1 = Vec::new();
@@ -207,14 +269,13 @@ impl PipeWire {
&self,
gbm: GbmDevice<DrmDeviceFd>,
formats: FormatSet,
session_id: usize,
stream_id: usize,
session_id: CastSessionId,
stream_id: CastStreamId,
target: CastTarget,
dynamic_target: bool,
size: Size<i32, Physical>,
refresh: u32,
alpha: bool,
cursor_mode: CursorMode,
mut cursor_mode: CursorMode,
signal_ctx: SignalEmitter<'static>,
) -> anyhow::Result<Cast> {
let _span = tracy_client::span!("PipeWire::start_cast");
@@ -222,13 +283,13 @@ impl PipeWire {
let to_niri_ = self.to_niri.clone();
let stop_cast = move || {
if let Err(err) = to_niri_.send(PwToNiri::StopCast { session_id }) {
warn!(session_id, "error sending StopCast to niri: {err:?}");
warn!(%session_id, "error sending StopCast to niri: {err:?}");
}
};
let to_niri_ = self.to_niri.clone();
let redraw = move || {
if let Err(err) = to_niri_.send(PwToNiri::Redraw { stream_id }) {
warn!(stream_id, "error sending Redraw to niri: {err:?}");
warn!(%stream_id, "error sending Redraw to niri: {err:?}");
}
};
let redraw_ = redraw.clone();
@@ -240,6 +301,14 @@ impl PipeWire {
)
.context("error creating Stream")?;
if cursor_mode == CursorMode::Metadata && !pw_version_supports_cursor_metadata() {
debug!(
"metadata cursor mode requested, but PipeWire is too old (need >= 1.4.8); \
switching to embedded cursor"
);
cursor_mode = CursorMode::Embedded;
}
let pending_size = Size::from((size.w as u32, size.h as u32));
// Like in good old wayland-rs times...
@@ -259,7 +328,8 @@ impl PipeWire {
let inner = inner.clone();
let stop_cast = stop_cast.clone();
move |stream, (), old, new| {
debug!(stream_id, "pw stream: state changed: {old:?} -> {new:?}");
let _span = debug_span!("state_changed", %stream_id).entered();
debug!("{old:?} -> {new:?}");
let mut inner = inner.borrow_mut();
match new {
@@ -267,7 +337,7 @@ impl PipeWire {
if inner.node_id.is_none() {
let id = stream.node_id();
inner.node_id = Some(id);
debug!(stream_id, "pw stream: sending signal with {id}");
debug!("sending signal with {id}");
let _span = tracy_client::span!("sending PipeWireStreamAdded");
async_io::block_on(async {
@@ -278,10 +348,7 @@ impl PipeWire {
.await;
if let Err(err) = res {
warn!(
stream_id,
"error sending PipeWireStreamAdded: {err:?}"
);
warn!("error sending PipeWireStreamAdded: {err:?}");
stop_cast();
}
});
@@ -311,7 +378,7 @@ impl PipeWire {
let formats = formats.clone();
move |stream, (), id, pod| {
let id = ParamType::from_raw(id);
trace!(stream_id, ?id, "pw stream: param_changed");
trace!(%stream_id, ?id, "param_changed");
let mut inner = inner.borrow_mut();
let inner = &mut *inner;
@@ -319,12 +386,14 @@ impl PipeWire {
return;
}
let _span = debug_span!("param_changed", %stream_id).entered();
let Some(pod) = pod else { return };
let (m_type, m_subtype) = match parse_format(pod) {
Ok(x) => x,
Err(err) => {
warn!(stream_id, "pw stream: error parsing format: {err:?}");
warn!("error parsing format: {err:?}");
return;
}
};
@@ -335,19 +404,19 @@ impl PipeWire {
let mut format = VideoInfoRaw::new();
format.parse(pod).unwrap();
debug!(stream_id, "pw stream: got format = {format:?}");
debug!("got format = {format:?}");
let format_size = Size::from((format.size().width, format.size().height));
let state = &mut inner.state;
if format_size != state.expected_format_size() {
if !matches!(&*state, CastState::ResizePending { .. }) {
warn!(stream_id, "pw stream: wrong size, but we're not resizing");
warn!("wrong size, but we're not resizing");
stop_cast();
return;
}
debug!(stream_id, "pw stream: wrong size, waiting");
debug!("wrong size, waiting");
return;
}
@@ -368,25 +437,25 @@ impl PipeWire {
let Some(prop_modifier) =
object.find_prop(spa::utils::Id(FormatProperties::VideoModifier.0))
else {
warn!(stream_id, "pw stream: modifier prop missing");
warn!("modifier prop missing");
stop_cast();
return;
};
if prop_modifier.flags().contains(PodPropFlags::DONT_FIXATE) {
debug!(stream_id, "pw stream: fixating the modifier");
debug!("fixating the modifier");
let pod_modifier = prop_modifier.value();
let Ok((_, modifiers)) = PodDeserializer::deserialize_from::<Choice<i64>>(
pod_modifier.as_bytes(),
) else {
warn!(stream_id, "pw stream: wrong modifier property type");
warn!("wrong modifier property type");
stop_cast();
return;
};
let ChoiceEnum::Enum { alternatives, .. } = modifiers.1 else {
warn!(stream_id, "pw stream: wrong modifier choice type");
warn!("wrong modifier choice type");
stop_cast();
return;
};
@@ -399,18 +468,14 @@ impl PipeWire {
) {
Ok(x) => x,
Err(err) => {
warn!(
stream_id,
"pw stream: couldn't find preferred modifier: {err:?}"
);
warn!("couldn't find preferred modifier: {err:?}");
stop_cast();
return;
}
};
debug!(
stream_id,
"pw stream: allocation successful \
"allocation successful \
(modifier={modifier:?}, plane_count={plane_count}), \
moving to confirmation pending"
);
@@ -447,7 +512,7 @@ impl PipeWire {
let mut params = [pod1, make_pod(&mut b2, o2)];
if let Err(err) = stream.update_params(&mut params) {
warn!(stream_id, "error updating stream params: {err:?}");
warn!("error updating stream params: {err:?}");
stop_cast();
}
@@ -476,14 +541,19 @@ impl PipeWire {
let modifier = *modifier;
let plane_count = *plane_count;
let damage_tracker =
if let CastState::Ready { damage_tracker, .. } = &mut *state {
damage_tracker.take()
let (damage_tracker, cursor_damage_tracker) =
if let CastState::Ready {
damage_tracker,
cursor_damage_tracker,
..
} = &mut *state
{
(damage_tracker.take(), cursor_damage_tracker.take())
} else {
None
(None, None)
};
debug!(stream_id, "pw stream: moving to ready state");
debug!("moving to ready state");
*state = CastState::Ready {
size,
@@ -491,6 +561,8 @@ impl PipeWire {
modifier,
plane_count,
damage_tracker,
cursor_damage_tracker,
last_cursor_location: None,
};
plane_count
@@ -506,15 +578,14 @@ impl PipeWire {
) {
Ok(x) => x,
Err(err) => {
warn!(stream_id, "pw stream: test allocation failed: {err:?}");
warn!("test allocation failed: {err:?}");
stop_cast();
return;
}
};
debug!(
stream_id,
"pw stream: allocation successful \
"allocation successful \
(modifier={modifier:?}, plane_count={plane_count}), \
moving to ready"
);
@@ -525,6 +596,8 @@ impl PipeWire {
modifier,
plane_count: plane_count as i32,
damage_tracker: None,
cursor_damage_tracker: None,
last_cursor_location: None,
};
plane_count as i32
@@ -543,7 +616,7 @@ impl PipeWire {
pod::Value::Choice(ChoiceValue::Int(Choice(
ChoiceFlags::empty(),
ChoiceEnum::Range {
default: 16,
default: 8,
min: 2,
max: 16
}
@@ -562,8 +635,6 @@ impl PipeWire {
),
);
// FIXME: Hidden / embedded / metadata cursor
let o2 = pod::object!(
SpaTypes::ObjectParamMeta,
ParamType::Meta,
@@ -578,10 +649,27 @@ impl PipeWire {
);
let mut b1 = vec![];
let mut b2 = vec![];
let mut params = [make_pod(&mut b1, o1), make_pod(&mut b2, o2)];
let mut params = vec![make_pod(&mut b1, o1), make_pod(&mut b2, o2)];
let mut b_cursor = vec![];
if cursor_mode == CursorMode::Metadata {
let o_cursor = pod::object!(
SpaTypes::ObjectParamMeta,
ParamType::Meta,
Property::new(
SPA_PARAM_META_type,
pod::Value::Id(spa::utils::Id(SPA_META_Cursor))
),
Property::new(
SPA_PARAM_META_size,
pod::Value::Int(CURSOR_META_SIZE as i32)
),
);
params.push(make_pod(&mut b_cursor, o_cursor));
}
if let Err(err) = stream.update_params(&mut params) {
warn!(stream_id, "error updating stream params: {err:?}");
warn!("error updating stream params: {err:?}");
stop_cast();
}
}
@@ -590,6 +678,7 @@ impl PipeWire {
let inner = inner.clone();
let stop_cast = stop_cast.clone();
move |stream, (), buffer| {
let _span = debug_span!("add_buffer", %stream_id).entered();
let mut inner = inner.borrow_mut();
let (size, alpha, modifier) = if let CastState::Ready {
@@ -601,15 +690,11 @@ impl PipeWire {
{
(*size, *alpha, *modifier)
} else {
trace!(stream_id, "pw stream: add buffer, but not ready yet");
trace!("add_buffer, but not ready yet");
return;
};
trace!(
stream_id,
"pw stream: add_buffer, size={size:?}, alpha={alpha}, \
modifier={modifier:?}"
);
trace!("size={size:?}, alpha={alpha}, modifier={modifier:?}");
unsafe {
let spa_buffer = (*buffer).buffer;
@@ -623,7 +708,7 @@ impl PipeWire {
let dmabuf = match allocate_dmabuf(&gbm, size, fourcc, modifier) {
Ok(dmabuf) => dmabuf,
Err(err) => {
warn!(stream_id, "error allocating dmabuf: {err:?}");
warn!("error allocating dmabuf: {err:?}");
stop_cast();
return;
}
@@ -654,7 +739,6 @@ impl PipeWire {
(*chunk).offset = offset;
trace!(
stream_id,
"pw buffer plane: fd={}, stride={stride}, offset={offset}",
(*spa_data).fd
);
@@ -674,7 +758,7 @@ impl PipeWire {
.remove_buffer({
let inner = inner.clone();
move |_stream, (), buffer| {
trace!(stream_id, "pw stream: remove_buffer");
trace!(%stream_id, "remove_buffer");
let mut inner = inner.borrow_mut();
inner
@@ -695,7 +779,7 @@ impl PipeWire {
.unwrap();
trace!(
stream_id,
%stream_id,
"starting pw stream with size={pending_size:?}, refresh={refresh:?}"
);
@@ -717,7 +801,7 @@ impl PipeWire {
stream,
_listener: listener,
target,
dynamic_target,
dynamic_target: false,
formats,
offer_alpha: alpha,
cursor_mode,
@@ -735,6 +819,10 @@ impl Cast {
self.inner.borrow().is_active
}
pub fn node_id(&self) -> Option<u32> {
self.inner.borrow().node_id
}
pub fn ensure_size(&self, size: Size<i32, Physical>) -> anyhow::Result<CastSizeChange> {
let mut inner = self.inner.borrow_mut();
@@ -947,7 +1035,7 @@ impl Cast {
let source = Generic::new(sync_fd, Interest::READ, Mode::OneShot);
self.event_loop
.insert_source(source, move |_, _, state| {
for cast in &mut state.niri.casts {
for cast in &mut state.niri.casting.casts {
if cast.stream_id == stream_id {
cast.queue_completed_buffers();
}
@@ -960,21 +1048,36 @@ impl Cast {
}
}
#[allow(clippy::too_many_arguments)]
pub fn dequeue_buffer_and_render(
&mut self,
renderer: &mut GlesRenderer,
elements: &[impl RenderElement<GlesRenderer>],
elements: &[CastRenderElement<GlesRenderer>],
cursor_data: &CursorData<CastRenderElement<GlesRenderer>>,
size: Size<i32, Physical>,
scale: Scale<f64>,
) -> bool {
let mut inner = self.inner.borrow_mut();
let CastState::Ready { damage_tracker, .. } = &mut inner.state else {
let CastState::Ready {
damage_tracker,
cursor_damage_tracker,
last_cursor_location,
..
} = &mut inner.state
else {
error!("cast must be in Ready state to render");
return false;
};
let damage_tracker = damage_tracker
.get_or_insert_with(|| OutputDamageTracker::new(size, scale, Transform::Normal));
let cursor_damage_tracker = cursor_damage_tracker.get_or_insert_with(|| {
OutputDamageTracker::new(
Size::from((CURSOR_WIDTH as _, CURSOR_HEIGHT as _)),
scale,
Transform::Normal,
)
});
// Size change will drop the damage tracker, but scale change won't, so check it here.
let OutputModeSource::Static { scale: t_scale, .. } = damage_tracker.mode() else {
@@ -982,13 +1085,31 @@ impl Cast {
};
if *t_scale != scale {
*damage_tracker = OutputDamageTracker::new(size, scale, Transform::Normal);
*cursor_damage_tracker = OutputDamageTracker::new(
Size::from((CURSOR_WIDTH as _, CURSOR_HEIGHT as _)),
scale,
Transform::Normal,
);
}
let (damage, _states) = damage_tracker.damage_output(1, elements).unwrap();
if damage.is_none() {
let mut has_cursor_update = false;
let mut redraw_cursor = false;
if self.cursor_mode != CursorMode::Hidden {
let (damage, _states) = cursor_damage_tracker
.damage_output(1, &cursor_data.relocated)
.unwrap();
redraw_cursor = damage.is_some();
has_cursor_update =
redraw_cursor || *last_cursor_location != Some(cursor_data.location);
}
if damage.is_none() && !has_cursor_update {
trace!("no damage, skipping frame");
return false;
}
*last_cursor_location = Some(cursor_data.location);
drop(inner);
let Some(pw_buffer) = self.dequeue_available_buffer() else {
@@ -1000,6 +1121,19 @@ impl Cast {
unsafe {
let spa_buffer = (*buffer).buffer;
let mut pointer_elements = None;
if self.cursor_mode == CursorMode::Metadata {
add_cursor_metadata(renderer, spa_buffer, cursor_data, redraw_cursor);
} else if self.cursor_mode != CursorMode::Hidden {
// Embed the cursor into the main render.
pointer_elements = Some(cursor_data.original.iter());
}
let pointer_elements = pointer_elements.into_iter().flatten();
let elements = pointer_elements.chain(elements);
// FIXME: would be good to skip rendering the full frame if only the pointer changed.
// Unfortunately, I think the OBS PipeWire code needs to be updated first to cleanly
// allow for that codepath.
let fd = (*(*spa_buffer).datas).fd;
let dmabuf = self.inner.borrow().dmabufs[&fd].clone();
@@ -1009,7 +1143,7 @@ impl Cast {
size,
scale,
Transform::Normal,
elements.iter().rev(),
elements.rev(),
) {
Ok(sync_point) => {
mark_buffer_as_good(pw_buffer, &mut self.sequence_counter);
@@ -1030,8 +1164,14 @@ impl Cast {
let mut inner = self.inner.borrow_mut();
// Clear out the damage tracker if we're in Ready state.
if let CastState::Ready { damage_tracker, .. } = &mut inner.state {
if let CastState::Ready {
damage_tracker,
cursor_damage_tracker,
..
} = &mut inner.state
{
*damage_tracker = None;
*cursor_damage_tracker = None;
};
drop(inner);
@@ -1044,6 +1184,10 @@ impl Cast {
unsafe {
let spa_buffer = (*buffer).buffer;
if self.cursor_mode == CursorMode::Metadata {
add_invisible_cursor(spa_buffer);
}
let fd = (*(*spa_buffer).datas).fd;
let dmabuf = self.inner.borrow().dmabufs[&fd].clone();
@@ -1082,6 +1226,12 @@ impl CastState {
}
}
fn pw_version_supports_cursor_metadata() -> bool {
// This PipeWire version fixed a critical memory issue with cursor metadata:
// https://gitlab.freedesktop.org/pipewire/pipewire/-/merge_requests/2538
unsafe { pw_check_library_version(1, 4, 8) }
}
fn make_video_params(
formats: &FormatSet,
size: Size<u32, Physical>,
@@ -1278,3 +1428,146 @@ unsafe fn find_meta_header(buffer: *mut spa_buffer) -> Option<NonNull<spa_meta_h
let p = spa_buffer_find_meta_data(buffer, SPA_META_Header, size_of::<spa_meta_header>()).cast();
NonNull::new(p)
}
unsafe fn add_invisible_cursor(spa_buffer: *mut spa_buffer) {
unsafe {
let cursor_meta_ptr: *mut spa_meta_cursor = spa_buffer_find_meta_data(
spa_buffer,
SPA_META_Cursor,
mem::size_of::<spa_meta_cursor>(),
)
.cast();
let Some(cursor_meta) = cursor_meta_ptr.as_mut() else {
return;
};
// The cursor is present but invisible.
cursor_meta.id = 1;
cursor_meta.position.x = 0;
cursor_meta.position.y = 0;
cursor_meta.hotspot.x = 0;
cursor_meta.hotspot.y = 0;
cursor_meta.bitmap_offset = BITMAP_META_OFFSET as _;
let bitmap_meta_ptr = cursor_meta_ptr
.byte_add(BITMAP_META_OFFSET)
.cast::<spa_meta_bitmap>();
let bitmap_meta = &mut *bitmap_meta_ptr;
// HACK: PipeWire docs say offset = 0 means invisible.
//
// Unfortunately, OBS doesn't actually check that, instead it checks that size isn't zero:
// https://github.com/obsproject/obs-studio/blob/f4aaa5f0417c5ec40a3799551e125129fce1e007/plugins/linux-pipewire/pipewire.c#L900
//
// Unfortunately, libwebrtc, on top of ignoring offset, also treats size = 0 as "preserve
// previous cursor":
// https://webrtc.googlesource.com/src/+/97b46e12582606a238d4f0c8524365cf5bdcb411/modules/desktop_capture/linux/wayland/shared_screencast_stream.cc#765
//
// So, send a 1x1 transparent pixel instead...
bitmap_meta.offset = BITMAP_DATA_OFFSET as _;
bitmap_meta.size.width = 1;
bitmap_meta.size.height = 1;
bitmap_meta.stride = CURSOR_BPP as i32;
bitmap_meta.format = CURSOR_FORMAT;
let bitmap_data = bitmap_meta_ptr.cast::<u8>().add(BITMAP_DATA_OFFSET);
let bitmap_slice = slice::from_raw_parts_mut(bitmap_data, CURSOR_BITMAP_SIZE);
bitmap_slice[..4].copy_from_slice(&[0, 0, 0, 0]);
}
}
unsafe fn add_cursor_metadata(
renderer: &mut GlesRenderer,
spa_buffer: *mut spa_buffer,
cursor_data: &CursorData<impl RenderElement<GlesRenderer>>,
redraw: bool,
) {
unsafe {
let cursor_meta_ptr: *mut spa_meta_cursor = spa_buffer_find_meta_data(
spa_buffer,
SPA_META_Cursor,
mem::size_of::<spa_meta_cursor>(),
)
.cast();
let Some(cursor_meta) = cursor_meta_ptr.as_mut() else {
return;
};
cursor_meta.id = 1;
cursor_meta.position.x = cursor_data.location.x;
cursor_meta.position.y = cursor_data.location.y;
cursor_meta.hotspot.x = cursor_data.hotspot.x;
cursor_meta.hotspot.y = cursor_data.hotspot.y;
if !redraw {
trace!("cursor not damaged, skipping rerendering");
cursor_meta.bitmap_offset = 0;
return;
}
cursor_meta.bitmap_offset = BITMAP_META_OFFSET as _;
let bitmap_meta_ptr = cursor_meta_ptr
.byte_add(BITMAP_META_OFFSET)
.cast::<spa_meta_bitmap>();
let bitmap_meta = &mut *bitmap_meta_ptr;
// Start with a 1x1 transparent pixel; see comment in add_invisible_cursor().
bitmap_meta.offset = BITMAP_DATA_OFFSET as _;
bitmap_meta.size.width = 1;
bitmap_meta.size.height = 1;
bitmap_meta.stride = CURSOR_BPP as i32;
bitmap_meta.format = CURSOR_FORMAT;
let bitmap_data = bitmap_meta_ptr.cast::<u8>().add(BITMAP_DATA_OFFSET);
let bitmap_slice = slice::from_raw_parts_mut(bitmap_data, CURSOR_BITMAP_SIZE);
bitmap_slice[..4].copy_from_slice(&[0, 0, 0, 0]);
let size = Size::new(
min(cursor_data.size.w, CURSOR_WIDTH as i32),
min(cursor_data.size.h, CURSOR_HEIGHT as i32),
);
if size.w == 0 || size.h == 0 {
trace!("cursor is invisible, skipping rendering");
return;
}
let _span = tracy_client::span!("add_cursor_metadata render cursor");
// FIXME: use a reliable buffer whenever we're rendering the cursor.
//
// PipeWire buffers are not normally guaranteed to reach the destination, so our buffer
// with the rendered cursor bitmap may not reach the consumer.
//
// Reliable buffers should be available starting from 1.6.0:
// https://gitlab.freedesktop.org/pipewire/pipewire/-/issues/4885
let mapping = match render_and_download(
renderer,
size,
cursor_data.scale,
Transform::Normal,
Fourcc::Argb8888,
cursor_data.relocated.iter().rev(),
) {
Ok(mapping) => mapping,
Err(err) => {
warn!("error rendering cursor: {err:?}");
return;
}
};
let pixels = match renderer.map_texture(&mapping) {
Ok(pixels) => pixels,
Err(err) => {
warn!("error mapping cursor texture: {err:?}");
return;
}
};
bitmap_slice[..pixels.len()].copy_from_slice(pixels);
// Fill the metadata now that everything succeeded.
bitmap_meta.size.width = size.w as _;
bitmap_meta.size.height = size.h as _;
bitmap_meta.stride = size.w * CURSOR_BPP as i32;
}
}
@@ -1,6 +1,6 @@
---
source: src/tests/window_opening.rs
description: "config:\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n}"
description: "config:\noutput \"headless-2\" {\n layout {\n border {\n on\n }\n }\n}\n\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n\n layout {\n border {\n width 10\n }\n\n default-column-width {\n fixed 500\n }\n }\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n}"
expression: snapshot
---
final monitor: headless-1
@@ -1,6 +1,6 @@
---
source: src/tests/window_opening.rs
description: "set parent: A1\nconfig:\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-1\"\n}"
description: "set parent: A1\nconfig:\noutput \"headless-2\" {\n layout {\n border {\n on\n }\n }\n}\n\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n\n layout {\n border {\n width 10\n }\n\n default-column-width {\n fixed 500\n }\n }\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-1\"\n}"
expression: snapshot
---
final monitor: headless-1
@@ -1,6 +1,6 @@
---
source: src/tests/window_opening.rs
description: "set parent: A2\nconfig:\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-2\"\n}"
description: "set parent: A2\nconfig:\noutput \"headless-2\" {\n layout {\n border {\n on\n }\n }\n}\n\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n\n layout {\n border {\n width 10\n }\n\n default-column-width {\n fixed 500\n }\n }\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-2\"\n}"
expression: snapshot
---
final monitor: headless-1
@@ -1,6 +1,6 @@
---
source: src/tests/window_opening.rs
description: "set parent: B1\nconfig:\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-1\"\n}"
description: "set parent: B1\nconfig:\noutput \"headless-2\" {\n layout {\n border {\n on\n }\n }\n}\n\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n\n layout {\n border {\n width 10\n }\n\n default-column-width {\n fixed 500\n }\n }\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-1\"\n}"
expression: snapshot
---
final monitor: headless-1
@@ -1,6 +1,6 @@
---
source: src/tests/window_opening.rs
description: "set parent: B2\nconfig:\noutput \"headless-2\" {\n layout {\n border {\n on\n }\n }\n}\n\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n\n layout {\n border {\n width 10\n }\n }\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-2\"\n}"
description: "set parent: B2\nconfig:\noutput \"headless-2\" {\n layout {\n border {\n on\n }\n }\n}\n\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n\n layout {\n border {\n width 10\n }\n\n default-column-width {\n fixed 500\n }\n }\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-2\"\n}"
expression: snapshot
---
final monitor: headless-2
@@ -1,6 +1,6 @@
---
source: src/tests/window_opening.rs
description: "want fullscreen: A1\nset parent: A1\nconfig:\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-1\"\n}"
description: "want fullscreen: A1\nset parent: A1\nconfig:\noutput \"headless-2\" {\n layout {\n border {\n on\n }\n }\n}\n\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n\n layout {\n border {\n width 10\n }\n\n default-column-width {\n fixed 500\n }\n }\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-1\"\n}"
expression: snapshot
---
final monitor: headless-1
@@ -1,6 +1,6 @@
---
source: src/tests/window_opening.rs
description: "want fullscreen: A1\nset parent: A2\nconfig:\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-2\"\n}"
description: "want fullscreen: A1\nset parent: A2\nconfig:\noutput \"headless-2\" {\n layout {\n border {\n on\n }\n }\n}\n\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n\n layout {\n border {\n width 10\n }\n\n default-column-width {\n fixed 500\n }\n }\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-2\"\n}"
expression: snapshot
---
final monitor: headless-1
@@ -1,6 +1,6 @@
---
source: src/tests/window_opening.rs
description: "want fullscreen: A1\nset parent: B1\nconfig:\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-1\"\n}"
description: "want fullscreen: A1\nset parent: B1\nconfig:\noutput \"headless-2\" {\n layout {\n border {\n on\n }\n }\n}\n\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n\n layout {\n border {\n width 10\n }\n\n default-column-width {\n fixed 500\n }\n }\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-1\"\n}"
expression: snapshot
---
final monitor: headless-1
@@ -1,6 +1,6 @@
---
source: src/tests/window_opening.rs
description: "want fullscreen: A1\nset parent: B2\nconfig:\noutput \"headless-2\" {\n layout {\n border {\n on\n }\n }\n}\n\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n\n layout {\n border {\n width 10\n }\n }\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-2\"\n}"
description: "want fullscreen: A1\nset parent: B2\nconfig:\noutput \"headless-2\" {\n layout {\n border {\n on\n }\n }\n}\n\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n\n layout {\n border {\n width 10\n }\n\n default-column-width {\n fixed 500\n }\n }\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-2\"\n}"
expression: snapshot
---
final monitor: headless-2
@@ -1,6 +1,6 @@
---
source: src/tests/window_opening.rs
description: "want fullscreen: A1\nconfig:\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}"
description: "want fullscreen: A1\nconfig:\noutput \"headless-2\" {\n layout {\n border {\n on\n }\n }\n}\n\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n\n layout {\n border {\n width 10\n }\n\n default-column-width {\n fixed 500\n }\n }\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}"
expression: snapshot
---
final monitor: headless-1
@@ -1,6 +1,6 @@
---
source: src/tests/window_opening.rs
description: "want fullscreen: A2\nset parent: A1\nconfig:\noutput \"headless-2\" {\n layout {\n border {\n on\n }\n }\n}\n\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n\n layout {\n border {\n width 10\n }\n }\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-1\"\n}"
description: "want fullscreen: A2\nset parent: A1\nconfig:\noutput \"headless-2\" {\n layout {\n border {\n on\n }\n }\n}\n\nworkspace \"ws-1\" {\n open-on-output \"headless-1\"\n}\n\nworkspace \"ws-2\" {\n open-on-output \"headless-2\"\n\n layout {\n border {\n width 10\n }\n\n default-column-width {\n fixed 500\n }\n }\n}\n\nwindow-rule {\n exclude title=\"parent\"\n\n open-fullscreen false\n}\n\nwindow-rule {\n match title=\"parent\"\n open-on-output \"headless-1\"\n}"
expression: snapshot
---
final monitor: headless-1

Some files were not shown because too many files have changed in this diff Show More