Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

Add a tabbed-windows guide covering grouping, switching tabs and taking windows out. Document the scrolling indicator's defaults and limits, and link the guide from the README, shortcuts and IPC pages. Update from main, including the latest tabbed-layout changes.

Separate Activities setup, configuration and publishing documentation. Review the guide prose with the unslop skill, keeping existing examples and heading links. The wiki workflow uses GitHub's built-in token to publish documentation changes on main.

Validation:

- 20 documentation tests pass, including README links and Activities/tab-indicator field coverage.
- The publisher builds 46 wiki pages; the site build and Python syntax checks pass.
- All 115 code examples from main are preserved.
- The Python suite passes: 90 tests passed and 31 opt-in integration tests skipped, with the system GI module available.
2026-10-13 00:32:23 +01:00
Samuel Onoja eaeabebe73 fix(layout): refine tab indicator sizing and theme styling 2026-10-13 00:21:10 +01:00
Samuel Onoja 042698ea61 feat(layout): overlay scrolling tab indicators and hover titles 2026-10-12 23:52:25 +01:00
Samuel Onoja a3974c2dda fix(layout): make tab bars more compact 2026-10-12 23:32:13 +01:00
Samuel Onoja 038b8884fc fix(layout): match tab bars to shell theme and corners 2026-10-12 23:30:47 +01:00
Samuel Onoja bfa78f62f2 feat(layout): add tabbed window groups and scrolling columns 2026-10-12 23:26:00 +01:00
Samuel Onoja 7d58eeb65d Add named keyboard binding modes (#32)
Add named keyboard shortcut maps with an Escape exit and a panel indicator. Keep press/release ownership across transitions, validate remapped Escape keys, and cancel stale repeats and focus previews.

Give the indicator ownership of the panel while a mode is active. Close popovers, collapse Morph, and keep Live Activities data current without resizing the hidden panel. Document configuration and transition behavior.

Validation: 1,519 workspace Rust tests passed; 70 Python tests passed. Workspace build, formatting, Clippy, and Settings configuration validation passed. Opt-in native desktop checks were not rerun because Weston is absent.

Related: #5.
2026-10-12 23:22:02 +01:00
Samuel OnojaandSamuel Onoja 6b0d903ad0 Add Live Activities as configurable bar items (#30)
* Add Live Activities as configurable bar items

* Fix activity owner monitoring and Morph list interactions

* Keep activity rows stable during updates and tighten their layout

* Use subtle theme tint for activity cards

* Tighten activity title and progress spacing

* Use theme accent for active progress rings

* Fit compact activities to measured titles and badge

* Expose Live Activities display controls in Settings

* Open activity terminals and streamline the Morph header

* Give Morph continuous icons and prioritize workspace navigation

* Show activity progress inside its ring

* Inset activity progress rings and strengthen their stroke

* Fix activity lifecycle, command output handling and list navigation

* Reduce activity update work and harden publisher lifetimes

* Set up Live Activities for the detected user shell during installation

* Document Activities setup, daily use and troubleshooting

* Preserve Morph idle width across empty activity updates

* Fix dimming flicker, hide inactive music tab and consolidate Activities docs

* Fix panel clearance across styles and match previews to theme settings

* Prevent panel blur from repainting neighboring islands

---------

Co-authored-by: Samuel Onoja <[email protected]>
2026-10-12 22:52:25 +01:00
Samuel Onoja 7a7df0106c Start with defaults when configuration is invalid (#29)
Invalid configuration currently prevents login. Ferese now starts with built-in defaults and shows a notification when the shell is ready, leaving the file untouched. Loading and validation belong to the config module, while the theme module resolves appearance. Validation remains strict, and failed live reloads keep the active settings.

The native Morph regression checks the animated input region and idle frame scheduling while its backing surface stays allocated.

Validation: workspace and Python tests, formatting, Clippy, release build, and isolated Wayland startup/reload and panel-motion tests. Native CI includes the startup regression.

Related: #28.
2026-10-12 13:18:50 +01:00
Samuel Onoja b31618b749 Fix attached Morph seams at fractional scales 2026-10-12 12:55:16 +01:00
Samuel Onoja e22f0ebef9 Add panel-wide styles and the Morph hub (#26)
Panel appearance now applies to the whole panel, with seven styles: Floating, Capsules, Notch, Edge to edge, Fused islands, Adaptive and Morph.

- Share composition, geometry, and motion through the renderer-independent `ferese-panel` crate, including native materials, input regions, and Settings previews.
- Add the Morph hub with media, calendar, notifications, controls, workspace previews, hover feedback, and attached mode. Keep recording and low-battery status visible and respect window clearance.
- Cache workspace preview geometry, draw thumbnails directly, centralize Morph transitions, and keep rendering settings local to each shell instance.
- Put Record Screen in the Morph header and show the display control only when an external monitor is connected. Update panel documentation, the desktop screenshot, and native check style names.

Local validation included 660 compositor tests, 191 shell tests, isolated preview-cache checks, GPU Morph captures at 1×/1.25×/1.5×, formatting, and Clippy with warnings denied. The final header change passed all 23 Morph tests. Release workflow jobs are skipped for this PR.
2026-10-12 01:31:34 +01:00
Samuel Onoja 42c3b290dd Add audio and region recording; make routine CI manual (#25)
The top-bar recorder previously saved a full monitor without sound. It now lets you select a region within one monitor and choose system audio, microphone audio, or both. Audio remains off by default, and recordings with audio are saved as VP8/Opus WebM files. The portal still controls screen access and cancellation; stopping from the bar or ending the video stream finishes the recording, including its audio tracks.

Region selection accounts for fractional display scales and negative monitor coordinates. The picker keeps its action buttons visible while the display list and recording options scroll together. The picker, dependency lists, and recording documentation now explain the available choices and the slurp and audio requirements.

Routine pushes and pull requests no longer start CI runners. Workspace checks and website deployment are manual, while release checks run when requested manually, through a version tag, or through a PR labeled `release`. Publishing still requires a pushed version tag. This also fixes release asset checksums by normalizing RPM filenames before generating the checksum file.

Validated locally with a release build, portal and shell recorder tests, formatting, Clippy, actionlint, packaging tests, and mocked publishing checks. Media tests encoded and decoded cropped video with audio, checked that video termination finishes live audio, and exercised system, microphone, and combined capture against a private PipeWire-Pulse server. Mixed recordings must contain both fixture frequencies, with negative controls for either missing source. The picker was also checked on 800 × 600 and 800 × 400 desktops and opened in a live nested window.
2026-10-11 15:37:51 +01:00
Samuel Onoja 1b9fabbbe4 Add Fedora and Arch package recipes (#24)
* Package Ferese for Fedora and Arch releases

* Build release packages alongside workspace checks

* Include dependency licenses and software GPU test drivers

* Limit package checks to release PRs and fix Arch Cargo dependency

* Prepare Ferese 0.1.0-beta

* Release config locks before closing inherited file descriptions

* Install the lock PAM policy for native release checks

* Include xauth in distribution package test dependencies

* Regenerate Arch Git package metadata

* Run package checks with an explicit UTC timezone

* Remove unused bundled fonts and accept installed license receipts

* Format the system font test helper

* Load an installed font for headless text rendering checks

* Isolate package test runtimes and reap container child processes
2026-10-11 14:16:00 +01:00
Samuel Onoja 587602b493 Space config parsing and editing steps for readability 2026-10-11 12:16:52 +01:00
Samuel Onoja 91e2f227d2 Fix config upgrade failures, save races, lock confirmation and release validation (#23)
* Coordinate config writes and gate releases on desktop validation

* Use toolkit rc.2 with painting and session lock fixes

* Ignore retired desktop widget settings in existing configs
2026-10-11 12:15:28 +01:00
Samuel Onoja a447136b01 Reduce software painting work on large surfaces (#22)
Pin the Iced/libcosmic candidate that paints contour interiors directly and reduces rectangular clipping work. Keep WGPU as the default and update all consumers consistently.
2026-10-11 12:05:53 +01:00
Samuel Onoja 216ef38972 Add pre-merge native stability checks (#21)
* Add pre-merge native stability checks

* Install native binding generator dependencies explicitly

* Pin portal contract fixtures for reproducible CI
2026-10-11 11:24:02 +01:00
Samuel Onoja b356f63b93 Remove desktop clock and sticky-note widgets 2026-10-10 23:33:44 +01:00
Samuel Onoja 78c7168a92 Tighten README features and highlight shell customisation 2026-10-10 23:10:56 +01:00
Samuel Onoja 649cf49427 Limit homepage screenshots to the hero and Overview 2026-10-10 23:02:23 +01:00
Samuel Onoja 0a48c07e86 Refine the website and handbook with real desktop screenshots 2026-10-10 22:34:45 +01:00
Samuel Onoja 9c3893b36d Show and animate the cursor on the lock screen 2026-10-10 21:01:09 +01:00
Samuel Onoja cf540f1feb Reduce panel allocations and fix transient surface leaks (#19)
Repeated panel updates rebuilt unchanged controls and SVG artwork, cloned composition data, and kept unused workspace icon resources. This reuses controls when their layout stays the same, bounds the SVG cache, removes temporary layout allocations, and releases icon resources when apps close or the workspace style changes.

Rapidly closing a modal could also leave a native surface behind when its asynchronous creation finished after the close. Late openings now get destroyed when their owner is gone. Ferese uses the published `ferese-v0.1.2` Iced/libcosmic fixes so a temporary software buffer/viewport size mismatch retries the frame instead of crashing.

Two runs per version of the same workspace/overview/reload workload measured **26% fewer allocated bytes and 23% fewer allocation calls**. A separate 1,800-cycle notification/modal stress run retained about **40 MiB less shell memory**, including shared-buffer retention falling from 28.2 MiB to 272 KiB. These are measured workload results, not an overall desktop speedup claim.

Validation:
- 1,350 workspace tests passed, including GPU Settings text rendering (50 optional tests ignored); formatting and Clippy passed.
- Native continuous/island panel tests passed rapid notification resizing, modal cancellation, and live material/input-region changes.
- A Wayland lifetime trace confirmed all 280 created modal surfaces were destroyed; longer DHAT traces and Intel GPU memory measurements were checked separately.
- All installation binaries rebuilt and the installation bundle passed inventory and checksum verification.
2026-10-10 20:36:23 +01:00
Samuel Onoja 65fcaef13f Add editable panels and refine shell settings (#18)
* Integrate panel composition, adaptive sizing, and overflow

* Add instance editing controls to panel settings

* Add panel group editing and measured composition preview

* Reorganize panel settings and add drag-and-drop item editing

* Simplify panel settings and place inspectors beside the editor

* Refine Panel & Shell settings, previews, and panel rendering

* Organize settings navigation and modernize packaged panel config

* Support top and bottom panels and refine shell surfaces

Add edge-aware settings, previews, popup placement, notifications, margins, and corner rendering. Preserve translucency in notification and dialog controls, and tighten spacing around the clock and display-mode icon.

* Add workspace styles and fix Settings text rendering

* Make panel allocation independent of measurement order

* Normalize panel item settings and visibility

* Move shell geometry into each panel

* Add independent group surfaces and finalize panel configuration

* Keep panel fit checks and restoration consistent with allocation

* Reject unknown panel item options and namespace metadata

* Reject invalid shell startup configuration and remove old radius fields

* Discover workspace application icons in a background task

* Remove the panel architecture document and improve reference prose

* Let panel media fill available space and improve Settings scrolling

* Preserve panel labels across clipped GPU rendering

* Use matching alpine lake wallpapers for light and dark modes

* Fix panel accessibility and popup lifecycle

Apply Reduce Transparency to panel opacity in the shell, compositor, and
Settings preview. Reject unknown theme tokens before snapshot substitution
and validate the current geometry fields in both consumers.

Dismiss popovers and workspace tooltips after panel layout changes. Handle
bar pointer exits directly and recover workspace hover after reentry without
repeated motion updates. Cover the transitions with regression tests and
update the wallpaper dimensions check for the bundled lake defaults.

* Use tagged Ferese toolkit fixes

* Fix center-only panel spacing and headless text rendering

* Bundle native 4K abstract wallpapers for both appearances

* Dismiss workspace tooltips before captured pointer dispatch

* Verify installed JPEG wallpapers when replacing a release
2026-10-10 18:50:37 +01:00
Samuel Onoja d4c8c10b39 Replace Ferese logos with shared Fe mark 2026-10-07 12:50:05 +01:00
Samuel Onoja dbd6c6d446 Check config revisions independently of theme animation in reload test 2026-10-07 10:36:02 +01:00
Samuel Onoja 1a02267266 Preserve tree stack windows during layout conversion 2026-10-07 10:22:41 +01:00
Samuel Onoja 977f281fb2 Preserve shell window identity and state flags
Retain window IDs, urgency and floating state in shell snapshots. Decode known flags from raw bitfields so future bits do not erase existing state.

Add exact-ID activation, close and overview selection helpers, with snapshot and wire-level regression coverage.
2026-10-07 10:06:07 +01:00
Samuel Onoja 5804562c84 Fix scrolling column sizing and row presentation invariants
Resize full and fixed columns from their resolved width. Preserve active rows during removal and source widths during extraction, satisfy feasible row minima without changing weights, and keep vertical resize usable for large stacks.

Propagate viewport-coupled width cycles across every row from a common presentation motion. Cover client holds, forecasts, reversal and reduced motion with regression tests.
2026-10-07 10:00:14 +01:00
Samuel Onoja 966c8988eb Test all workspace packages in push and PR CI 2026-10-07 09:32:40 +01:00
Samuel Onoja dcbcec6ba4 Preserve display assignments and fix CI checks
Keep surviving outputs on their applied CRTCs when enabling another display. Reserve nested IPC directories with explicit private permissions and cover session umasks.

Separate compositor checks from release-only workspace checks, provide test runtime prerequisites, and correct the reload test's initial revision assumption.
2026-10-07 09:30:25 +01:00
Samuel Onoja a6f24e8188 Harden desktop recovery and pre-release stability checks
Bound IPC transport deadlines while preserving portal leases, supervise desktop helper and agent recovery, and reserve final floating window geometry during placement.

Stabilize CLI output and isolated tests, expand workspace CI, resolve formatting and lint failures, and bundle the MIT license with installer compatibility.
2026-10-07 09:08:23 +01:00
Samuel Onoja a00f1c6f3a Unify scrolling viewport presentation ownership 2026-10-07 08:44:40 +01:00
Samuel Onoja 4a9880eaad Support keyboard movement and resizing for floating windows 2026-10-07 08:19:33 +01:00
Samuel Onoja 76112b59ac Add MRU focus switching between tiled and floating layers 2026-10-07 08:13:42 +01:00
Samuel Onoja ce024d4794 Isolate nested IPC sockets and define session ownership policy 2026-10-07 08:03:30 +01:00
Samuel Onoja 97026a0db7 Modernize feresectl and add bounded IPC event streaming 2026-10-07 07:39:27 +01:00
Samuel Onoja cfdd2a148a Fix lock-screen input and wallpaper handling 2026-10-07 07:19:06 +01:00
Samuel Onoja bbc5b27c25 Add unified window focus opacity and dimming effects 2026-10-07 07:00:30 +01:00
Samuel Onoja 02ec7e5199 optimize wallpaper loading and free pixels 2026-10-06 20:32:07 +01:00
Samuel Onoja a6f83689f8 Add XWayland support (#16)
* save dev state

* fix xwayland-satellite supervisor lifecycle, readiness and cleanup

* remove wayland scanner protocol generated files

* default xwayland enabled with path

* remove jargons

* address x11 review: park unconfirmed cleanup, never signal a reaped group, fix storm test and notify dir mode

* fix resize tests: never build Size values from negative dimensions

* add min-width/min-height window rules that replace client minimum sizes

* rename effective_size_constraints to effective_minimum_size and minor cleanups
2026-10-06 19:40:22 +01:00
Samuel Onoja ed23949a95 style MPRIS bar border and background
Assign the result back to bar_style.border instead of mutating in place, and tint the media bar background with the palette border color.
2026-10-05 21:54:54 +01:00
Samuel Onoja 8a0dd7615e mpris and bar control impl
gitignore

fix import error
2026-10-05 16:12:24 +01:00
Samuel Onoja 73f21e20ce general improvement and bug fixes, cursor scaling, DMA-BUF routing and nested backend recovery
ignore performance-baseline.md

more improvement and code quality fixes
2026-10-04 18:33:05 +01:00
Samuel Onoja 405c74af9f fix compositor edge cases, process handing and other cool improvements 2026-10-04 17:11:40 +01:00
Samuel Onoja 5706a71941 Simplify compositor and shell conditionals
Flatten nested guards in swipe cleanup, recording updates and popup dismissal. Use an eager optional fallback for workspace layouts and an array in the malformed-opacity test.
2026-10-04 14:43:43 +01:00
Samuel Onoja 3ba1f3522d Release wallpaper pixel memory after image changes
Store decoded pixels and RGB conversion scratch in anonymous mappings so dropping an image releases its pages. Reclaim decoder scratch on the worker thread after each load.

Verify PNG, JPEG and WebP pixels against the existing decoder and cover decode limits and corrupt files. Add an isolated release memory regression for repeated 4K wallpaper loads.
2026-10-04 14:41:53 +01:00
Samuel Onoja 004a5cdefe Add island bar layout and Settings controls
Group workspaces with Overview and status controls with the clock. Keep themed backgrounds and borders around each island, with click-through gaps and configurable side padding defaulting to 4 px.

Capture blur within each panel region and update material and input regions when island bounds change. Cover layout reloads and padding changes with widget and native shell tests.
2026-10-04 14:18:03 +01:00
Samuel Onoja 90e5c3543d Avoid idle clock and screenshot polling
Update visible shell clocks at their displayed time boundaries and receive recorder state through events, with elapsed updates only while recording. Arm screenshot expiry at the earliest pending deadline and cancel abandoned requests when their IPC client disconnects.

Cache desktop clock formats across shell updates. Transfer owned screenshot readback pixels to the encoder and borrow the canvas for PNG encoding; both paths previously copied a full image. Close the recorder's stdout reader before waiting for its child when a status line is rejected.

Validation: 910 Rust tests passed in the release workspace suite, with 44 ignored. The six screenshot-script tests passed. A private nested release test passed with a visible desktop clock, notification and modal animations, and three real PNG captures. The recorder shutdown and readback ownership regressions failed before their fixes and pass afterwards.

The isolated clock settings check dropped from 30,000 allocations to zero over 10,000 rebuilds. Borrowing the PNG canvas removed 33,177,600 allocated bytes for a 3840x2160 capture while producing identical PNG bytes.

Direct DRM and laptop power checks remain pending.
2026-10-04 13:01:52 +01:00
Samuel Onoja 49e54f9a63 Fix Settings rendering, theme accents and wallpaper loading 2026-10-04 12:30:54 +01:00
Samuel Onoja 950ba723ab docs: restore screenshots to Why Ferese 2026-10-04 12:13:18 +01:00
Samuel Onoja 643fd5fef1 docs: explain why Ferese integrates the desktop 2026-10-04 12:10:26 +01:00
Samuel Onoja 4cdce60d62 Update desktop screenshots 2026-10-04 12:04:44 +01:00
Samuel Onoja 4dd425f1f1 Fix wallpaper upgrade compatibility and Settings icon clipping
Allow installed releases to omit the two newer appearance wallpapers while keeping new bundle verification strict. Fit the Settings header symbol within its square slot without clipping.

Verified 30 installer tests, the real upgrade dry-run, and release rendering previews on GPU and software backends.
2026-10-04 11:18:08 +01:00
Samuel Onoja 3533fac521 Update branding
Update the Ferese symbols, appearance-specific wallpapers, and rounded wallpaper and theme previews. Bundle Figtree and add the Ferese name to the website header.

Verified configuration, theme, Settings, wallpaper and installer tests, both UI renderers, the static site build, and browser layouts.
2026-10-04 11:11:14 +01:00
Samuel Onoja f81d8d50ea Group imported themes in a custom gallery 2026-10-04 09:39:53 +01:00
Samuel Onoja 5c321addec Inset panel highlights and align bar content 2026-10-04 09:18:18 +01:00
Samuel Onoja 831ac3a77f Derive theme gradients and add solid paint controls 2026-10-04 09:03:28 +01:00
Samuel Onoja 20395007a5 Use the orbit symbol in Settings and the shell 2026-10-04 08:25:28 +01:00
Samuel Onoja 3c40854077 add animation model doc 2026-10-04 08:23:05 +01:00
Samuel Onoja a2f5a7ffba Fix collective resize waits and fullscreen ownership 2026-10-04 08:16:03 +01:00
Samuel Onoja d5a829eb16 Decouple scrolling translation from resize readiness
Track configure-owned presentation dependencies so independent viewport translation can continue while clients resize. Preserve linked reflow waits, native snapshots, handoff deadlines and shared rendering/input coordinates.

Closes #10.
2026-10-04 08:05:44 +01:00
Samuel Onoja c1801e1f07 fix overview entrance animation 2026-10-04 07:53:34 +01:00
Samuel Onoja d75c93db85 Match shell squircle outlines with compositor masks
Use shared corner geometry for shell controls, window clipping, materials and blur masks. Preserve circular pills and controls where appropriate, track contour changes in damage, and use tagged ferese-shape and libcosmic dependencies.
2026-10-04 07:39:54 +01:00
Samuel Onoja 03a265b97f Add automatic idle inhibition for fullscreen playback and selected apps 2026-10-03 11:20:55 +01:00
Samuel Onoja 453ec16583 Protect selected windows from screen capture
Add capture privacy rules for windows, transient children, overview previews and client cursors, with separate capture rendering and deferred-frame invalidation. Keep detached close snapshots out of captures after clients leave the registry.

Validated with release workspace tests, private compositor/EGL regressions, and an isolated release PipeWire capture test.
2026-10-03 10:49:01 +01:00
Samuel Onoja bffd538dd4 Synchronize shell material presentation with surface commits
Queue popup, modal and notification material geometry and opacity until the matching content buffer commits. Remove event-handler opacity writes that could present material state ahead of content, while preserving compositor dismissal ownership.

Add pending-presentation regression coverage and update the nested shell lifecycle check.
2026-10-03 04:45:12 +01:00
Samuel Onoja e6e1a2a8cf Restore inactive workspace previews in overview 2026-10-03 04:43:26 +01:00
Samuel Onoja 1d7a472847 make button focus ring conditional on tab press 2026-10-03 04:29:06 +01:00
Samuel Onoja 64a80dc069 Unify window presentation and frame-driven shell motion
Share window geometry across overview, resize snapshots and retained closes. Advance close handoffs and group retained rendering by stacking position.

Drive shell motion from surface frames, reuse settled widget content, and synchronize notification materials with each card's fade. Replace physical config keys with duration and bounce, and update documentation and regression coverage.
2026-10-03 04:22:36 +01:00
Samuel Onoja f8bc17e913 Implement Continuum compositor and shell motion 2026-10-03 03:19:11 +01:00
Samuel Onoja 259f7e93b6 Document animation configuration and desktop comparisons 2026-10-03 01:01:44 +01:00
Samuel Onoja 934380d095 Remove component boundary script and dependent CI workflow 2026-10-03 00:14:09 +01:00
Samuel Onoja ee686507c5 Check component dependency boundaries in CI 2026-10-03 00:13:42 +01:00
Samuel Onoja cc0bddbec1 Separate theme rendering from client subscriptions and materials 2026-10-03 00:12:59 +01:00
Samuel Onoja ccf7497b6f Publish theme snapshots through IPC from explicit live state 2026-10-03 00:05:29 +01:00
Samuel Onoja 8195b82073 Separate resolved theme values from configuration resolution 2026-10-02 23:57:02 +01:00
Samuel Onoja 4c3e884438 Pin theme snapshot defaults and offline preview behavior 2026-10-02 23:55:26 +01:00
Samuel Onoja c3acb5af5a Move shell configuration out of desktop core 2026-10-02 23:54:27 +01:00
Samuel Onoja f5de30d037 Publish output topology through a desktop reconciliation boundary 2026-10-02 23:46:08 +01:00
Samuel Onoja 647eaf4ad9 Reduce Settings page and config reload overhead
Mount nearby shortcut and theme rows, preserve gallery keyboard navigation, reset page scrolling, and reuse native themes during palette transitions. Prepare and deduplicate live config reloads on a worker and apply only changed runtime sections.

Align pointer focus and overview selection on overlapping outputs, add regression coverage, and clean up workspace Clippy warnings.
2026-10-02 23:10:01 +01:00
Samuel Onoja 629414e0fb Fix workspace preview cards intercepting pointer input 2026-10-02 22:59:38 +01:00
Samuel Onoja 4166c1ea29 Slow overview transitions to sixty percent speed 2026-10-02 18:47:18 +01:00
Samuel Onoja 3a79121308 fix installer killed process mask 2026-10-02 14:48:59 +01:00
Samuel Onoja 96f5ee442e Add display mode chooser and align screen-sharing styles
Open the four display modes from XF86Display or the external-monitor bar icon. Reuse output reconciliation and confirmation for manual layout changes, and notify the shell when monitor state changes.

Release backdrop locks before render-element queries to prevent blurred popups from freezing the compositor. Cover popup open, update and close with regression tests.

Share translucent card styles between display modes and capture sources, show focus rings during keyboard navigation, and align picker buttons, label sizing and footer spacing with system modals.
2026-10-02 13:25:39 +01:00
Samuel Onoja 5100d533fb Confirm pending locks after output reconciliation 2026-10-02 12:14:08 +01:00
Samuel Onoja d14cd3cbd2 Extend output profiles with monitor reconciliation and mirroring 2026-10-02 12:08:19 +01:00
Samuel Onoja ac184576f6 Document bundle installation and normalize release directory permissions 2026-10-02 10:20:52 +01:00
Samuel Onoja bfec60848d Use Rust 1.95 consistently throughout the workspace 2026-10-02 10:20:52 +01:00
Samuel Onoja 50c5f94007 Assign launcher permissions when preparing release bundles 2026-10-02 10:17:54 +01:00
Samuel Onoja f770147c6d Install verified release bundles with recoverable activation 2026-10-02 10:13:54 +01:00
Samuel Onoja 17243464f6 Rewrite documentation and complete shortcut and feature lists 2026-10-02 10:13:53 +01:00
Samuel Onoja bbb1eac189 Recover known GPUs across DRM node renumbering 2026-10-02 09:52:18 +01:00
Samuel Onoja 428267a714 Refresh lid observations before rendering after resume 2026-10-02 09:41:40 +01:00
Samuel Onoja 0420e59cbe Reconcile DRM topology before resuming presentation 2026-10-02 09:33:19 +01:00
Samuel Onoja 73da6c68d2 Add Apple-style window corners with isolated shell rendering 2026-10-02 09:24:17 +01:00
Samuel Onoja b45917c41d Harden lock UI inputs and decoding and reduce idle clock updates 2026-10-02 08:51:35 +01:00
Samuel Onoja 8ad5293178 Model lock ownership explicitly and allow protected crash recovery 2026-10-02 08:48:01 +01:00
Samuel Onoja 570984a39b Wake shell status from service and hardware events 2026-10-02 08:33:37 +01:00
Samuel Onoja 8bb0b1da9c Supervise daemons with pidfds and restart deadlines 2026-10-02 08:23:47 +01:00
Samuel Onoja 181b7bce9a Register battery polling only for active automatic-refresh outputs 2026-10-02 08:18:47 +01:00
Samuel Onoja 79a968b0a6 Use lock idle deadlines and stop polling after display sleep 2026-10-02 08:18:01 +01:00
Samuel Onoja 96b0a9d097 Separate presentation facts from surface eligibility policy 2026-10-02 08:16:00 +01:00
Samuel Onoja aecf54bc76 Share visibility checks and test client presentation lifecycle 2026-10-02 08:07:24 +01:00
Samuel Onoja bb78801720 Separate requested and applied output runtime power 2026-10-02 08:05:28 +01:00
Samuel Onoja 2f63cae54c Gate display frame callbacks on shared presentation visibility 2026-10-02 07:59:41 +01:00
Samuel Onoja 6b2974d0fa Track displayed surface visibility for idle inhibition 2026-10-02 07:57:15 +01:00
Samuel Onoja aa54ab1b2b Reuse window capture targets and offload pixel conversion 2026-10-02 07:44:13 +01:00
Samuel Onoja 325835f40a Invalidate blur from per-output sampled backdrop damage 2026-10-02 04:27:07 +01:00
Samuel Onoja 3d6cee125d Preserve opaque interiors of rounded window surfaces 2026-10-02 02:47:04 +01:00
Samuel Onoja 95c8940008 Skip layer relayout for content-only surface commits 2026-10-02 02:41:44 +01:00
Samuel Onoja 092a1f9560 Wake shell UI immediately for compositor control events 2026-10-02 02:39:48 +01:00
Samuel Onoja ab0c174948 Reduce idle shell polling and stabilize overview damage 2026-10-02 02:34:35 +01:00
Samuel Onoja 6f2951c6b0 Scope redraw requests and avoid redundant animation fallback wakeups
Instrument redraw sources, per-output request coalescing, and animation fallback scheduling. Route local visual changes to their affected outputs while preserving deferred grab and gesture updates.

Schedule fallback recovery using each orphaned animation output's refresh interval, suppress it while a normal frame chain exists, and retain requested final frames after scheduling failures. Add mixed-refresh, cursor footprint, frame-chain, and disconnect regressions and document measurement limits.
2026-10-02 01:21:11 +01:00
Samuel Onoja 28e03a2624 Use analytic springs and instrument resize barriers
Share a stable damped oscillator solver across scalar and rectangle animations while preserving retarget and explicit no-crossing presentation policies. Add deterministic traces, scrolling and zoom continuity coverage, and pause-aware advancement.

Measure resize barrier and handoff timing behind resize-metrics, expose the feature through the release installer, and wait for the intended backend in isolated portal tests.
2026-10-02 00:49:52 +01:00
Samuel Onoja 42f022fde5 nits 2026-10-01 23:42:40 +01:00
Samuel Onoja da60ecb719 micro optimization fixes 2026-10-01 20:47:05 +01:00
1031 changed files with 294855 additions and 21035 deletions

No files matched your search

+1 -9
View File
@@ -1,13 +1,5 @@
name: Deploy website to GitHub Pages
on:
push:
branches: [main]
paths:
- "site/**"
- "docs/**"
- "packaging/icons/ferese.svg"
- "scripts/build-site.py"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
@@ -20,7 +12,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: actions/configure-pages@v5
- name: Build website and handbook
- name: Build homepage and documentation redirects
run: |
python3 -m venv .site-venv
.site-venv/bin/pip install -r site/requirements.txt
+85
View File
@@ -0,0 +1,85 @@
name: Workspace release checks
on:
workflow_dispatch:
workflow_call:
permissions:
contents: read
concurrency:
group: release-checks-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
workspace:
runs-on: ubuntu-24.04
container:
image: fedora:44
options: --init
timeout-minutes: 60
env:
CARGO_BUILD_JOBS: "2"
CARGO_INCREMENTAL: "0"
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
XDG_RUNTIME_DIR: /tmp/ferese-release-runtime
steps:
- name: Install desktop build and test dependencies
run: |
dnf install -y --allowerasing python3 python3-gobject python3-pillow git curl gcc gcc-c++ make cmake pkgconf-pkg-config \
wayland-devel wayland-protocols-devel libxkbcommon-devel libinput-devel systemd-devel libseat-devel \
mesa-libgbm-devel mesa-libEGL-devel mesa-dri-drivers mesa-vulkan-drivers libdrm-devel fontconfig-devel \
freetype-devel expat-devel dbus-daemon dbus-tools desktop-file-utils foot pam xorg-x11-xauth \
clang clang-devel pipewire-devel pipewire wireplumber polkit polkit-libs libei \
xdg-desktop-portal xdg-desktop-portal-gtk weston \
gstreamer1-devel gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer
- name: Create private test runtime directory
run: install -d -m 0700 "$XDG_RUNTIME_DIR"
- uses: actions/checkout@v6
- name: Install pinned Rust and checks
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup.sh
sh /tmp/rustup.sh -y --profile minimal --default-toolchain 1.95.0 --component rustfmt --component clippy
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Check formatting and all workspace lints
run: |
cargo fmt --all --check
cargo clippy --locked --workspace --all-targets -- -D warnings
- name: Build desktop binaries and test the complete workspace
run: |
cargo build --locked --workspace
cargo test --locked --workspace --no-fail-fast
- name: Check release tag matches the workspace version
if: startsWith(github.ref, 'refs/tags/')
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
python3 - <<'PYTHON'
import os, tomllib
with open("Cargo.toml", "rb") as source:
version = tomllib.load(source)["workspace"]["package"]["version"]
assert os.environ["RELEASE_TAG"] == "v" + version, "Tag must match workspace.package.version"
PYTHON
- name: Test capture privacy and failure recovery
env:
LIBGL_ALWAYS_SOFTWARE: "1"
EGL_PLATFORM: surfaceless
run: |
cargo test --locked -p ferese state::capture_privacy::tests -- --ignored
cargo test --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored
cargo test --locked -p ferese winit::tests::capture_failure_restores_display_target_and_subsequent_frames_render -- --ignored
- name: Test recorder audio, cropping and stream revocation
run: |
cargo test --locked -p xdg-desktop-portal-ferese --bin ferese-record encoder_finishes_a_real_webm -- --ignored
cargo test --locked -p xdg-desktop-portal-ferese --bin ferese-record encoder_saves_cropped_video_and_mixed_audio_when_stopped -- --ignored
cargo test --locked -p xdg-desktop-portal-ferese --bin ferese-record video_eos_finishes_live_audio_without_waiting_for_the_user -- --ignored
- name: Test native shell, visibility and resize behavior
run: |
install -m 0644 packaging/pam.d/ferese-lock.fedora /etc/pam.d/ferese-lock
python3 scripts/ci/run-native-checks.py --suite desktop --log-dir native-logs
- name: Upload native test logs
if: always()
uses: actions/upload-artifact@v4
with:
name: native-test-logs
path: native-logs
if-no-files-found: ignore
- name: Test installer, session recovery and private portal contracts
run: python3 -m unittest discover -s scripts/tests -v
+201
View File
@@ -0,0 +1,201 @@
name: Release packages
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
push:
tags: ['v*']
workflow_dispatch:
permissions:
contents: read
env:
CARGO_BUILD_JOBS: '2'
CARGO_INCREMENTAL: '0'
CARGO_PROFILE_RELEASE_DEBUG: '0'
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
checks:
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'release')
uses: ./.github/workflows/release-checks.yml
source:
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'release')
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v6
- name: Read release version
id: version
env:
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }}
run: |
python3 - <<'PY'
import importlib.util, os
from pathlib import Path
spec = importlib.util.spec_from_file_location('package', 'scripts/package.py')
package = importlib.util.module_from_spec(spec)
spec.loader.exec_module(package)
version = package.version()
package.check_tag(os.environ['RELEASE_TAG'], version)
if os.environ['RELEASE_TAG']:
notes = Path('docs/releases') / f"v{version}.md"
if not notes.read_text().strip():
raise ValueError('Release notes are empty')
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
print(f'version={version}', file=output)
PY
- name: Install pinned Rust toolchain
run: rustup show
- name: Package offline sources and recipes
env:
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }}
run: |
python3 -m unittest discover -s scripts/tests -p test_package.py
python3 scripts/package.py source --tag "$RELEASE_TAG"
- uses: actions/upload-artifact@v7
with:
name: release-source
path: target/dist/*
if-no-files-found: error
compression-level: 0
fedora:
needs: source
runs-on: ubuntu-24.04
container:
image: fedora:44
options: --init
steps:
- name: Install RPM tooling
run: dnf install -y rpm-build dnf5-plugins git findutils tar gzip
- uses: actions/download-artifact@v8
with:
name: release-source
path: dist
- name: Build and test Fedora package
run: |
dnf builddep -y dist/ferese.spec
mkdir -p /tmp/rpmbuild/SOURCES
cp dist/ferese-*.tar.gz /tmp/rpmbuild/SOURCES/
rpmbuild -ba --define '_topdir /tmp/rpmbuild' dist/ferese.spec
mkdir -p artifacts
find /tmp/rpmbuild/RPMS /tmp/rpmbuild/SRPMS -name '*.rpm' -exec cp '{}' artifacts/ \;
- uses: actions/upload-artifact@v7
with:
name: release-fedora
path: artifacts/*.rpm
if-no-files-found: error
compression-level: 0
arch:
needs: source
runs-on: ubuntu-24.04
container:
image: archlinux:base-devel
options: --init
steps:
- name: Prepare Arch build user and dependencies
run: |
pacman -Syu --noconfirm --needed rust cargo cmake clang pkgconf python git desktop-file-utils \
wayland libxkbcommon libinput systemd seatd mesa vulkan-swrast libdrm fontconfig freetype2 expat dbus \
pam polkit foot pipewire wireplumber libpipewire gstreamer xdg-desktop-portal xdg-desktop-portal-gtk xorg-xauth
useradd --create-home builder
- uses: actions/download-artifact@v8
with:
name: release-source
path: dist
- name: Build and test Arch package
run: |
mkdir -p /home/builder/package
cp dist/PKGBUILD dist/ferese-*.tar.gz /home/builder/package/
chown -R builder:builder /home/builder/package
runuser -u builder -- bash -c 'cd /home/builder/package && makepkg --cleanbuild --noconfirm'
mkdir -p artifacts
cp /home/builder/package/*.pkg.tar.zst artifacts/
runuser -u builder -- bash -c 'cd /home/builder/package && makepkg --printsrcinfo' > artifacts/ferese.SRCINFO
- uses: actions/upload-artifact@v7
with:
name: release-arch
path: artifacts/*
if-no-files-found: error
compression-level: 0
publish:
if: github.event_name == 'push' && github.ref_type == 'tag'
needs: [source, checks, fedora, arch]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v8
with:
pattern: release-*
merge-multiple: true
path: artifacts
- name: Prepare draft release
uses: actions/github-script@v8
with:
script: |
const tag = context.ref.slice('refs/tags/'.length);
const repo = context.repo;
const fs = require('node:fs');
if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/.test(tag)) {
throw new Error('Invalid release tag');
}
const body = fs.readFileSync(`docs/releases/${tag}.md`, 'utf8');
if (!body.trim()) throw new Error('Release notes are empty');
const commit = await github.rest.repos.getCommit({...repo, ref: tag});
if (commit.data.sha !== context.sha) {
throw new Error('Release tag moved after validation');
}
let release;
try {
release = (await github.rest.repos.getReleaseByTag({...repo, tag})).data;
} catch (error) {
if (error.status !== 404) throw error;
}
if (release && !release.draft) {
throw new Error(`Release ${tag} is already published`);
}
if (!release) {
await github.rest.repos.createRelease({
...repo, tag_name: tag, name: tag, draft: true,
body, prerelease: tag.includes('-')
});
} else {
await github.rest.repos.updateRelease({
...repo, release_id: release.id, body,
prerelease: tag.includes('-')
});
}
- name: Upload packages and publish completed release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
cd artifacts
# GitHub replaces '~' in uploaded filenames. Use those same download
# names in SHA256SUMS so verification works after downloading RPMs.
python3 - <<'PY'
from pathlib import Path
for source in Path('.').glob('*.rpm'):
destination = source.with_name(source.name.replace('~', '.'))
if source != destination:
if destination.exists():
raise SystemExit(f'Duplicate release asset: {destination}')
source.rename(destination)
PY
rm SHA256SUMS
sha256sum * > SHA256SUMS
gh release upload "$RELEASE_TAG" ./* --clobber
test "$(gh api "repos/$GH_REPO/commits/$RELEASE_TAG" --jq .sha)" = "$GITHUB_SHA"
gh release edit "$RELEASE_TAG" --draft=false
+48
View File
@@ -0,0 +1,48 @@
name: Publish documentation to GitHub Wiki
on:
push:
branches: [main]
paths:
- 'docs/**'
- 'site/README.md'
- 'crates/ferese-theme/README.md'
- 'scripts/build-wiki.py'
- 'scripts/tests/test_wiki.py'
- 'site/requirements.txt'
- '.github/workflows/wiki.yml'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: wiki
cancel-in-progress: false
jobs:
publish:
if: github.repository == 'ferese-wm/ferese' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: main
persist-credentials: false
- name: Install Markdown parser and validate publisher
run: |
python3 -m venv .site-venv
.site-venv/bin/pip install -r site/requirements.txt
.site-venv/bin/python -m unittest discover -s scripts/tests -p test_wiki.py -v
.site-venv/bin/python scripts/build-wiki.py
- name: Publish changed wiki pages
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eu
gh auth setup-git --hostname github.com
git clone --depth 1 https://github.com/ferese-wm/ferese.wiki.git build/wiki-checkout
.site-venv/bin/python scripts/build-wiki.py --output build/wiki-checkout
git -C build/wiki-checkout add -- '*.md' .ferese-pages.json
if git -C build/wiki-checkout diff --cached --quiet; then
echo 'Wiki is already current'
exit 0
fi
git -C build/wiki-checkout -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' commit -m "Publish documentation from $(git rev-parse --short HEAD)"
git -C build/wiki-checkout push origin HEAD
+128
View File
@@ -0,0 +1,128 @@
name: Workspace and native stability checks
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: workspace-checks-${{ github.ref }}
cancel-in-progress: true
jobs:
workspace:
runs-on: ubuntu-24.04
timeout-minutes: 60
env:
CARGO_BUILD_JOBS: "2"
CARGO_INCREMENTAL: "0"
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
XDG_RUNTIME_DIR: /tmp/ferese-workspace-runtime
steps:
- uses: actions/checkout@v6
- name: Install workspace build and test dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential pkg-config dbus-daemon libwayland-dev \
libxkbcommon-dev xkb-data libinput-dev libudev-dev libseat-dev libgbm-dev \
python3-gi python3-pil libegl-dev libegl-mesa0 libgl1-mesa-dri mesa-vulkan-drivers libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev xauth \
clang libclang-dev libpipewire-0.3-dev libspa-0.2-dev libglib2.0-dev libgstreamer1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good libpam-modules
- name: Create private test runtime directory
run: install -d -m 0700 "$XDG_RUNTIME_DIR"
- name: Install Rust
run: rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy
- name: Check formatting and all workspace lints
run: |
cargo +1.95.0 fmt --all --check
cargo +1.95.0 clippy --locked --workspace --all-targets -- -D warnings
- name: Build desktop binaries
run: cargo +1.95.0 build --locked --workspace
- name: Test every workspace package
run: cargo +1.95.0 test --locked --workspace --no-fail-fast -- --nocapture
- name: Validate every Settings control against the compositor
env:
FERESE_TEST_BINARY: ${{ github.workspace }}/target/debug/ferese
run: cargo +1.95.0 test --locked -p ferese-settings all_gui_controls_and_presets_pass_compositor_validation -- --ignored --nocapture
- name: Fetch pinned upstream portal contracts
uses: actions/checkout@v6
with:
repository: flatpak/xdg-desktop-portal
ref: 23a76c392170dbbd26230f85ef56c3a57e52b857 # 1.20.3
path: build/portal-contracts
- name: Test installer, session recovery and private portal contracts
env:
FERESE_TEST_PORTAL_INTERFACES: ${{ github.workspace }}/build/portal-contracts/data
FERESE_TEST_PORTAL_BACKGROUND: "1"
run: python3 -m unittest discover -s scripts/tests -v
- name: Test EGL initialization, capture privacy and cursor composition
env:
LIBGL_ALWAYS_SOFTWARE: "1"
EGL_PLATFORM: surfaceless
run: |
cargo +1.95.0 test --locked -p ferese backends::direct::context::tests -- --include-ignored --nocapture
cargo +1.95.0 test --locked -p ferese backends::direct::capture::tests::unchanged_capture_skips_preparation_without_losing_source_updates -- --ignored --nocapture
cargo +1.95.0 test --locked -p ferese state::capture_privacy::tests -- --ignored --nocapture
cargo +1.95.0 test --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored --nocapture
cargo +1.95.0 test --locked -p ferese winit::tests::capture_failure_restores_display_target_and_subsequent_frames_render -- --ignored --nocapture
cargo +1.95.0 test --locked -p ferese render::capture::stream_gpu_tests -- --ignored --nocapture
- name: Preserve static recording duration
run: cargo +1.95.0 test --locked -p xdg-desktop-portal-ferese --bin ferese-record recorder::tests::encoder_preserves_the_duration_of_a_static_capture -- --ignored --nocapture
- name: Share desktop binaries with native checks
uses: actions/upload-artifact@v7
with:
name: native-check-binaries
path: |
target/debug/ferese
target/debug/ferese-shell
target/debug/feresectl
if-no-files-found: error
retention-days: 1
native:
name: Native panel and XWayland checks
needs: workspace
runs-on: ubuntu-24.04
timeout-minutes: 20
env:
CARGO_BUILD_JOBS: "2"
CARGO_PROFILE_DEV_DEBUG: "0"
LIBGL_ALWAYS_SOFTWARE: "1"
steps:
- uses: actions/checkout@v6
- name: Install native test dependencies
run: |
sudo apt-get update
sudo apt-get install -y weston xwayland x11-utils xauth dbus-daemon \
build-essential pkg-config clang libclang-dev libxcb1-dev libxcb-cursor-dev libxkbcommon-dev \
libwayland-dev wayland-protocols libgbm1 libseat1 libinput10 libpipewire-0.3-0t64 \
libegl1 libegl-mesa0 libgl1-mesa-dri mesa-vulkan-drivers fonts-dejavu-core
- name: Create the shared X11 socket directory
run: sudo install -d -m 1777 -o root -g root /tmp/.X11-unix
- uses: actions/download-artifact@v8
with:
name: native-check-binaries
path: target/debug
- name: Restore executable permissions
run: chmod +x target/debug/ferese target/debug/ferese-shell target/debug/feresectl
- name: Install pinned Rust
run: rustup toolchain install 1.95.0 --profile minimal
- name: Build pinned XWayland Satellite with readiness support
uses: actions/checkout@v6
with:
repository: Supreeeme/xwayland-satellite
ref: b83eab900644e4c7c77982ce3d44cb490f0c5e1d # v0.8.3
path: build/xwayland-satellite
- name: Build XWayland Satellite
run: cargo +1.95.0 build --manifest-path build/xwayland-satellite/Cargo.toml --locked --features systemd
- name: Run isolated native checks on a headless Wayland host
env:
FERESE_TEST_BINARY: target/debug/ferese
FERESE_TEST_SHELL: target/debug/ferese-shell
FERESE_TEST_CTL: target/debug/feresectl
FERESE_TEST_SATELLITE: build/xwayland-satellite/target/debug/xwayland-satellite
run: python3 scripts/ci/run-native-checks.py --log-dir build/native-check-logs
- name: Save native test logs
if: always()
uses: actions/upload-artifact@v7
with:
name: native-check-logs
path: build/native-check-logs
if-no-files-found: warn
retention-days: 7
+2 -1
View File
@@ -1,12 +1,13 @@
/target/
/.artifacts/
/tools/
*.log
ChatGPT Image*.png
# Local architecture decision notes
/docs/adr/
# Generated GitHub Pages site
/build/site/
/build/wiki/
/.site-venv/
__pycache__/
Generated
+1525 -353
View File
File diff suppressed because it is too large. Load diff
+30 -5
View File
@@ -3,12 +3,17 @@ members = [
"compositor",
"feresectl",
"crates/ferese-animation",
"crates/ferese-activities",
"crates/ferese-core",
"crates/ferese-config",
"crates/ferese-theme",
"crates/ferese-theme-client",
"crates/ferese-theme-model",
"crates/ferese-ipc",
"crates/ferese-layout",
"crates/ferese-panel",
"crates/ferese-protocols",
"crates/ferese-rime",
"shell/ferese-shell",
"shell/ferese-settings",
"shell/ferese-lock",
@@ -16,20 +21,37 @@ members = [
"portal",
]
resolver = "3"
exclude = ["vendor/smithay"]
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT"
rust-version = "1.98"
rust-version = "1.95"
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
unused_must_use = "deny"
[workspace.lints.clippy]
borrowed_box = "deny"
must_use_candidate = "warn"
needless_for_each = "warn"
ptr_arg = "deny"
ref_option = "deny"
undocumented_unsafe_blocks = "deny"
wildcard_imports = "deny"
[workspace.dependencies]
schemars = { version = "1.2.3", features = ["preserve_order"] }
ferese-shape = { git = "https://github.com/ferese-wm/ferese-shape", tag = "v0.1.0" }
bitflags = "2.9.4"
calloop = { version = "0.14.4", features = ["signals"] }
calloop = { version = "0.14.5", features = ["signals"] }
jiff = "0.2.37"
libc = "0.2.176"
libcosmic = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0", default-features = false, features = ["multi-window", "tokio", "wayland"] }
smithay = { version = "0.7.0", default-features = false, features = [
iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.3-rc.3" }
libcosmic = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.3-rc.3", default-features = false, features = ["multi-window", "tokio", "wayland"] }
smithay = { git = "https://github.com/Smithay/smithay", rev = "8a270232f1bfc416c781273c98a3f52a812a0bba", default-features = false, features = [
"backend_drm",
"backend_gbm",
"backend_libinput",
@@ -42,7 +64,7 @@ smithay = { version = "0.7.0", default-features = false, features = [
] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
serde = { version = "1.0.228", features = ["derive"] }
serde = { version = "1.0.228", features = ["derive", "rc"] }
serde_json = "1.0.145"
tempfile = "3.23.0"
xcursor = "0.3.11"
@@ -51,3 +73,6 @@ wayland-client = "0.31.15"
wayland-scanner = "0.31.11"
wayland-server = "0.31.14"
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
[patch."https://github.com/Smithay/smithay"]
smithay = { path = "vendor/smithay" }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ferese contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+80 -55
View File
@@ -1,37 +1,61 @@
<p align="center">
<img src="docs/images/ferese-lockup.svg" width="300" alt="Ferese">
<img src="assets/branding/ferese.svg" width="160" height="160" alt="Ferese">
</p>
<p align="center"><strong>A complete Wayland desktop built around personalization</strong></p>
<p align="center"><strong>A configurable Wayland desktop</strong></p>
<p align="center">
<a href="docs/installation.md">Install</a> ·
<a href="docs/configuration.md">Configure</a> ·
<a href="docs/screenshots.md">Screenshots</a> ·
<a href="docs/README.md">Documentation</a>
<a href="https://github.com/ferese-wm/ferese/releases">Download</a> ·
<a href="https://github.com/ferese-wm/ferese/wiki/Installation">Install</a> ·
<a href="https://github.com/ferese-wm/ferese/wiki/Configuration">Configure</a> ·
<a href="https://github.com/ferese-wm/ferese/wiki">Documentation</a>
</p>
Ferese is a window manager and desktop shell built together and configured in one place, for people who want their desktop to be their own.
Ferese is a Linux desktop built in Rust with Smithay. It includes a window manager,
desktop shell, Settings and a lock screen, with shared configuration and theming.
Choose a layout per workspace and configure the desktop through Settings or a KDL file.
![Ferese Blue desktop with Settings and Control Center](docs/images/screenshots/ferese-main-hero.png)
**Version 0.1.0** is the first stable release. See the [release notes](docs/releases/v0.1.0.md)
for what's included. Packages target Fedora 44 and Arch Linux on x86_64.
This README and the wiki describe `main`.
[View more screenshots](docs/screenshots.md)
This local `twilight/rime-service-poc` branch adds an independent native Wayland
Rime service and compositor popup fixes. See [Rime prototype validation](docs/development/rime-poc.md)
for its architecture, isolated preview, test commands and current limits.
## Features
![Ferese desktop with the launcher, build progress, Appearance settings and terminal windows](docs/images/screenshots/ferese-desktop.png)
- Scrolling columns, tree tiling and floating windows, with an overview of all your workspaces
- Keyboard shortcuts and touchpad gestures you can remap
- Multiple monitors with fractional scaling
- Themes, wallpapers, blur and corners that update live, with Light/Dark/Auto modes and custom KDL themes
- Desktop widgets, including a clock and sticky notes
- Screenshots with annotation, plus screen sharing and built-in recording
- A built-in lock screen
- Integrated Settings and Control Center for configuring your desktop
[Screenshots](https://github.com/ferese-wm/ferese/wiki/Screenshots)
## Using Ferese
- Use scrolling columns, tree tiling, floating windows and
[tabbed window groups](https://github.com/ferese-wm/ferese/wiki/Tabbed-Windows).
- Arrange [panel items and groups](https://github.com/ferese-wm/ferese/wiki/Panel-Configuration),
place the panel at the top or bottom, and choose its style.
- Follow command and build progress in the panel with
[Live Activities](https://github.com/ferese-wm/ferese/wiki/Live-Activities).
- Set up [binding modes](https://github.com/ferese-wm/ferese/wiki/Configuration-Bindings#binding-modes)
for resizing, launching apps and other shortcut groups.
- Change themes, wallpapers, blur and animation settings across the desktop.
Configuration edits apply live. Invalid edits leave the last working configuration active.
- Manage displays, Wi-Fi, Bluetooth and audio from Settings and Control Center.
Multiple monitors, fractional scaling and monitor mirroring are supported.
- Annotate screenshots, share a window, workspace or display, and record a monitor or selected
region with audio.
The [feature roadmap](https://github.com/ferese-wm/ferese/issues/5) tracks implemented features and remaining work.
## Install
Start with the [dependencies](docs/installation.md#requirements), then clone Ferese and
build it as your normal user:
Download the package for your distribution from
[Releases](https://github.com/ferese-wm/ferese/releases/tag/v0.1.0), then follow
the [package installation instructions](https://github.com/ferese-wm/ferese/wiki/Packages-and-Releases#install-a-release-package).
Read the [release notes](docs/releases/v0.1.0.md) before upgrading.
To build the current development version, install the
[build dependencies](https://github.com/ferese-wm/ferese/wiki/Installation#requirements)
and run these commands as your normal user:
```sh
git clone https://github.com/ferese-wm/ferese.git
@@ -39,55 +63,56 @@ cd ferese
./scripts/install.sh
```
Once it’s installed, log out and choose **Ferese** at your login screen, or run
`ferese-session` from a TTY or a greeter that accepts a command. The [installation
guide](docs/installation.md) also walks you through previews, updates and recovery.
Once installed, log out and select **Ferese** at your login screen, or run
`ferese-session` from a local TTY. You can also try a
[nested preview](https://github.com/ferese-wm/ferese/wiki/Installation#preview-and-logs)
inside your existing Wayland desktop.
## Configure
Make it yours through **Control Center → Settings** or `~/.config/ferese/config.kdl`,
with changes applying as you make them and the last working configuration staying in
place if an edit is invalid.
Open **Control Center → Settings** or edit `~/.config/ferese/config.kdl`.
The [visual direction notes](docs/development/visual-direction.md) document the
Graphite dark/light family and the responsive settings layout.
The [configuration reference](docs/configuration.md) and [example
config](packaging/config.kdl) cover everything from appearance and window layouts to
displays and startup apps.
This fork adds a grouped **Appearance** settings hub. **General → Language**
supports the system language, 简体中文 and English and saves the choice as
`ui { language "zh-CN"; }` (`auto` and `en` are also supported). It changes
Ferese's interface independently of keyboard layouts and input methods.
## Default shortcuts
**All options** exposes the compositor's complete configuration schema, including
nested lists, maps and optional sections. Confirm text with Enter and apply the
whole draft; validation, conflict detection and backups use the normal Settings
save path. Basic settings continue to save immediately.
Use `Super`, usually the Windows key, to move around your desktop, and open **Settings →
Shortcuts** whenever you want to change a shortcut or gesture.
**Tray icons** controls panel status icons and StatusNotifierItem application
icons. Applications can be hidden individually, including after they exit. Tray
preferences live under `status { tray { enabled #true; show-passive #false;
icon-size 16; hidden-items "application-id"; }; }`. Existing custom panels can
add an **Application tray** item in **Panel & Shell**. The compositor, Shell and
Settings must be built and installed together so the schema and editor match.
The [configuration reference](https://github.com/ferese-wm/ferese/wiki/Configuration)
and [example config](packaging/config.kdl) cover appearance, layouts, displays,
window rules and startup apps.
## First shortcuts
`Super` is usually the Windows key. Change shortcuts and gestures in **Settings → Shortcuts**.
| Control | Action |
| --- | --- |
| Super + Enter | Open a terminal |
| Super + H / J / K / L | Focus left / down / up / right |
| Super + Backspace | Return to the last focused window |
| Super + Escape | Toggle between the last two visited workspaces on this monitor |
| Alt + Tab / Alt + Shift + Tab | Preview recent windows forward / backward; release Alt to select, Escape to cancel |
| Super + F | Maximize the window |
| Super + Shift + F | Toggle fullscreen |
| Super + R | Cycle column width |
| Super + Tab | Open overview |
| Super + M | Switch scrolling / tree layout |
| Print Screen | Capture the active monitor and open Satty |
| Super + Shift + S | Select a screenshot area |
| Super + Shift + E | Ask to log out |
| Super + left-button drag | Move a floating window |
| Super + right-button drag | Resize a floating window |
| Three-finger swipe up / down | Next / previous workspace |
| Three-finger swipe left / right | Focus the window to the right / left |
Explore [all shortcuts and mouse actions](docs/shortcuts.md) for more ways to arrange
your windows and move between workspaces.
See [all shortcuts and gestures](https://github.com/ferese-wm/ferese/wiki/Shortcuts-and-Gestures)
for resizing, grouping windows, screenshots and workspace navigation.
## Documentation
## Development and bug reports
The [documentation index](docs/README.md) brings the desktop guides together, while
[development instructions](docs/development.md) cover building, testing and
contributing.
Ferese is written in Rust using Smithay, with development happening [on
GitHub](https://github.com/ferese-wm/ferese). If something goes wrong, [open an
issue](https://github.com/ferese-wm/ferese/issues) with the steps to reproduce it and
any relevant logs.
[Development instructions](https://github.com/ferese-wm/ferese/wiki/Development)
cover building, tests and hardware checks. To
[report a bug](https://github.com/ferese-wm/ferese/issues), include steps to reproduce it,
your version or commit, relevant logs, and whether you ran a nested preview or a direct session.
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="160 185 950 950" role="img" aria-label="Ferese">
<path d="M0 0 C4.55 4.32 7.91 8.89 8.33 15.33 C8.35 16.55 8.36 17.78 8.38 19 C8.39 19.3 8.39 19.3 8.44 20.83 C8.49 27.21 5.87 31.95 1.71 36.73 C-4.2 42.15 -10.93 44.1 -18.76 44.55 C-50.32 46.61 -75.43 58.22 -98.88 79.19 C-99.21 79.48 -99.21 79.48 -100.9 80.99 C-124.9 103.69 -139.89 139.69 -141.14 172.46 C-141.28 178.36 -141.25 184.26 -141.22 190.16 C-141.21 192.41 -141.22 194.66 -141.22 196.91 C-141.22 201.71 -141.21 206.51 -141.19 211.32 C-141.17 218.26 -141.16 225.2 -141.16 232.15 C-141.15 243.42 -141.13 254.69 -141.1 265.96 C-141.07 276.89 -141.05 287.83 -141.04 298.76 C-141.04 299.44 -141.04 300.12 -141.04 300.81 C-141.03 304.21 -141.03 307.6 -141.03 310.99 C-140.99 339.06 -140.94 367.12 -140.88 395.19 C-140.31 395.28 -139.74 395.37 -139.15 395.47 C-133.11 396.45 -127.07 397.43 -121.03 398.42 C-118.79 398.78 -116.55 399.15 -114.31 399.51 C-60.83 408.2 -60.83 408.2 -54.88 410.19 C-54.71 409.64 -54.54 409.1 -54.37 408.54 C-47.93 388.17 -34.96 370.82 -20.25 355.62 C-18.86 354.17 -17.49 352.71 -16.12 351.24 C-4.73 339.25 8.69 329.23 23.12 321.19 C23.84 320.79 24.55 320.39 25.29 319.98 C50.29 306.18 77.01 296.78 105.88 296.87 C107.93 296.87 109.97 296.85 112.02 296.83 C133.34 296.72 155.21 302.4 171.12 317.19 C177.61 324.1 181.44 332.74 182.12 342.19 C182.18 342.83 182.23 343.47 182.28 344.12 C183.26 366.26 170.19 386.3 156 402.12 C142.58 416.77 125.82 428.36 108.12 437.19 C107.42 437.54 106.71 437.9 105.98 438.26 C73.26 454.35 37.72 460.63 1.56 460.31 C-0.43 460.3 -2.43 460.29 -4.42 460.29 C-9.24 460.26 -14.06 460.23 -18.88 460.19 C-18.58 462.41 -18.28 464.63 -17.98 466.84 C-17.94 467.15 -17.94 467.15 -17.73 468.73 C-15.1 487.74 -2.61 505.82 12.38 517.44 C45.27 541.86 86.62 552.07 127.12 553.19 C127.53 553.2 127.53 553.2 129.6 553.27 C191.26 555.11 250.44 535.68 303.61 505.44 C304.2 505.11 304.79 504.77 305.4 504.43 C306.5 503.81 307.59 503.18 308.69 502.55 C315.54 498.66 321.72 497.01 329.43 498.69 C335.43 500.46 339.97 502.79 343.38 508.16 C346.2 513.74 347.2 519 346.12 525.19 C341.87 538.11 331.44 544.92 320.12 551.19 C319.32 551.64 318.51 552.09 317.67 552.55 C280.55 572.94 239.61 586.84 197.88 593.94 C197.58 593.99 197.58 593.99 196.08 594.25 C176.92 597.48 157.92 598.57 138.5 598.5 C137.4 598.5 136.3 598.5 135.17 598.49 C118.96 598.45 103.11 598.08 87.12 595.19 C86.12 595.01 85.12 594.83 84.08 594.65 C50.1 588.51 15.4 577.01 -11.88 555.19 C-12.46 554.72 -13.05 554.25 -13.66 553.76 C-31.32 539.47 -44.56 522.98 -53.88 502.19 C-54 501.91 -54 501.91 -54.64 500.51 C-60.89 486.4 -63.29 471.5 -63.88 456.19 C-78.94 453.2 -94.1 450.81 -109.25 448.35 C-112.83 447.76 -116.4 447.18 -119.97 446.6 C-126.94 445.46 -133.91 444.32 -140.88 443.19 C-140.88 443.96 -140.88 444.74 -140.89 445.53 C-140.97 464.47 -141.06 483.4 -141.16 502.34 C-141.21 511.49 -141.26 520.65 -141.3 529.81 C-141.33 537.8 -141.37 545.79 -141.42 553.78 C-141.44 558 -141.46 562.22 -141.48 566.45 C-141.54 583.79 -141.65 600.96 -143.88 618.19 C-144.02 619.33 -144.16 620.48 -144.31 621.65 C-147.21 643.73 -153.97 665.28 -163.88 685.19 C-164.05 685.55 -164.05 685.55 -164.96 687.38 C-173.84 705.26 -185.28 720.58 -198.88 735.19 C-199.49 735.85 -200.1 736.52 -200.73 737.21 C-226.52 764.21 -267.21 783.28 -304.69 784.35 C-306.01 784.37 -307.33 784.38 -308.64 784.39 C-309.36 784.4 -310.07 784.4 -310.81 784.41 C-313.1 784.43 -315.39 784.44 -317.69 784.44 C-318.08 784.44 -318.08 784.44 -320.06 784.44 C-331.93 784.42 -343.28 783.89 -354.88 781.19 C-355.24 781.11 -355.24 781.11 -357.1 780.72 C-376.29 776.61 -395.44 768.97 -411.88 758.19 C-412.44 757.82 -413.01 757.45 -413.6 757.06 C-422.26 751.33 -429.96 744.83 -437.41 737.6 C-438.74 736.32 -440.09 735.06 -441.45 733.8 C-471.2 705.54 -489.04 662.81 -490.16 621.95 C-491.32 566.83 -469.26 516.41 -431.34 476.72 C-430.86 476.21 -430.37 475.71 -429.88 475.19 C-429.36 474.65 -428.84 474.11 -428.3 473.55 C-421.71 466.72 -415 460.26 -407.48 454.44 C-405.62 452.99 -403.78 451.53 -401.94 450.06 C-393.58 443.48 -385.11 437.52 -375.84 432.3 C-373.76 431.12 -371.7 429.93 -369.64 428.73 C-333.29 407.84 -293.36 396.2 -251.88 391.19 C-251.14 391.1 -250.4 391.01 -249.64 390.92 C-243.13 390.19 -236.72 390.06 -230.18 390.09 C-229.11 390.09 -228.03 390.09 -226.93 390.09 C-223.53 390.1 -220.14 390.11 -216.75 390.12 C-214.43 390.13 -212.12 390.13 -209.8 390.14 C-204.16 390.15 -198.52 390.17 -192.88 390.19 C-192.88 389.63 -192.88 389.63 -192.88 386.8 C-192.92 360.23 -192.96 333.65 -192.98 307.08 C-192.98 303.8 -192.98 300.52 -192.98 297.24 C-192.99 296.91 -192.99 296.91 -192.99 295.26 C-193 284.72 -193.01 274.19 -193.03 263.66 C-193.05 252.82 -193.06 241.99 -193.06 231.16 C-193.07 224.49 -193.07 217.83 -193.09 211.16 C-193.1 206.02 -193.1 200.88 -193.1 195.74 C-193.1 193.65 -193.11 191.56 -193.11 189.47 C-193.17 172.31 -192.41 155.4 -188.19 138.69 C-187.93 137.64 -187.67 136.59 -187.4 135.5 C-180.48 108.47 -167.73 82.68 -149.88 61.19 C-149.38 60.59 -148.89 59.99 -148.38 59.37 C-118.56 23.29 -76.88 -0.56 -30.08 -5.29 C-19.46 -6.27 -8Line truncated
</svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="160 185 950 950" role="img" aria-label="Ferese">
<defs><linearGradient id="blue0" gradientUnits="userSpaceOnUse" x1="250" y1="260" x2="1040" y2="980" gradientTransform="translate(-706.875 -245.8125)"><stop offset="0" stop-color="#69A8FF" /><stop offset="0.5" stop-color="#3B82F6" /><stop offset="1" stop-color="#245CE5" /></linearGradient></defs><path d="M0 0 C4.55 4.32 7.91 8.89 8.33 15.33 C8.35 16.55 8.36 17.78 8.38 19 C8.39 19.3 8.39 19.3 8.44 20.83 C8.49 27.21 5.87 31.95 1.71 36.73 C-4.2 42.15 -10.93 44.1 -18.76 44.55 C-50.32 46.61 -75.43 58.22 -98.88 79.19 C-99.21 79.48 -99.21 79.48 -100.9 80.99 C-124.9 103.69 -139.89 139.69 -141.14 172.46 C-141.28 178.36 -141.25 184.26 -141.22 190.16 C-141.21 192.41 -141.22 194.66 -141.22 196.91 C-141.22 201.71 -141.21 206.51 -141.19 211.32 C-141.17 218.26 -141.16 225.2 -141.16 232.15 C-141.15 243.42 -141.13 254.69 -141.1 265.96 C-141.07 276.89 -141.05 287.83 -141.04 298.76 C-141.04 299.44 -141.04 300.12 -141.04 300.81 C-141.03 304.21 -141.03 307.6 -141.03 310.99 C-140.99 339.06 -140.94 367.12 -140.88 395.19 C-140.31 395.28 -139.74 395.37 -139.15 395.47 C-133.11 396.45 -127.07 397.43 -121.03 398.42 C-118.79 398.78 -116.55 399.15 -114.31 399.51 C-60.83 408.2 -60.83 408.2 -54.88 410.19 C-54.71 409.64 -54.54 409.1 -54.37 408.54 C-47.93 388.17 -34.96 370.82 -20.25 355.62 C-18.86 354.17 -17.49 352.71 -16.12 351.24 C-4.73 339.25 8.69 329.23 23.12 321.19 C23.84 320.79 24.55 320.39 25.29 319.98 C50.29 306.18 77.01 296.78 105.88 296.87 C107.93 296.87 109.97 296.85 112.02 296.83 C133.34 296.72 155.21 302.4 171.12 317.19 C177.61 324.1 181.44 332.74 182.12 342.19 C182.18 342.83 182.23 343.47 182.28 344.12 C183.26 366.26 170.19 386.3 156 402.12 C142.58 416.77 125.82 428.36 108.12 437.19 C107.42 437.54 106.71 437.9 105.98 438.26 C73.26 454.35 37.72 460.63 1.56 460.31 C-0.43 460.3 -2.43 460.29 -4.42 460.29 C-9.24 460.26 -14.06 460.23 -18.88 460.19 C-18.58 462.41 -18.28 464.63 -17.98 466.84 C-17.94 467.15 -17.94 467.15 -17.73 468.73 C-15.1 487.74 -2.61 505.82 12.38 517.44 C45.27 541.86 86.62 552.07 127.12 553.19 C127.53 553.2 127.53 553.2 129.6 553.27 C191.26 555.11 250.44 535.68 303.61 505.44 C304.2 505.11 304.79 504.77 305.4 504.43 C306.5 503.81 307.59 503.18 308.69 502.55 C315.54 498.66 321.72 497.01 329.43 498.69 C335.43 500.46 339.97 502.79 343.38 508.16 C346.2 513.74 347.2 519 346.12 525.19 C341.87 538.11 331.44 544.92 320.12 551.19 C319.32 551.64 318.51 552.09 317.67 552.55 C280.55 572.94 239.61 586.84 197.88 593.94 C197.58 593.99 197.58 593.99 196.08 594.25 C176.92 597.48 157.92 598.57 138.5 598.5 C137.4 598.5 136.3 598.5 135.17 598.49 C118.96 598.45 103.11 598.08 87.12 595.19 C86.12 595.01 85.12 594.83 84.08 594.65 C50.1 588.51 15.4 577.01 -11.88 555.19 C-12.46 554.72 -13.05 554.25 -13.66 553.76 C-31.32 539.47 -44.56 522.98 -53.88 502.19 C-54 501.91 -54 501.91 -54.64 500.51 C-60.89 486.4 -63.29 471.5 -63.88 456.19 C-78.94 453.2 -94.1 450.81 -109.25 448.35 C-112.83 447.76 -116.4 447.18 -119.97 446.6 C-126.94 445.46 -133.91 444.32 -140.88 443.19 C-140.88 443.96 -140.88 444.74 -140.89 445.53 C-140.97 464.47 -141.06 483.4 -141.16 502.34 C-141.21 511.49 -141.26 520.65 -141.3 529.81 C-141.33 537.8 -141.37 545.79 -141.42 553.78 C-141.44 558 -141.46 562.22 -141.48 566.45 C-141.54 583.79 -141.65 600.96 -143.88 618.19 C-144.02 619.33 -144.16 620.48 -144.31 621.65 C-147.21 643.73 -153.97 665.28 -163.88 685.19 C-164.05 685.55 -164.05 685.55 -164.96 687.38 C-173.84 705.26 -185.28 720.58 -198.88 735.19 C-199.49 735.85 -200.1 736.52 -200.73 737.21 C-226.52 764.21 -267.21 783.28 -304.69 784.35 C-306.01 784.37 -307.33 784.38 -308.64 784.39 C-309.36 784.4 -310.07 784.4 -310.81 784.41 C-313.1 784.43 -315.39 784.44 -317.69 784.44 C-318.08 784.44 -318.08 784.44 -320.06 784.44 C-331.93 784.42 -343.28 783.89 -354.88 781.19 C-355.24 781.11 -355.24 781.11 -357.1 780.72 C-376.29 776.61 -395.44 768.97 -411.88 758.19 C-412.44 757.82 -413.01 757.45 -413.6 757.06 C-422.26 751.33 -429.96 744.83 -437.41 737.6 C-438.74 736.32 -440.09 735.06 -441.45 733.8 C-471.2 705.54 -489.04 662.81 -490.16 621.95 C-491.32 566.83 -469.26 516.41 -431.34 476.72 C-430.86 476.21 -430.37 475.71 -429.88 475.19 C-429.36 474.65 -428.84 474.11 -428.3 473.55 C-421.71 466.72 -415 460.26 -407.48 454.44 C-405.62 452.99 -403.78 451.53 -401.94 450.06 C-393.58 443.48 -385.11 437.52 -375.84 432.3 C-373.76 431.12 -371.7 429.93 -369.64 428.73 C-333.29 407.84 -293.36 396.2 -251.88 391.19 C-251.14 391.1 -250.4 391.01 -249.64 390.92 C-243.13 390.19 -236.72 390.06 -230.18 390.09 C-229.11 390.09 -228.03 390.09 -226.93 390.09 C-223.53 390.1 -220.14 390.11 -216.75 390.12 C-214.43 390.13 -212.12 390.13 -209.8 390.14 C-204.16 390.15 -198.52 390.17 -192.88 390.19 C-192.88 389.63 -192.88 389.63 -192.88 386.8 C-192.92 360.23 -192.96 333.65 -192.98 307.08 C-192.98 303.8 -192.98 300.52 -192.98 297.24 C-192.99 296.91 -192.99 296.91 -192.99 295.26 C-193 284.72 -193.01 274.19 -193.03 263.66 C-193.05 252.82 -193.06 241.99 -193.06 231.16 C-193.07 224.49 -193.07 217.83 -193.09 211.16 C-193.1 206.02 -193.1 200.Line truncated
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.
-111
View File
@@ -1,111 +0,0 @@
Copyright (c) 2009--2019, The Cantarell Authors
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-------------------------------------------------------------------------------
Scripts contain parts of the source code of glyphsLib:
# Copyright 2015 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 1.8 MiB

-62
View File
@@ -1,62 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo4MzkwZjY3Yi1lMWMzLTRkZjItYjc5Ni1kMThmOTcwMzJiOTgAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaHMKM0mGirl6EAsa915f3d8AAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDplYTUzOTY3Ni03MTE4LTQzMGItYTU3Ni02MDk2Zjk0ZmY0NTdscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNomXih8e+70OF/YLHlVgL9LgAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggnypnxLNZxd05KEbslyHWdZn0du7pvfZdyFM3FXgUiimkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaEYNHg3OE8TdlNzzrWNuGkkAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCBmyCV0xk4L/Lqf9X1S9qWNTl5TCz9r70H5YR7X/R91+mRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiaZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjgzOTBmNjdiLWUxYzMtNGRmMi1iNzk2LWQxOGY5NzAzMmI5OC9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOmY4MGM0NTA5LTE1NmEtNDQxYi04ZDU4LTNjMzE5YTRiOWU4ZHJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCfKmfEs1nF3TkoRuyXIdZ1mfR27um99l3IUzcVeBSKKaJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggBkna+FVWA7ttg4e5fN3SGCgCzCQlDA9sXSLaXIILGWiiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggaFremDqsMi+9hC8EXdAqcvUmalJPQpuJro7fci7a9hF0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALine truncated
<defs>
<radialGradient id="bg" cx="50%" cy="42%" r="75%">
<stop offset="0%" stop-color="#12151c"/>
<stop offset="55%" stop-color="#0a0c11"/>
<stop offset="100%" stop-color="#050609"/>
</radialGradient>
<linearGradient id="glow" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6ea3ff"/>
<stop offset="100%" stop-color="#3d7be6"/>
</linearGradient>
<linearGradient id="glass" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.06"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.015"/>
</linearGradient>
<filter id="soft" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="18"/>
</filter>
<filter id="softer" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="45"/>
</filter>
<filter id="markGlow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="14" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- base -->
<rect width="1920" height="1080" fill="url(#bg)"/>
<!-- distant blurred glow behind mark -->
<circle cx="960" cy="540" r="420" fill="url(#glow)" opacity="0.10" filter="url(#softer)"/>
<!-- faint background tiling grid, evoking Ferese's window layout -->
<g opacity="0.35">
<rect x="90" y="90" width="430" height="300" rx="18" fill="url(#glass)" stroke="#ffffff" stroke-opacity="0.05" filter="url(#soft)"/>
<rect x="90" y="420" width="430" height="220" rx="18" fill="url(#glass)" stroke="#ffffff" stroke-opacity="0.05" filter="url(#soft)"/>
<rect x="1400" y="120" width="420" height="230" rx="18" fill="url(#glass)" stroke="#ffffff" stroke-opacity="0.05" filter="url(#soft)"/>
<rect x="1400" y="380" width="420" height="330" rx="18" fill="url(#glass)" stroke="#ffffff" stroke-opacity="0.05" filter="url(#soft)"/>
<rect x="120" y="760" width="360" height="220" rx="18" fill="url(#glass)" stroke="#ffffff" stroke-opacity="0.04" filter="url(#soft)"/>
<rect x="1440" y="800" width="360" height="180" rx="18" fill="url(#glass)" stroke="#ffffff" stroke-opacity="0.04" filter="url(#soft)"/>
</g>
<!-- centered Ferese mark, scaled from the 200x200 source -->
<g transform="translate(960,540) scale(1.9) translate(-100,-100)" filter="url(#markGlow)">
<rect x="20" y="20" width="50" height="160" rx="8" fill="#3d7be6"/>
<rect x="80" y="20" width="100" height="55" rx="8" fill="#3d7be6"/>
<rect x="80" y="95" width="70" height="45" rx="8" fill="#3d7be6"/>
</g>
<!-- subtle vignette -->
<rect width="1920" height="1080" fill="url(#bg)" opacity="0" />
<rect x="0" y="0" width="1920" height="1080" fill="black" opacity="0.12" style="mix-blend-mode:multiply"/>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

+170
View File
@@ -0,0 +1,170 @@
"""Render the matching Ferese wallpapers directly at UHD resolution.
Run with Blender 4.5 or later:
blender --background --python assets/wallpapers/render.py
Pass -- --preview to render 960 x 540 previews instead.
"""
import math
import sys
from pathlib import Path
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parent
PREVIEW = "--preview" in sys.argv
DESTINATION = Path("/tmp/ferese-wallpaper-render") if PREVIEW else ROOT
def material(name, color, metallic=0.0, roughness=0.35):
result = bpy.data.materials.new(name)
result.use_nodes = True
shader = result.node_tree.nodes.get("Principled BSDF")
shader.inputs["Base Color"].default_value = (*color, 1.0)
shader.inputs["Metallic"].default_value = metallic
shader.inputs["Roughness"].default_value = roughness
shader.inputs["Coat Weight"].default_value = 0.3
shader.inputs["Coat Roughness"].default_value = 0.2
return result
def ribbon(name, phase, offset, surface):
vertices, faces = [], []
length_steps, width_steps = 360, 64
for i in range(length_steps):
t = i / length_steps
angle = 2.0 * math.pi * t
x = 2.0 + 5.4 * math.cos(angle)
y = 3.5 * math.sin(angle)
z = 0.8 * math.sin(2.0 * angle) + offset
tangent = Vector((-5.4 * math.sin(angle), 3.5 * math.cos(angle), 0.0))
normal = Vector((-tangent.y, tangent.x, 0.0)).normalized()
width = 1.75 + 0.25 * math.cos(angle + phase)
twist = angle + phase
for j in range(width_steps + 1):
u = 2.0 * j / width_steps - 1.0
distance = width * u * math.cos(twist)
curl = 0.25 * width * u * u
vertices.append(
(
x + normal.x * distance,
y + normal.y * distance,
z + width * u * math.sin(twist) + curl,
)
)
for i in range(length_steps):
for j in range(width_steps):
a = i * (width_steps + 1) + j
b = ((i + 1) % length_steps) * (width_steps + 1) + j
faces.append((a, a + 1, b + 1, b))
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(vertices, [], faces)
mesh.update()
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
obj.data.materials.append(surface)
for polygon in mesh.polygons:
polygon.use_smooth = True
solid = obj.modifiers.new("Satin edge", "SOLIDIFY")
solid.thickness = 0.028
bevel = obj.modifiers.new("Soft edge", "BEVEL")
bevel.width = 0.025
bevel.segments = 3
return obj
def area(name, position, color, power, size, target=(1.0, 0.0, 0.0)):
light = bpy.data.lights.new(name, "AREA")
light.energy = power
light.color = color
light.shape = "RECTANGLE"
light.size = size
light.size_y = size * 2.0
obj = bpy.data.objects.new(name, light)
bpy.context.collection.objects.link(obj)
obj.location = position
obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler()
def render(appearance):
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.samples = 24 if PREVIEW else 64
scene.cycles.use_denoising = True
scene.cycles.adaptive_threshold = 0.03
scene.cycles.max_bounces = 6
scene.render.resolution_x = 960 if PREVIEW else 3840
scene.render.resolution_y = 540 if PREVIEW else 2160
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGB"
scene.render.image_settings.color_depth = "8"
scene.render.film_transparent = False
scene.view_settings.view_transform = "AgX"
scene.view_settings.look = "AgX - Medium High Contrast"
dark = appearance == "dark"
world = bpy.data.worlds.new("Studio")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (
(0.11, 0.16, 0.27, 1.0) if dark else (0.75, 0.82, 0.95, 1.0)
)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.35 if dark else 0.55
scene.world = world
pearl = material("Pearl satin", (0.12, 0.43, 0.66) if dark else (0.56, 0.75, 0.95), 0.45, 0.28)
blue = material("Blue satin", (0.014, 0.085, 0.30) if dark else (0.24, 0.46, 0.74), 0.55, 0.3)
backdrop = bpy.data.materials.new("Quiet gradient")
backdrop.use_nodes = True
nodes, links = backdrop.node_tree.nodes, backdrop.node_tree.links
nodes.clear()
geometry = nodes.new("ShaderNodeNewGeometry")
distance = nodes.new("ShaderNodeVectorMath")
distance.operation = "DISTANCE"
distance.inputs[1].default_value = (5.0, 2.0, -4.0)
links.new(geometry.outputs["Position"], distance.inputs[0])
radius = nodes.new("ShaderNodeMath")
radius.operation = "DIVIDE"
radius.inputs[1].default_value = 18.0
links.new(distance.outputs["Value"], radius.inputs[0])
ramp = nodes.new("ShaderNodeValToRGB")
ramp.color_ramp.interpolation = "EASE"
ramp.color_ramp.elements[0].color = (0.022, 0.052, 0.11, 1.0) if dark else (1.5, 1.7, 2.0, 1.0)
ramp.color_ramp.elements[1].color = (0.002, 0.006, 0.016, 1.0) if dark else (0.9, 1.15, 1.5, 1.0)
links.new(radius.outputs[0], ramp.inputs[0])
emission = nodes.new("ShaderNodeEmission")
links.new(ramp.outputs[0], emission.inputs["Color"])
output = nodes.new("ShaderNodeOutputMaterial")
links.new(emission.outputs[0], output.inputs["Surface"])
fold = ribbon("Satin fold", -0.6, 0.7, pearl)
fold.data.materials.append(blue)
fold.modifiers["Satin edge"].material_offset = 1
bpy.ops.mesh.primitive_plane_add(size=200, location=(0.0, 0.0, -4.0))
bpy.context.object.data.materials.append(backdrop)
area("Broad softbox", (-4.0, 3.0, 12.0), (0.81, 0.93, 1.0), 2400, 8.0)
area("Upper rim", (7.0, 6.0, 8.0), (0.52, 0.82, 1.0), 1900, 6.0)
area("Blue reflection", (1.0, -7.0, 5.0), (0.25, 0.46, 1.0) if dark else (0.75, 0.87, 1.0), 1100, 7.0)
camera = bpy.data.cameras.new("Wallpaper camera")
camera.type = "ORTHO"
camera.ortho_scale = 21.0
obj = bpy.data.objects.new("Wallpaper camera", camera)
bpy.context.collection.objects.link(obj)
obj.location = (-1.5, -7.0, 22.0)
target = Vector((-1.5, 0.0, 0.0))
obj.rotation_euler = (target - obj.location).to_track_quat("-Z", "Y").to_euler()
scene.camera = obj
DESTINATION.mkdir(parents=True, exist_ok=True)
scene.render.filepath = str(DESTINATION / f"ferese-wallpaper-{appearance}.png")
bpy.ops.render.render(write_still=True)
Path(scene.render.filepath).chmod(0o644)
for appearance in ("dark", "light"):
render(appearance)
+17 -1
View File
@@ -5,7 +5,14 @@ edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
schemars.workspace = true
ferese-activities = { path = "../crates/ferese-activities" }
ferese-panel = { path = "../crates/ferese-panel" }
ferese-shape.workspace = true
ferese-config = { path = "../crates/ferese-config" }
ab_glyph = "0.2.32"
fontdb = "0.23.0"
@@ -15,17 +22,26 @@ ferese-core = { path = "../crates/ferese-core" }
ferese-ipc = { path = "../crates/ferese-ipc" }
ferese-layout = { path = "../crates/ferese-layout" }
ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] }
futures-lite = "2.6.1"
async-channel = "2.5.0"
libc.workspace = true
jiff.workspace = true
notify = "8.2.0"
zbus = "5.19.0"
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
memmap2 = "0.9.11"
smithay.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
xcursor.workspace = true
tempfile.workspace = true
[dev-dependencies]
tempfile.workspace = true
[features]
# Opt-in counters/histograms with a per-client exit summary.
resize-metrics = []
# One-shot KMS recovery checks; never enabled by the installer.
drm-fault-injection = []
+894
View File
@@ -0,0 +1,894 @@
//! Session broker: verified process identities, one-shot policy deadlines and
//! bounded long-poll subscribers. Process inspection runs on IPC workers.
pub(crate) mod deadline;
mod pomodoro;
mod publication;
use ferese_activities::{Data, Id, Owner, Source, State, Store, Template, Value};
use ferese_ipc::Response;
use ferese_ipc::activities::{Publish, Snapshot};
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::mpsc::SyncSender;
use std::sync::{Arc, RwLock};
use std::time::Duration;
#[derive(Clone, Debug)]
pub(crate) struct Rules {
pub enabled: bool,
pub matcher: ferese_config::activities::Matcher,
}
pub(crate) type SharedRules = Arc<RwLock<Arc<Rules>>>;
pub(crate) struct Engine {
pub store: Store,
pomodoro: Option<pomodoro::Timer>,
pomodoro_settings: ferese_activities::pomodoro::Settings,
alerts: Option<pomodoro::Alerts>,
pub rules: SharedRules,
pub monitor: Option<crate::ipc::activity_owner::Monitor>,
process_enabled: bool,
process_sender: Option<smithay::reexports::calloop::channel::SyncSender<crate::ipc::IpcEvent>>,
process_watcher: Option<crate::activities_process::Watcher>,
pub timer: Option<deadline::Deadline>,
revision: u64,
cached_snapshot: Option<Arc<Snapshot>>,
publisher: Option<publication::Publisher>,
watchers: BTreeMap<u64, (u64, SyncSender<Response>)>,
locked: bool,
rates: BTreeMap<Owner, (u64, u16)>,
windows: BTreeMap<Id, u64>,
results: BTreeMap<Id, ferese_config::activities::ResultText>,
}
impl Default for Engine {
fn default() -> Self {
let settings = ferese_config::activities::Settings::default();
Self {
pomodoro: None,
pomodoro_settings: Default::default(),
alerts: None,
store: Store::new(
ferese_ipc::activities::now_ms() ^ u64::from(std::process::id()) << 32,
),
rules: Arc::new(RwLock::new(Arc::new(Rules {
enabled: false,
matcher: ferese_config::activities::Matcher::try_new(&settings)
.expect("built-in rules"),
}))),
monitor: None,
process_enabled: false,
process_sender: None,
process_watcher: None,
timer: None,
revision: 1,
cached_snapshot: None,
publisher: publication::Publisher::new()
.map_err(|error| tracing::warn!(%error, "activity snapshot worker unavailable"))
.ok(),
watchers: BTreeMap::new(),
locked: false,
rates: BTreeMap::new(),
results: BTreeMap::new(),
windows: BTreeMap::new(),
}
}
}
impl Engine {
pub fn detect_process(
&mut self,
owner: Owner,
matched: ferese_config::activities::Matched,
output: Option<String>,
fd: &std::os::fd::OwnedFd,
) {
if crate::ipc::activity_owner::exited(fd) {
return;
}
// A dispatched detection is not an admission acknowledgement. Retry
// starts after rejection, and refresh existing records without restarting
// their eligibility delay or recency boost.
if self.resolve(owner, "process", None).is_ok() {
let _ = self.publish(
owner,
Publish::Update {
local: "process".into(),
handle: None,
value: None,
lease_ms: Some(5_000),
},
None,
None,
);
} else {
let _ = self.publish(
owner,
Publish::Hook {
local: "process".into(),
command: String::new(),
cwd: String::new(),
},
Some(matched),
output,
);
}
}
pub fn configure(
&mut self,
settings: &ferese_config::activities::Settings,
matcher: ferese_config::activities::Matcher,
) {
self.store.limits = settings.limits();
self.pomodoro_settings = settings.pomodoro.clone();
self.process_enabled = settings.sources.process_watch;
*self.rules.write().expect("activity rules") = Arc::new(Rules {
enabled: settings.sources.shell_hook,
matcher,
});
self.refresh_process_source();
self.changed();
}
pub fn attach_process_source(
&mut self,
sender: smithay::reexports::calloop::channel::SyncSender<crate::ipc::IpcEvent>,
) {
self.process_sender = Some(sender);
self.refresh_process_source();
}
fn refresh_process_source(&mut self) {
if !self.process_enabled {
self.process_watcher = None;
} else if self.process_watcher.is_none()
&& let Some(sender) = &self.process_sender
{
match crate::activities_process::Watcher::start(self.rules.clone(), sender.clone()) {
Ok(watcher) => self.process_watcher = Some(watcher),
Err(error) => {
tracing::warn!(%error, "could not start optional activity process source")
}
}
}
}
pub fn snapshot(&mut self) -> Arc<Snapshot> {
if let Some(snapshot) = &self.cached_snapshot {
return snapshot.clone();
}
let snapshot = Arc::new(Snapshot {
version: ferese_ipc::activities::VERSION,
revision: self.revision,
now_ms: ferese_ipc::activities::now_ms(),
limits: self.store.limits,
activities: self
.store
.entries()
.cloned()
.chain(self.pomodoro.iter().map(|timer| timer.activity()))
.collect(),
pomodoro: self
.pomodoro
.as_ref()
.map(|timer| ferese_ipc::activities::Pomodoro {
id: timer.id.clone(),
timer: timer.session.clone(),
}),
windows: self
.windows
.iter()
.map(
|(activity, window)| ferese_ipc::activities::ActivityWindow {
activity: activity.clone(),
window: *window,
},
)
.collect(),
});
self.cached_snapshot = Some(snapshot.clone());
snapshot
}
pub fn remove_connection(&mut self, owner: u64) {
self.watchers.remove(&owner);
}
pub fn set_locked(&mut self, locked: bool) {
self.locked = locked;
if locked {
if let Some(publisher) = &self.publisher {
publisher.revoke();
}
publication::locked(std::mem::take(&mut self.watchers).into_values().collect());
}
}
fn deliver(&mut self, recipients: Vec<(u64, SyncSender<Response>)>) {
if self.locked {
publication::locked(recipients);
return;
}
if recipients.is_empty() {
return;
}
let snapshot = self.snapshot();
if let Some(publisher) = &self.publisher {
publisher.send(snapshot, recipients);
} else {
publication::unavailable(recipients);
}
}
pub fn get(&mut self, request: u64, sender: SyncSender<Response>) {
self.deliver(vec![(request, sender)]);
}
pub fn watch(
&mut self,
connection: u64,
request: u64,
since: u64,
sender: SyncSender<Response>,
) {
if self.locked || since != self.revision {
self.get(request, sender);
} else if self.watchers.len() < 32 {
self.watchers.insert(connection, (request, sender));
} else {
let _ = sender.try_send(Response::error(
request,
"subscriber_limit",
"Activity subscriber limit",
));
}
}
pub fn owner_exit(&mut self, owner: Owner) {
self.store.owner_exit(owner);
self.rates.remove(&owner);
self.changed();
}
pub fn changed(&mut self) {
self.revision = self.revision.saturating_add(1);
self.cached_snapshot = None;
self.results
.retain(|id, _| self.store.entries().any(|entry| &entry.id == id));
self.windows
.retain(|id, _| self.store.entries().any(|entry| &entry.id == id));
let recipients = std::mem::take(&mut self.watchers).into_values().collect();
self.deliver(recipients);
self.schedule();
}
pub fn tick(&mut self) {
let now = ferese_ipc::activities::now_ms();
self.store.advance(now);
self.advance_pomodoro(now);
self.changed();
}
pub fn schedule(&self) {
if let Some(monitor) = &self.monitor {
monitor.retain(
&self
.store
.entries()
.filter(|entry| entry.state == State::Running)
.map(|entry| entry.id.owner)
.collect(),
);
}
if let Some(timer) = &self.timer {
let now = ferese_ipc::activities::now_ms();
if let Err(error) = timer.update(
self.store
.next_deadline(now)
.into_iter()
.chain(
self.pomodoro
.as_ref()
.and_then(|timer| timer.session.deadline),
)
.min(),
) {
tracing::error!(%error, "Activity deadline could not be armed");
}
}
}
pub fn publish_verified(
&mut self,
verified: crate::ipc::activity_owner::VerifiedOwner,
publish: Publish,
matched: Option<ferese_config::activities::Matched>,
output: Option<String>,
window: Option<u64>,
) -> Result<serde_json::Value, String> {
// Admission and publication run in one compositor dispatch. A queued
// request owns its pidfd, but is not yet subject to monitor pruning.
let owner = self
.monitor
.as_ref()
.ok_or("Activity owner monitor unavailable")?
.register(verified)
.map_err(|error| error.to_string())?;
let result = self
.publish_from(owner, publish, matched, output, window)
.map_err(str::to_owned);
self.schedule();
result
}
pub fn publish(
&mut self,
owner: Owner,
publish: Publish,
matched: Option<ferese_config::activities::Matched>,
output: Option<String>,
) -> Result<serde_json::Value, &'static str> {
self.publish_from(owner, publish, matched, output, None)
}
fn publish_from(
&mut self,
owner: Owner,
publish: Publish,
matched: Option<ferese_config::activities::Matched>,
output: Option<String>,
window: Option<u64>,
) -> Result<serde_json::Value, &'static str> {
let started = match &publish {
Publish::Start { local, .. }
| Publish::Run { local, .. }
| Publish::Hook { local, .. } => Some(local.clone()),
_ => None,
};
let now = ferese_ipc::activities::now_ms();
if matches!(publish, Publish::Hook { .. }) && matched.is_none() {
return Ok(json!({"matched":false}));
}
self.rates
.retain(|_, (at, _)| now.saturating_sub(*at) < 60_000);
if self.rates.len() >= 128 && !self.rates.contains_key(&owner) {
return Err("owner publication quota exceeded");
}
let rate = self.rates.entry(owner).or_insert((now, 0));
if now.saturating_sub(rate.0) >= 1_000 {
*rate = (now, 0);
}
// Each connection waits for acknowledgement; excess replaceable updates
// are explicitly rejected so terminal events retain capacity.
if (matches!(publish, Publish::Update { .. }) && rate.1 >= 30)
|| (matches!(
publish,
Publish::Start { .. } | Publish::Hook { .. } | Publish::Run { .. }
) && rate.1 >= 10)
{
return Err("publication rate exceeded");
}
rate.1 = rate.1.saturating_add(1);
// Expiration is a change even when the following publication is rejected.
// Notify subscribers before a store mutation can silently sweep leases.
if self.store.advance(now) {
self.changed();
}
let result = match publish {
Publish::Start {
local,
mut data,
lease_ms,
} => {
if data.output.is_none() {
data.output = output;
}
let id = self
.store
.start(owner, local, data, now, 0, lease_ms, true, true, None)?;
json!({"handle":id})
}
Publish::Run { local, title, .. } => {
if let Some(matched) = matched {
let data = Data {
source: if local == "process" {
Source::Process
} else {
Source::Command
},
output,
priority: matched.rule.priority,
icon: matched.rule.icon.clone(),
title: title.unwrap_or(matched.title),
value: Value::Indeterminate,
template: Template::Progress,
};
let id = self.store.start(
owner,
local,
data,
now,
matched.rule.min_duration_ms,
None,
true,
matched.rule.flash_on_fail,
matched.group,
)?;
self.results.insert(id.clone(), matched.rule.result);
json!({"handle":id,"progress":matched.rule.progress})
} else {
let data = Data {
source: Source::Command,
output,
priority: 100,
icon: "ferese".into(),
title: title.unwrap_or_else(|| "Command".into()),
value: Value::Indeterminate,
template: Template::Progress,
};
let id = self
.store
.start(owner, local, data, now, 0, None, true, true, None)?;
json!({"handle":id,"progress":"indeterminate"})
}
}
Publish::Hook { local, .. } => {
let Some(matched) = matched else {
return Ok(json!({"matched":false}));
};
let data = Data {
source: if local == "process" {
Source::Process
} else {
Source::Command
},
output,
priority: matched.rule.priority,
icon: matched.rule.icon.clone(),
title: matched.title,
value: Value::Indeterminate,
template: Template::Progress,
};
let lease = (local == "process").then_some(5_000);
let id = self.store.start(
owner,
local,
data,
now,
matched.rule.min_duration_ms,
lease,
true,
matched.rule.flash_on_fail,
matched.group,
)?;
self.results.insert(id.clone(), matched.rule.result);
json!({"matched":true,"handle":id,"progress":matched.rule.progress})
}
Publish::Update {
local,
handle,
value,
lease_ms,
} => {
let id = self.resolve(owner, &local, handle)?;
self.store.update(owner, &id, value, lease_ms, now)?;
json!({})
}
Publish::End {
local,
handle,
result,
message,
} => {
let id = self.resolve(owner, &local, handle)?;
let message = message.or_else(|| {
self.results.get(&id).map(|text| {
if result == State::Done {
text.ok.clone()
} else {
text.fail.clone()
}
})
});
self.store.finish(owner, &id, result, message, now)?;
json!({})
}
Publish::Cancel { local, handle } => {
let id = self.resolve(owner, &local, handle)?;
self.store.finish(owner, &id, State::Cancelled, None, now)?;
json!({})
}
};
if let Some(local) = started
&& let Some(id) = self.store.resolve(owner, &local)
&& let Some(window) = window
{
self.windows.insert(id.clone(), window);
}
self.changed();
Ok(result)
}
fn resolve(&self, owner: Owner, local: &str, handle: Option<Id>) -> Result<Id, &'static str> {
match handle {
Some(id) if id.local == local => Ok(id),
Some(_) => Err("handle does not match local ID"),
None => self
.store
.resolve(owner, local)
.cloned()
.ok_or("unknown owner-scoped activity"),
}
}
}
impl crate::Ferese {
pub(crate) fn activity_window(
&self,
owner: &crate::ipc::activity_owner::VerifiedOwner,
) -> Option<u64> {
use smithay::reexports::wayland_server::Resource;
let mut ancestors = owner.ancestor_pids().peekable();
ancestors.peek()?;
let windows: Vec<_> = self
.windows
.records()
.filter_map(|(id, record)| {
let client = record.surface.as_ref()?.client()?;
let pid =
u32::try_from(client.get_credentials(&self.display_handle).ok()?.pid).ok()?;
Some((pid, id.0))
})
.collect();
origin_window(ancestors, &windows)
}
}
fn origin_window(ancestors: impl Iterator<Item = u32>, windows: &[(u32, u64)]) -> Option<u64> {
for pid in ancestors {
let mut matches = windows.iter().filter(|(client, _)| *client == pid);
if let Some((_, window)) = matches.next() {
// A terminal server may own several windows. Never guess from focus.
return matches.next().is_none().then_some(*window);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn owner() -> Owner {
Owner { pid: 12, birth: 3 }
}
fn data() -> Data {
Data {
source: Source::Publisher,
output: None,
priority: 255,
icon: "hammer".into(),
title: "Build".into(),
value: Value::Indeterminate,
template: Template::Progress,
}
}
#[test]
fn session_lock_cancels_pending_and_rejects_new_activity_reads() {
let mut engine = Engine::default();
let revision = engine.snapshot().revision;
let (send, receive) = std::sync::mpsc::sync_channel(1);
engine.watch(1, 42, revision, send);
engine.set_locked(true);
let reply = receive.recv().unwrap();
assert_eq!(reply.id, 42);
assert_eq!(reply.error.unwrap().code, "session_locked");
assert!(reply.result.is_none());
engine.changed();
for since in [revision, engine.revision] {
let (send, receive) = std::sync::mpsc::sync_channel(1);
engine.watch(2, 43, since, send);
assert_eq!(
receive.recv().unwrap().error.unwrap().code,
"session_locked"
);
}
let (send, receive) = std::sync::mpsc::sync_channel(1);
engine.get(44, send);
assert_eq!(
receive.recv().unwrap().error.unwrap().code,
"session_locked"
);
engine.set_locked(false);
let (send, receive) = std::sync::mpsc::sync_channel(1);
engine.watch(3, 45, revision, send);
assert!(
receive
.recv_timeout(Duration::from_secs(2))
.unwrap()
.result
.is_some()
);
}
#[test]
fn rejected_updates_do_not_leave_a_snapshot_from_before_lease_expiry() {
let mut engine = Engine::default();
let id = engine
.store
.start(
owner(),
"expired".into(),
data(),
ferese_ipc::activities::now_ms().saturating_sub(5_000),
0,
Some(1_000),
true,
true,
None,
)
.unwrap();
let before = engine.snapshot();
assert_eq!(before.activities.len(), 1);
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
engine.watch(1, 11, before.revision, sender);
assert!(
engine
.publish(
owner(),
Publish::Update {
local: "expired".into(),
handle: Some(id),
value: None,
lease_ms: None,
},
None,
None
)
.is_err()
);
assert!(engine.snapshot().activities.is_empty());
let response = receiver.recv_timeout(Duration::from_secs(2)).unwrap();
let snapshot: Snapshot = serde_json::from_value(response.result.unwrap()).unwrap();
assert!(snapshot.activities.is_empty());
assert!(snapshot.revision > before.revision);
assert_eq!(before.activities.len(), 1);
}
#[test]
fn snapshots_are_lazy_shared_and_immutable_across_publications() {
let mut engine = Engine::default();
engine.changed();
assert!(
engine.cached_snapshot.is_none(),
"no subscriber needs a snapshot"
);
let before = engine.snapshot();
assert!(Arc::ptr_eq(&before, &engine.snapshot()));
let (first, first_reply) = std::sync::mpsc::sync_channel(1);
let (second, second_reply) = std::sync::mpsc::sync_channel(1);
engine.watch(1, 11, before.revision, first);
engine.watch(2, 22, before.revision, second);
engine
.publish(
owner(),
Publish::Start {
local: "build".into(),
data: data(),
lease_ms: None,
},
None,
None,
)
.unwrap();
let after = engine.snapshot();
assert!(!Arc::ptr_eq(&before, &after));
assert!(before.activities.is_empty());
assert_eq!(after.activities.len(), 1);
for (id, reply) in [(11, first_reply), (22, second_reply)] {
let reply = reply.recv_timeout(Duration::from_secs(2)).unwrap();
assert_eq!(reply.id, id);
let snapshot: Snapshot = serde_json::from_value(reply.result.unwrap()).unwrap();
assert_eq!(snapshot.revision, after.revision);
assert_eq!(snapshot.activities[0].id, after.activities[0].id);
}
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
engine.watch(3, 33, before.revision, sender);
assert_eq!(
receiver.recv_timeout(Duration::from_secs(2)).unwrap().id,
33
);
assert!(Arc::ptr_eq(&after, &engine.snapshot()));
}
#[test]
fn process_that_exits_before_detection_dispatch_is_not_published() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.unwrap();
let (owner, fd, _) = crate::ipc::activity_owner::inspect(child.id()).unwrap();
let matched = ferese_config::activities::Matcher::try_new(&Default::default())
.unwrap()
.matches("cargo test", "/tmp", &[])
.unwrap();
child.kill().unwrap();
child.wait().unwrap();
let mut engine = Engine::default();
engine.detect_process(owner, matched, None, &fd);
assert_eq!(engine.store.entries().count(), 0);
}
#[test]
fn rejected_process_detection_is_retried_without_restarting_admitted_records() {
let mut engine = Engine::default();
let (owner, fd, _) = crate::ipc::activity_owner::inspect(std::process::id()).unwrap();
let settings = ferese_config::activities::Settings::default();
let matched = ferese_config::activities::Matcher::try_new(&settings)
.unwrap()
.matches("cargo test", "/tmp/test", &[])
.unwrap();
engine.store.limits.max_total = 0;
engine.detect_process(owner, matched.clone(), None, &fd);
assert_eq!(engine.store.entries().count(), 0);
engine.store.limits.max_total = 128;
engine.detect_process(owner, matched.clone(), None, &fd);
let admitted = engine.store.entries().next().unwrap().clone();
engine.detect_process(owner, matched, None, &fd);
let refreshed = engine.store.entries().next().unwrap();
assert_eq!(refreshed.id, admitted.id);
assert_eq!(refreshed.eligible_at, admitted.eligible_at);
assert_eq!(engine.store.entries().count(), 1);
}
#[test]
fn terminal_matching_uses_nearest_unique_ancestor_and_never_guesses() {
let ancestry = [400, 300, 200, 100];
assert_eq!(
origin_window(ancestry.into_iter(), &[(200, 7), (100, 8)]),
Some(7)
);
assert_eq!(
origin_window(ancestry.into_iter(), &[(200, 7), (200, 9), (100, 8)]),
None
);
assert_eq!(origin_window(ancestry.into_iter(), &[(900, 7)]), None);
assert_eq!(origin_window([].into_iter(), &[(200, 7)]), None);
}
#[test]
fn activity_window_survives_updates_and_results_then_expires() {
let mut engine = Engine::default();
engine.store.limits.start_delay_ms = 0;
engine
.publish_from(
owner(),
Publish::Run {
local: "build".into(),
title: Some("Build".into()),
command: String::new(),
cwd: String::new(),
},
None,
None,
Some(42),
)
.unwrap();
let id = engine.store.entries().next().unwrap().id.clone();
assert_eq!(engine.snapshot().windows[0].window, 42);
engine
.publish(
owner(),
Publish::Update {
local: "build".into(),
handle: Some(id.clone()),
value: Some(Value::Progress(0.5)),
lease_ms: None,
},
None,
None,
)
.unwrap();
assert_eq!(engine.snapshot().windows[0].window, 42);
engine
.publish(
owner(),
Publish::End {
local: "build".into(),
handle: Some(id.clone()),
result: State::Done,
message: None,
},
None,
None,
)
.unwrap();
engine.owner_exit(owner());
assert_eq!(engine.snapshot().windows[0].activity, id);
engine
.store
.advance(ferese_ipc::activities::now_ms() + 2_000);
engine.changed();
assert!(engine.snapshot().windows.is_empty());
}
#[test]
fn one_shot_disconnect_does_not_own_lifetime_and_rate_limit_preserves_completion() {
let mut engine = Engine::default();
let result = engine
.publish(
owner(),
Publish::Start {
local: "build".into(),
data: data(),
lease_ms: None,
},
None,
Some("first".into()),
)
.unwrap();
let id: Id = serde_json::from_value(result["handle"].clone()).unwrap();
engine.remove_connection(1);
assert_eq!(engine.store.entries().count(), 1);
for _ in 0..40 {
let _ = engine.publish(
owner(),
Publish::Update {
local: "build".into(),
handle: Some(id.clone()),
value: Some(Value::Progress(0.5)),
lease_ms: None,
},
None,
Some("other".into()),
);
}
assert_eq!(
engine
.store
.entries()
.next()
.unwrap()
.data
.output
.as_deref(),
Some("first")
);
engine
.publish(
owner(),
Publish::End {
local: "build".into(),
handle: Some(id),
result: State::Done,
message: None,
},
None,
None,
)
.unwrap();
assert_eq!(
engine.store.entries().count(),
0,
"completion before delay never flashes"
);
}
#[test]
fn rule_is_pinned_across_atomic_reload_and_no_raw_metadata_is_published() {
let settings = ferese_config::activities::Settings::default();
let matched = ferese_config::activities::Matcher::try_new(&settings)
.unwrap()
.matches("cargo test --secret", "/secret/cwd", &[])
.unwrap();
let mut engine = Engine::default();
engine
.publish(
owner(),
Publish::Run {
local: "build".into(),
title: None,
command: String::new(),
cwd: String::new(),
},
Some(matched),
Some("first".into()),
)
.unwrap();
let before = engine.store.entries().next().unwrap().clone();
let mut replacement = settings.clone();
replacement.start_delay_ms = 5_000;
replacement.rules[0].title = "Replacement".into();
engine.configure(
&replacement,
ferese_config::activities::Matcher::try_new(&replacement).unwrap(),
);
let after = engine.store.entries().next().unwrap();
assert_eq!(after.data.title, "cargo test");
assert_eq!(after.eligible_at, before.eligible_at);
let serialized = serde_json::to_string(engine.snapshot().as_ref()).unwrap();
assert!(!serialized.contains("secret"));
}
}
+127
View File
@@ -0,0 +1,127 @@
//! Activity deadlines share CLOCK_BOOTTIME with the published countdowns. A
//! monotonic calloop timer would otherwise keep waiting for time spent asleep.
use smithay::reexports::calloop::generic::Generic;
use smithay::reexports::calloop::{Interest, LoopHandle, Mode, PostAction, RegistrationToken};
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
pub(crate) struct Deadline {
fd: OwnedFd,
armed: std::cell::Cell<Option<u64>>,
handle: LoopHandle<'static, crate::Ferese>,
token: RegistrationToken,
}
impl Deadline {
pub fn new(
handle: LoopHandle<'static, crate::Ferese>,
) -> Result<Self, Box<dyn std::error::Error>> {
// SAFETY: timerfd_create returns a new owned descriptor or -1.
let fd = unsafe {
libc::timerfd_create(libc::CLOCK_BOOTTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK)
};
if fd < 0 {
return Err(io::Error::last_os_error().into());
}
// SAFETY: timerfd_create succeeded above; this fresh descriptor transfers to one OwnedFd.
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
let token = handle.insert_source(
Generic::new(fd.try_clone()?, Interest::READ, Mode::Level),
|_, fd, state| {
let mut expirations = 0_u64;
// SAFETY: expirations is a writable eight-byte timerfd counter.
let result =
unsafe { libc::read(fd.as_raw_fd(), (&raw mut expirations).cast(), 8) };
if result < 0 {
let error = io::Error::last_os_error();
if error.kind() != io::ErrorKind::WouldBlock {
return Err(error);
}
} else {
state.activities.tick();
}
Ok(PostAction::Continue)
},
)?;
Ok(Self {
fd,
armed: std::cell::Cell::new(None),
handle,
token,
})
}
pub fn update(&self, deadline: Option<u64>) -> io::Result<()> {
if self.armed.get() == deadline {
return Ok(());
}
let ms = deadline.unwrap_or(0);
let spec = libc::itimerspec {
it_interval: libc::timespec {
tv_sec: 0,
tv_nsec: 0,
},
it_value: libc::timespec {
tv_sec: (ms / 1_000) as _,
tv_nsec: ((ms % 1_000) * 1_000_000) as _,
},
};
// SAFETY: spec is initialized; null old_value is permitted.
if unsafe {
libc::timerfd_settime(
self.fd.as_raw_fd(),
libc::TFD_TIMER_ABSTIME,
&spec,
std::ptr::null_mut(),
)
} < 0
{
Err(io::Error::last_os_error())
} else {
self.armed.set(deadline);
Ok(())
}
}
}
impl Drop for Deadline {
fn drop(&mut self) {
self.handle.remove(self.token);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deadline_uses_boottime_and_disarming_leaves_no_periodic_wakeups() {
let events = smithay::reexports::calloop::EventLoop::<crate::Ferese>::try_new().unwrap();
let deadline = Deadline::new(events.handle()).unwrap();
let info =
std::fs::read_to_string(format!("/proc/self/fdinfo/{}", deadline.fd.as_raw_fd()))
.unwrap();
assert!(
info.lines()
.any(|line| line.split_whitespace().collect::<Vec<_>>() == ["clockid:", "7"]),
"{info}"
);
deadline
.update(Some(ferese_ipc::activities::now_ms() + 20))
.unwrap();
let mut pollfd = libc::pollfd {
fd: deadline.fd.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
// SAFETY: pollfd is a valid array of one descriptor.
assert_eq!(unsafe { libc::poll(&raw mut pollfd, 1, 500) }, 1);
let mut counter = 0_u64;
assert_eq!(
// SAFETY: counter is writable for the requested eight bytes and deadline owns the
// timer descriptor.
unsafe { libc::read(deadline.fd.as_raw_fd(), (&raw mut counter).cast(), 8) },
8
);
assert_eq!(counter, 1);
deadline.update(None).unwrap();
// SAFETY: pollfd is a live writable pollfd and the entry count is one.
assert_eq!(unsafe { libc::poll(&raw mut pollfd, 1, 0) }, 0);
}
}
+406
View File
@@ -0,0 +1,406 @@
use super::{Data, Duration, Engine, Id, Source, State, Template, Value};
#[cfg(test)]
use ferese_activities::Owner;
use ferese_activities::pomodoro::{Session, Status};
use ferese_ipc::activities::{PomodoroAction, PomodoroRequest};
pub(super) struct Timer {
pub id: Id,
pub session: Session,
output: Option<String>,
created_at: u64,
}
impl Timer {
pub fn activity(&self) -> ferese_activities::Activity {
let now = ferese_ipc::activities::now_ms();
let value = if let Some(deadline) = self.session.deadline {
Value::Countdown {
remaining_ms: deadline.saturating_sub(now),
sampled_at: now,
}
} else {
Value::PausedCountdown {
remaining_ms: self.session.remaining_ms,
}
};
ferese_activities::Activity {
id: self.id.clone(),
data: Data {
source: Source::Timer,
output: self.output.clone(),
priority: 100,
icon: "timer".into(),
title: format!(
"Pomodoro · {}{}",
self.session.phase.label(),
match self.session.status {
Status::Paused => " · Paused",
Status::Ready => " · Ready",
Status::Finished => " · Finished",
_ => "",
}
),
value,
template: Template::Timer,
},
state: State::Running,
created_at: self.created_at,
eligible_at: self.created_at,
refreshed_at: self.created_at,
lease_deadline: None,
result_deadline: None,
result_message: None,
flash_on_fail: false,
result_flash_ms: 0,
group: None,
}
}
}
impl Engine {
pub fn pomodoro_control(
&mut self,
request: PomodoroRequest,
output: Option<String>,
) -> Result<serde_json::Value, String> {
if request.version != ferese_ipc::activities::VERSION {
return Err("Unsupported activity schema".into());
}
if let Some(id) = &request.session
&& self.pomodoro.as_ref().is_none_or(|timer| &timer.id != id)
{
return Err("Pomodoro session changed; refresh its controls".into());
}
let now = ferese_ipc::activities::now_ms();
if !matches!(request.action, PomodoroAction::Stop | PomodoroAction::Reset)
&& self.advance_pomodoro(now)
{
self.changed();
}
match request.action {
PomodoroAction::Status => {}
PomodoroAction::Start | PomodoroAction::Reset => {
if request.action == PomodoroAction::Start
&& self
.pomodoro
.as_ref()
.is_some_and(|timer| timer.session.status != Status::Finished)
{
return Err("A Pomodoro session already exists; resume or reset it".into());
}
let session = Session::start(self.pomodoro_settings.clone(), now)?;
// Verify the long-lived host, never the short-lived CLI, as owner.
let (owner, _pidfd, _) = crate::ipc::activity_owner::inspect(std::process::id())
.map_err(|error| error.to_string())?;
let id = Id {
owner,
local: "pomodoro".into(),
generation: self.revision,
session: self.store.session(),
};
self.pomodoro = Some(Timer {
id,
session,
output,
created_at: now,
});
}
PomodoroAction::Stop => {
self.pomodoro = None;
}
action => {
let timer = self
.pomodoro
.as_mut()
.ok_or("No Pomodoro session; start one first")?;
match action {
PomodoroAction::Pause => timer.session.pause(now)?,
PomodoroAction::Resume => timer.session.resume(now)?,
PomodoroAction::Next => timer.session.next(now)?,
_ => unreachable!(),
}
}
}
if request.action != PomodoroAction::Status {
self.changed();
}
let snapshot = self.snapshot();
serde_json::to_value(&snapshot.pomodoro).map_err(|error| error.to_string())
}
pub(super) fn advance_pomodoro(&mut self, now: u64) -> bool {
let Some(timer) = &mut self.pomodoro else {
return false;
};
let Some(completion) = timer.session.advance(now) else {
return false;
};
if timer.session.settings.notifications {
let title = format!("Pomodoro: {} complete", completion.phase.label());
let body = completion.next.map_or_else(
|| "Your Pomodoro session is finished.".into(),
|phase| {
format!(
"{} {}.",
phase.label(),
if timer.session.status == Status::Running {
"has started"
} else {
"is ready"
}
)
},
);
let alerts = self.alerts.get_or_insert_with(Alerts::new);
if alerts.sender.try_send((title, body)).is_err() {
tracing::warn!("Pomodoro alert queue unavailable");
}
}
true
}
pub fn pomodoro_remove_output(&mut self, output: &str, fallback: Option<&str>) {
if let Some(timer) = &mut self.pomodoro
&& timer.output.as_deref() == Some(output)
{
timer.output = fallback.map(str::to_owned);
}
}
pub fn pomodoro_attach_output(&mut self, output: &str) -> bool {
if let Some(timer) = &mut self.pomodoro
&& timer.output.is_none()
{
timer.output = Some(output.into());
return true;
}
false
}
}
/// Notification delivery runs off the compositor thread and is attempted once.
/// Reconnection never replays an alert; the notification server applies DND and
/// lock-screen privacy, just as it does for other applications.
pub(super) struct Alerts {
sender: std::sync::mpsc::SyncSender<(String, String)>,
}
impl Alerts {
fn new() -> Self {
let (sender, receiver) = std::sync::mpsc::sync_channel::<(String, String)>(16);
std::thread::spawn(move || {
while let Ok((title, body)) = receiver.recv() {
let result = (|| -> zbus::Result<()> {
let connection = zbus::blocking::connection::Builder::session()?
.method_timeout(Duration::from_secs(2))
.build()?;
let mut hints =
std::collections::HashMap::<&str, zbus::zvariant::Value<'_>>::new();
hints.insert("suppress-sound", zbus::zvariant::Value::Bool(true));
let reply = connection.call_method(
Some("org.freedesktop.Notifications"),
"/org/freedesktop/Notifications",
Some("org.freedesktop.Notifications"),
"Notify",
&(
"Ferese",
0_u32,
"",
title,
body,
Vec::<String>::new(),
hints,
-1_i32,
),
)?;
let _: u32 = reply.body().deserialize()?;
Ok(())
})();
if let Err(error) = result {
tracing::warn!(%error,"Pomodoro completion alert could not be delivered");
}
}
});
Self { sender }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn control(engine: &mut Engine, action: PomodoroAction) -> Result<serde_json::Value, String> {
engine.pomodoro_control(
PomodoroRequest {
version: ferese_ipc::activities::VERSION,
action,
session: None,
},
Some("test".into()),
)
}
#[test]
fn completion_uses_the_standard_notification_service_without_sound() {
const TEST: &str = "activities::pomodoro::tests::completion_uses_the_standard_notification_service_without_sound";
if !crate::startup_tests::private_runtime(TEST) {
return;
}
use std::io::BufRead;
struct Bus(std::process::Child);
impl Drop for Bus {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
let mut bus = Bus(std::process::Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap());
let mut address = String::new();
std::io::BufReader::new(bus.0.stdout.take().unwrap())
.read_line(&mut address)
.unwrap();
// SAFETY: private_runtime runs only this test in a separate process;
// set the bus before starting the notification service or alert worker.
unsafe {
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", address.trim());
}
struct Notifications(std::sync::mpsc::Sender<(String, String, bool)>);
#[zbus::interface(name = "org.freedesktop.Notifications")]
impl Notifications {
#[allow(clippy::too_many_arguments)]
fn notify(
&self,
_app_name: &str,
_replaces_id: u32,
_app_icon: &str,
summary: &str,
body: &str,
_actions: Vec<String>,
hints: std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
_expire_timeout: i32,
) -> u32 {
let silent = hints
.get("suppress-sound")
.and_then(|value| bool::try_from(value).ok())
.unwrap_or(false);
self.0.send((summary.into(), body.into(), silent)).unwrap();
1
}
}
let (sender, receiver) = std::sync::mpsc::channel();
let _service = zbus::blocking::connection::Builder::address(address.trim())
.unwrap()
.serve_at("/org/freedesktop/Notifications", Notifications(sender))
.unwrap()
.name("org.freedesktop.Notifications")
.unwrap()
.build()
.unwrap();
let mut engine = Engine::default();
control(&mut engine, PomodoroAction::Start).unwrap();
let deadline = engine.pomodoro.as_ref().unwrap().session.deadline.unwrap();
engine.advance_pomodoro(deadline);
let (title, body, silent) = receiver.recv_timeout(Duration::from_secs(3)).unwrap();
assert_eq!(title, "Pomodoro: Work complete");
assert_eq!(body, "Short break is ready.");
assert!(silent);
engine.advance_pomodoro(deadline + 1);
assert!(receiver.try_recv().is_err());
}
#[test]
fn host_owns_the_single_session_and_stale_popup_controls_are_rejected() {
let mut engine = Engine::default();
let first = control(&mut engine, PomodoroAction::Start).unwrap();
let first: ferese_ipc::activities::Pomodoro = serde_json::from_value(first).unwrap();
assert_eq!(first.id.owner.pid, std::process::id());
assert!(control(&mut engine, PomodoroAction::Start).is_err());
engine.remove_connection(10);
engine.owner_exit(Owner {
pid: 999_999,
birth: 0,
});
assert_eq!(engine.snapshot().pomodoro.as_ref().unwrap().id, first.id);
assert_eq!(engine.snapshot().activities.len(), 1);
control(&mut engine, PomodoroAction::Reset).unwrap();
assert_ne!(engine.snapshot().pomodoro.as_ref().unwrap().id, first.id);
assert!(
engine
.pomodoro_control(
PomodoroRequest {
version: ferese_ipc::activities::VERSION,
action: PomodoroAction::Stop,
session: Some(first.id)
},
None
)
.is_err()
);
control(&mut engine, PomodoroAction::Stop).unwrap();
assert!(engine.snapshot().pomodoro.is_none());
assert!(engine.snapshot().activities.is_empty());
assert!(Engine::default().snapshot().pomodoro.is_none());
}
#[test]
fn completions_are_delivered_once_and_cancellation_does_not_alert() {
let mut engine = Engine::default();
let (sender, receiver) = std::sync::mpsc::sync_channel(16);
engine.alerts = Some(Alerts { sender });
control(&mut engine, PomodoroAction::Start).unwrap();
let deadline = engine.pomodoro.as_ref().unwrap().session.deadline.unwrap();
assert!(engine.advance_pomodoro(deadline));
assert!(!engine.advance_pomodoro(deadline + 1));
let (title, body) = receiver.try_recv().unwrap();
assert_eq!(title, "Pomodoro: Work complete");
assert_eq!(body, "Short break is ready.");
assert!(receiver.try_recv().is_err());
control(&mut engine, PomodoroAction::Reset).unwrap();
engine.pomodoro.as_mut().unwrap().session.deadline = Some(1);
control(&mut engine, PomodoroAction::Stop).unwrap();
assert!(receiver.try_recv().is_err());
control(&mut engine, PomodoroAction::Start).unwrap();
engine.pomodoro.as_mut().unwrap().session.deadline = Some(1);
control(&mut engine, PomodoroAction::Reset).unwrap();
assert!(receiver.try_recv().is_err());
}
#[test]
fn paused_snapshot_is_constant_and_output_loss_routes_the_timer() {
let mut engine = Engine::default();
control(&mut engine, PomodoroAction::Start).unwrap();
control(&mut engine, PomodoroAction::Pause).unwrap();
let snapshot = engine.snapshot();
let activity = &snapshot.activities[0];
assert!(activity.data.title.contains("Paused"));
assert_eq!(
activity.data.value.remaining_ms(0),
activity.data.value.remaining_ms(u64::MAX)
);
engine.pomodoro_remove_output("test", None);
assert!(engine.pomodoro_attach_output("new"));
engine.changed();
assert_eq!(
engine.snapshot().activities[0].data.output.as_deref(),
Some("new")
);
}
#[test]
fn disabled_alerts_and_schema_errors_have_no_side_effects() {
let mut engine = Engine::default();
engine.pomodoro_settings.notifications = false;
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
engine.alerts = Some(Alerts { sender });
control(&mut engine, PomodoroAction::Start).unwrap();
assert!(
engine
.pomodoro_control(
PomodoroRequest {
version: 99,
action: PomodoroAction::Stop,
session: None
},
None
)
.is_err()
);
let deadline = engine.pomodoro.as_ref().unwrap().session.deadline.unwrap();
engine.advance_pomodoro(deadline);
assert!(receiver.try_recv().is_err());
}
}
+141
View File
@@ -0,0 +1,141 @@
//! Bounded serialization and delivery outside the compositor event loop.
use ferese_ipc::Response;
use ferese_ipc::activities::Snapshot;
use std::sync::mpsc::{self, SyncSender};
use std::sync::{Arc, RwLock};
type Recipient = (u64, SyncSender<Response>);
struct Job {
epoch: u64,
snapshot: Arc<Snapshot>,
recipients: Vec<Recipient>,
}
pub(super) struct Publisher(SyncSender<Job>, Arc<RwLock<u64>>);
impl Publisher {
pub fn new() -> std::io::Result<Self> {
let (send, receive) = mpsc::sync_channel::<Job>(8);
let epoch = Arc::new(RwLock::new(0));
let worker_epoch = epoch.clone();
std::thread::Builder::new()
.name("ferese-activity-snapshots".into())
.spawn(move || {
let mut cached: Option<(Arc<Snapshot>, serde_json::Value)> = None;
while let Ok(job) = receive.recv() {
if !cached
.as_ref()
.is_some_and(|(snapshot, _)| Arc::ptr_eq(snapshot, &job.snapshot))
{
cached = Some((
job.snapshot.clone(),
serde_json::json!(job.snapshot.as_ref()),
));
}
let value = &cached
.as_ref()
.expect("activity serialization populated the cache for this revision")
.1;
// Lock acquisition revokes queued/in-flight serialization.
// Hold the guard through delivery to order sends before revocation.
deliver(job.epoch, &worker_epoch, job.recipients, value);
}
})?;
Ok(Self(send, epoch))
}
pub fn revoke(&self) {
let mut epoch = self.1.write().expect("activity publication epoch");
*epoch = epoch.wrapping_add(1);
}
pub fn send(&self, snapshot: Arc<Snapshot>, recipients: Vec<Recipient>) {
let epoch = *self.1.read().expect("activity publication epoch");
if let Err(error) = self.0.try_send(Job {
epoch,
snapshot,
recipients,
}) {
let (mpsc::TrySendError::Full(job) | mpsc::TrySendError::Disconnected(job)) = error;
unavailable(job.recipients);
}
}
}
fn deliver(
epoch: u64,
current: &RwLock<u64>,
recipients: Vec<Recipient>,
value: &serde_json::Value,
) {
let current = current.read().expect("activity publication epoch");
if epoch != *current {
locked(recipients);
return;
}
for (id, sender) in recipients {
// A slow/disconnected client cannot block another subscriber.
let _ = sender.try_send(Response::success(id, value.clone()));
}
}
pub(super) fn locked(recipients: Vec<Recipient>) {
for (id, sender) in recipients {
let _ = sender.try_send(Response::error(id, "session_locked", "Session is locked"));
}
}
pub(super) fn unavailable(recipients: Vec<Recipient>) {
for (id, sender) in recipients {
let _ = sender.try_send(Response::error(
id,
"activity_busy",
"Activity snapshot delivery unavailable; retry",
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn lock_revokes_serialized_jobs_even_after_unlock() {
let (send, _receive) = mpsc::sync_channel(1);
let publisher = Publisher(send, Arc::new(RwLock::new(0)));
let job_epoch = *publisher.1.read().unwrap();
publisher.revoke();
let (reply, receive) = mpsc::sync_channel(1);
deliver(
job_epoch,
&publisher.1,
vec![(9, reply)],
&serde_json::json!({"title": "private"}),
);
let response = receive.recv().unwrap();
assert_eq!(response.id, 9);
assert!(response.result.is_none());
assert_eq!(response.error.unwrap().code, "session_locked");
}
#[test]
fn saturated_queue_rejects_without_blocking_and_slow_clients_do_not_block_others() {
let (send, _receive) = mpsc::sync_channel(0);
let publisher = Publisher(send, Arc::new(RwLock::new(0)));
let (reply, receive) = mpsc::sync_channel(1);
publisher.send(Arc::new(Snapshot::default()), vec![(7, reply)]);
let response = receive.recv_timeout(Duration::from_secs(1)).unwrap();
assert_eq!(response.id, 7);
assert_eq!(response.error.unwrap().code, "activity_busy");
let publisher = Publisher::new().unwrap();
let (slow, _slow_receive) = mpsc::sync_channel(1);
slow.send(Response::success(1, serde_json::json!({})))
.unwrap();
let (fast, fast_receive) = mpsc::sync_channel(1);
publisher.send(Arc::new(Snapshot::default()), vec![(2, slow), (3, fast)]);
assert_eq!(
fast_receive
.recv_timeout(Duration::from_secs(1))
.unwrap()
.id,
3
);
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Explicitly enabled fallback. Bounded /proc sampling and regex work stay on
//! one worker; disappearance emits owner exit, never an inferred exit status.
use crate::ipc::IpcEvent;
use ferese_activities::Owner;
use smithay::reexports::calloop::channel;
use std::collections::BTreeMap;
use std::io::Read;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
pub(crate) struct Watcher(Arc<AtomicBool>);
impl Drop for Watcher {
fn drop(&mut self) {
self.0.store(true, Ordering::Release);
}
}
impl Watcher {
pub fn start(
rules: crate::activities::SharedRules,
sender: channel::SyncSender<IpcEvent>,
) -> std::io::Result<Self> {
let stop = Arc::new(AtomicBool::new(false));
let worker_stop = stop.clone();
std::thread::Builder::new()
.name("ferese-activity-processes".into())
.spawn(move || {
let mut observed = BTreeMap::<
Owner,
(
Arc<std::os::fd::OwnedFd>,
ferese_config::activities::Matched,
),
>::new();
while !worker_stop.load(Ordering::Acquire) {
let rules = rules.read().expect("activity rules").clone();
let dead: Vec<_> = observed
.iter()
.filter_map(|(owner, (fd, _))| {
crate::ipc::activity_owner::exited(fd).then_some(*owner)
})
.collect();
for owner in dead {
observed.remove(&owner);
if sender.send(IpcEvent::ActivityOwnerExited(owner)).is_err() {
return;
}
}
// Reconcile every detection: admission may have failed or its lease expired.
for (owner, (fd, matched)) in &observed {
let _ = sender.try_send(IpcEvent::ActivityDetected(
*owner,
Box::new(matched.clone()),
fd.clone(),
));
}
if let Ok(processes) = std::fs::read_dir("/proc") {
for process in processes.take(4_096).flatten() {
if worker_stop.load(Ordering::Acquire) || observed.len() >= 128 {
break;
}
let Some(pid) = process
.file_name()
.to_str()
.and_then(|name| name.parse::<u32>().ok())
else {
continue;
};
let Ok((owner, fd, parents)) = crate::ipc::activity_owner::inspect(pid)
else {
continue;
};
if observed.contains_key(&owner)
|| parents.iter().any(|parent| parent == "feresectl")
{
continue;
}
let Ok(file) = std::fs::File::open(process.path().join("cmdline"))
else {
continue;
};
let mut bytes = Vec::new();
if file.take(4_097).read_to_end(&mut bytes).is_err()
|| bytes.len() > 4_096
{
continue;
}
let Ok(command) = String::from_utf8(bytes) else {
continue;
};
let command = command.trim_end_matches('\0').replace('\0', " ");
let Ok(cwd) = std::fs::read_link(process.path().join("cwd")) else {
continue;
};
let cwd = cwd.to_string_lossy();
let Some(matched) = rules.matcher.matches(&command, &cwd, &parents)
else {
continue;
};
let fd = Arc::new(fd);
// Keep the inspected process identity alive through dispatch.
// No raw command, cwd or environment crosses the dispatcher.
if sender
.try_send(IpcEvent::ActivityDetected(
owner,
Box::new(matched.clone()),
fd.clone(),
))
.is_ok()
{
observed.insert(owner, (fd, matched));
}
}
}
std::thread::sleep(Duration::from_secs(2));
}
})?;
Ok(Self(stop))
}
}
File diff suppressed because it is too large. Load diff
+832 -21
View File
@@ -1,36 +1,381 @@
use smithay::backend::allocator::Fourcc;
use smithay::backend::renderer::damage::{Error as DamageError, OutputDamageTracker};
use smithay::backend::renderer::element::RenderElement;
use smithay::backend::renderer::gles::{GlesError, GlesRenderer, GlesTarget, GlesTexture};
use smithay::backend::renderer::{Bind, Offscreen, Texture};
use smithay::output::Output;
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use std::fmt::Display;
use std::time::{Duration, Instant};
use crate::Ferese;
use crate::render::{redraw_output, sampled_output_elements};
use crate::render::{capture_output_elements, redraw_output};
use crate::state::FrameScene;
pub(super) fn capture_output(
const CACHE_IDLE: Duration = Duration::from_secs(2);
pub(crate) mod changes;
pub(crate) struct CaptureTarget {
texture: GlesTexture,
visible: Option<smithay::backend::renderer::element::RenderElementStates>,
damage: OutputDamageTracker,
output: Output,
revision: u64,
id: smithay::backend::renderer::element::Id,
commit: smithay::backend::renderer::utils::CommitCounter,
stamp: Option<changes::Stamp>,
preparations: u64,
reuses: u64,
compositions: u64,
trace: bool,
reported: Instant,
}
impl CaptureTarget {
pub(crate) fn surface_visible(
&self,
surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
) -> bool {
self.visible
.as_ref()
.and_then(|states| states.element_render_state(surface))
.is_some_and(crate::display_presentation::is_visible)
}
pub(crate) fn image(&self) -> (GlesTexture, u64) {
(self.texture.clone(), self.revision)
}
pub(crate) fn new(texture: GlesTexture, output: &Output) -> Self {
Self {
texture,
visible: None,
damage: OutputDamageTracker::from_output(output),
output: output.clone(),
revision: 0,
id: smithay::backend::renderer::element::Id::new(),
commit: Default::default(),
stamp: None,
preparations: 0,
reuses: 0,
compositions: 0,
trace: std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some(),
reported: Instant::now(),
}
}
}
pub(crate) fn touch_cache(state: &mut Ferese) {
if state.capture_last_used.replace(Instant::now()).is_some() {
return;
}
if let Err(error) =
state
.loop_handle
.insert_source(Timer::from_duration(CACHE_IDLE), |_, _, state| {
let Some(last_used) = state.capture_last_used else {
return TimeoutAction::Drop;
};
let idle = last_used.elapsed();
if idle < CACHE_IDLE {
return TimeoutAction::ToDuration(CACHE_IDLE - idle);
}
if let Some(backend) = state.nested_backend.clone()
&& let Ok(mut backend) = backend.try_borrow_mut()
{
crate::render::clear_stream_capture_targets(backend.renderer());
}
if let Some(backend) = state.direct_backend.as_mut() {
backend.clear_stream_capture_targets();
}
if !state.pending_screencopies.is_empty() || state.image_capture.has_sessions() {
// A damage-waiting stream must retain the comparison image.
return TimeoutAction::ToDuration(CACHE_IDLE);
}
// A stream can pause or fail without another repaint. Release its
// offscreen targets and filtered-scene caches from the event loop.
state.capture_last_used = None;
state.capture_render = Default::default();
state.nested_capture_texture = Default::default();
if let Some(backend) = state.direct_backend.as_mut() {
for device in backend.devices.values_mut() {
for output in device.outputs.values_mut() {
output.capture_texture = Default::default();
output.mirror_capture_texture = Default::default();
}
}
}
TimeoutAction::Drop
})
{
state.capture_last_used = None;
tracing::warn!(%error, "could not schedule capture cache cleanup");
}
}
pub(crate) fn capture_output(
state: &mut Ferese,
renderer: &mut GlesRenderer,
texture: &mut Option<GlesTexture>,
textures: &mut [Option<CaptureTarget>; 2],
output: &Output,
scene: &FrameScene,
) -> Result<(), GlesError> {
) {
for include_cursor in [true, false] {
if !state.has_pending_screencopy(output, include_cursor) {
continue;
}
// Sample the same scene into an independent target: the displayed
// primary buffer may omit both a scanned-out client and hardware cursor.
let elements = sampled_output_elements(state, renderer, output, include_cursor, scene);
let target = render_capture(renderer, texture, output, &elements)?;
state.process_screencopies(renderer, &target, output, include_cursor);
let texture = &mut textures[usize::from(include_cursor)];
if !prepare_capture(
state,
renderer,
texture,
output,
output,
include_cursor,
scene,
) {
continue;
}
let texture = texture.as_mut().expect("capture target allocated");
let Some((target, revision)) = capture_request(state, output, include_cursor, || {
renderer
.bind(&mut texture.texture)
.map(|target| (target, texture.revision))
}) else {
continue;
};
state.process_screencopies_with_revision(
renderer,
&target,
output,
include_cursor,
Some(revision),
);
drop(target);
state.process_output_streams(renderer, &texture.texture, output, include_cursor, revision);
}
Ok(())
}
fn render_capture<'a, E: RenderElement<GlesRenderer>>(
pub(super) fn capture_mirror(
state: &mut Ferese,
renderer: &mut GlesRenderer,
source: &Output,
scene: &FrameScene,
output: &mut super::DirectOutput,
) {
for include_cursor in [true, false] {
if !state.has_pending_screencopy(&output.output, include_cursor) {
continue;
}
// Never sample mirror_texture: it contains the unfiltered display.
let canvas = output
.mirror_canvas
.as_ref()
.expect("mirror display composed");
super::mirror::configure_canvas(canvas, source);
let source_target = &mut output.mirror_capture_texture[usize::from(include_cursor)];
if !prepare_capture(
state,
renderer,
source_target,
source,
canvas,
include_cursor,
scene,
) {
continue;
}
let source_target = source_target
.as_ref()
.expect("mirror capture target allocated");
let element = super::mirror::fitted_texture(
source_target.texture.clone(),
&output.output,
source_target.id.clone(),
source_target.commit,
);
let Some((target, revision)) =
capture_request(state, &output.output, include_cursor, || {
render_capture_with_revision(
renderer,
&mut output.capture_texture[usize::from(include_cursor)],
&output.output,
&[element],
)
})
else {
continue;
};
state.process_screencopies_with_revision(
renderer,
&target,
&output.output,
include_cursor,
Some(revision),
);
drop(target);
state.process_output_streams(
renderer,
&output.capture_texture[usize::from(include_cursor)]
.as_ref()
.expect("capture target allocated")
.texture
.clone(),
&output.output,
include_cursor,
revision,
);
}
}
/// Display damage cannot prove a filtered scene is unchanged: an opaque private
/// window may hide updates that must remain visible to capture underneath it.
/// Retain a separate source revision and only prepare the filtered scene on a
/// real mutation. Delivery buffers still receive a complete image.
fn prepare_capture(
state: &mut Ferese,
renderer: &mut GlesRenderer,
target: &mut Option<CaptureTarget>,
source: &Output,
destination: &Output,
include_cursor: bool,
scene: &FrameScene,
) -> bool {
touch_cache(state);
let stamp = changes::Stamp::new(state, source, include_cursor);
let reusable = target.as_ref().is_some_and(|target| {
target.stamp.as_ref() == Some(&stamp)
&& target.output == *destination
&& destination
.current_mode()
.is_some_and(|mode| target.texture.size() == (mode.size.w, mode.size.h).into())
});
if reusable {
target.as_mut().expect("cached capture target").reuses += 1;
} else {
// Keep the capture image separate from the display swapchain and planes.
let elements = capture_output_elements(state, renderer, source, include_cursor, scene);
let Some(framebuffer) = capture_request(state, destination, include_cursor, || {
render_capture_with_revision(renderer, target, destination, &elements)
}) else {
// A failed render can have changed the retained pixels already.
if let Some(target) = target.as_mut() {
target.stamp = None;
}
return false;
};
drop(framebuffer);
let target = target.as_mut().expect("capture target allocated");
target.stamp = Some(stamp);
target.preparations += 1;
}
let target = target.as_mut().expect("capture target allocated");
if target.trace && target.reported.elapsed() >= Duration::from_secs(5) {
tracing::info!(target: "ferese::render", output = %source.name(), include_cursor,
preparations = target.preparations, scene_reuses = target.reuses,
compositions = target.compositions, "capture scene outcomes");
target.reported = Instant::now();
target.preparations = 0;
target.reuses = 0;
target.compositions = 0;
}
true
}
pub(crate) fn capture_request<T, E: Display>(
state: &mut Ferese,
output: &Output,
include_cursor: bool,
render: impl FnOnce() -> Result<T, E>,
) -> Option<T> {
match render() {
Ok(target) => Some(target),
Err(error) => {
tracing::warn!(?output, include_cursor, %error, "screencopy render failed");
state.fail_screencopies(output, include_cursor);
None
}
}
}
pub(super) fn after_queue<T, E>(queued: Result<T, E>, capture: impl FnOnce()) -> Result<T, E> {
let queued = queued?;
capture();
Ok(queued)
}
#[cfg(test)]
pub(super) fn render_capture<'a, E: RenderElement<GlesRenderer>>(
renderer: &mut GlesRenderer,
target: &'a mut Option<CaptureTarget>,
output: &Output,
elements: &[E],
) -> Result<GlesTarget<'a>, DamageError<GlesError>> {
render_capture_with_revision(renderer, target, output, elements).map(|(target, _)| target)
}
pub(crate) fn render_capture_with_revision<'a, E: RenderElement<GlesRenderer>>(
renderer: &mut GlesRenderer,
target: &'a mut Option<CaptureTarget>,
output: &Output,
elements: &[E],
) -> Result<(GlesTarget<'a>, u64), DamageError<GlesError>> {
let size = output.current_mode().expect("output has a mode").size;
if target
.as_ref()
.is_none_or(|target| target.texture.size() != (size.w, size.h).into())
{
*target = Some(CaptureTarget::new(
Offscreen::<GlesTexture>::create_buffer(
renderer,
Fourcc::Abgr8888,
(size.w, size.h).into(),
)
.map_err(DamageError::Rendering)?,
output,
));
}
let target = target.as_mut().expect("capture target allocated");
if target.output != *output {
target.damage = OutputDamageTracker::from_output(output);
target.output = output.clone();
}
// This texture retains the previous capture, independently of display
// scanout and damage. Age one lets unchanged pixels survive; a new target
// has no history and is repainted in full. Rebind after every readback.
let mut framebuffer = renderer
.bind(&mut target.texture)
.map_err(DamageError::Rendering)?;
let result = target.damage.render_output(
renderer,
&mut framebuffer,
1,
elements,
[0.035, 0.04, 0.055, 1.0],
)?;
if result
.damage
.as_ref()
.is_some_and(|damage| !damage.is_empty())
{
static NEXT_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
target.commit.increment();
target.compositions += 1;
target.revision = NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
if output
.user_data()
.get::<crate::handlers::workspace_capture::CaptureOutputId>()
.is_some()
{
target.visible = Some(result.states);
}
Ok((framebuffer, target.revision))
}
pub(super) fn render_texture<'a, E: RenderElement<GlesRenderer>>(
renderer: &mut GlesRenderer,
texture: &'a mut Option<GlesTexture>,
output: &Output,
@@ -69,14 +414,363 @@ mod tests {
#[test]
#[ignore = "requires an EGL rendering device"]
fn capture_recomposes_cursor_without_touching_the_display_target() {
let device = EGLDevice::enumerate().unwrap().last().expect("an EGL device");
fn unchanged_capture_skips_preparation_without_losing_source_updates() {
if !crate::startup_tests::private_runtime(
"backends::direct::capture::tests::unchanged_capture_skips_preparation_without_losing_source_updates",
) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut config = crate::config::Config::default().runtime_config().unwrap();
config.wallpaper.path = None;
config.animations_enabled = false;
let mut state = Ferese::new(
&mut events,
smithay::reexports::wayland_server::Display::new().unwrap(),
config,
)
.unwrap();
let output = Output::new(
"capture-cache".into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: (320, 240).into(),
refresh: 60_000,
}),
Some(Transform::Normal),
None,
None,
);
state.space.map_output(&output, (0, 0));
state.register_output(&output, "capture-cache".into());
let (window, _client) = crate::state::test_window(&mut state, &mut events, 0xffff0000);
state.advance_animations(Instant::now());
let device = EGLDevice::enumerate().unwrap().last().expect("EGL device");
// SAFETY: the device and display outlive this exclusively owned context.
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
// SAFETY: this test thread exclusively owns the fresh context.
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let mut target = None;
let prepare = |state: &mut Ferese,
renderer: &mut GlesRenderer,
target: &mut Option<CaptureTarget>| {
let scene = state.sample_frame(&output, Duration::ZERO);
assert!(prepare_capture(
state, renderer, target, &output, &output, false, &scene
));
};
let pixels = |renderer: &mut GlesRenderer, target: &mut Option<CaptureTarget>| {
let target = target.as_mut().unwrap();
let framebuffer = renderer.bind(&mut target.texture).unwrap();
let mapping = renderer
.copy_framebuffer(
&framebuffer,
Rectangle::from_size((320, 240).into()),
Fourcc::Abgr8888,
)
.unwrap();
drop(framebuffer);
renderer.map_texture(&mapping).unwrap().to_vec()
};
prepare(&mut state, &mut renderer, &mut target);
// Keep assertions independent of the opt-in periodic reporting clock.
target.as_mut().unwrap().trace = false;
let original = pixels(&mut renderer, &mut target);
for _ in 0..100 {
super::super::schedule_capture(&mut state, std::slice::from_ref(&output));
prepare(&mut state, &mut renderer, &mut target);
}
assert_eq!(target.as_ref().unwrap().preparations, 1);
assert_eq!(target.as_ref().unwrap().reuses, 100);
assert_eq!(pixels(&mut renderer, &mut target), original);
let toplevel = window.toplevel().unwrap();
smithay::wayland::compositor::with_states(toplevel.wl_surface(), |states| {
states
.data_map
.get::<smithay::wayland::shell::xdg::XdgToplevelSurfaceData>()
.unwrap()
.lock()
.unwrap()
.title = Some("Metadata without new pixels".into());
});
<Ferese as smithay::wayland::shell::xdg::XdgShellHandler>::title_changed(
&mut state,
toplevel.clone(),
);
prepare(&mut state, &mut renderer, &mut target);
assert_eq!(
target.as_ref().unwrap().preparations,
1,
"a title change must reuse the captured pixels"
);
assert_eq!(pixels(&mut renderer, &mut target), original);
super::super::render_cursor(&mut state);
prepare(&mut state, &mut renderer, &mut target);
assert_eq!(target.as_ref().unwrap().preparations, 1);
state.capture_epoch += 1;
prepare(&mut state, &mut renderer, &mut target);
assert_eq!(target.as_ref().unwrap().preparations, 2);
let (_cover, _cover_client) =
crate::state::test_window(&mut state, &mut events, 0xff00ff00);
state.advance_animations(Instant::now());
prepare(&mut state, &mut renderer, &mut target);
assert_eq!(target.as_ref().unwrap().preparations, 3);
assert_ne!(pixels(&mut renderer, &mut target), original);
}
#[test]
#[ignore = "requires an EGL rendering device"]
fn incremental_capture_matches_full_redraw_after_scene_and_output_changes() {
let device = EGLDevice::enumerate().unwrap().last().expect("EGL device");
// SAFETY: the enumerated device remains alive through its EGL display.
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
// SAFETY: this thread exclusively owns the renderer and its fresh context.
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let output = |name: &str| {
Output::new(
name.into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
)
};
let outputs = [output("first"), output("second")];
let mut capture = None;
let mut reference = None;
let background = SolidColorRenderElement::new(
Id::new(),
Rectangle::from_size((96, 96).into()),
CommitCounter::default(),
Color32F::new(0., 0., 1., 1.),
Kind::Unspecified,
);
let item_id = Id::new();
for output in outputs.iter().chain(std::iter::once(&outputs[0])) {
for (width, height) in [(64, 48), (48, 64)] {
for scale in [1., 1.25, 1.5] {
for transform in [
Transform::Normal,
Transform::_90,
Transform::_180,
Transform::Flipped,
] {
output.change_current_state(
Some(Mode {
size: (width, height).into(),
refresh: 60_000,
}),
Some(transform),
Some(smithay::output::Scale::Fractional(scale)),
None,
);
let mut commit = CommitCounter::default();
// Include moves, color/alpha changes, removal, cursor
// inclusion/exclusion and an entirely empty scene.
for step in [6, 0, 1, 2, 3, 4, 5, 6] {
if step == 2 {
commit.increment();
}
let mut elements = Vec::new();
if matches!(step, 0..=2 | 4) {
elements.push(SolidColorRenderElement::new(
item_id.clone(),
Rectangle::new(
(if step == 0 { 4 } else { 20 }, 4).into(),
(12, 12).into(),
),
commit,
if step < 2 {
Color32F::new(0., 1., 0., 1.)
} else {
Color32F::new(0.5, 0., 0., 0.5)
},
Kind::Cursor,
));
}
if step != 6 {
elements.push(background.clone());
}
drop(
render_capture(&mut renderer, &mut capture, output, &elements)
.unwrap(),
);
// The repeated scene must preserve the same pixels when
// damage tracking skips all drawing on the next capture.
let actual =
render_capture(&mut renderer, &mut capture, output, &elements)
.unwrap();
let mapping = renderer
.copy_framebuffer(
&actual,
Rectangle::from_size((width, height).into()),
Fourcc::Abgr8888,
)
.unwrap();
let actual_pixels = renderer.map_texture(&mapping).unwrap().to_vec();
drop(actual);
let expected =
render_texture(&mut renderer, &mut reference, output, &elements)
.unwrap();
let mapping = renderer
.copy_framebuffer(
&expected,
Rectangle::from_size((width, height).into()),
Fourcc::Abgr8888,
)
.unwrap();
let expected_pixels = renderer.map_texture(&mapping).unwrap();
assert_eq!(
actual_pixels,
expected_pixels,
"{} {width}x{height} scale={scale} {transform:?} step={step}",
output.name()
);
}
}
}
}
}
}
#[test]
#[ignore = "manual 4K capture benchmark; requires a hardware EGL device"]
fn profile_output_capture() {
let device = EGLDevice::enumerate()
.unwrap()
.find(|device| device.render_device_path().is_ok())
.expect("a hardware EGL rendering device");
eprintln!("capture_benchmark device={:?}", device.render_device_path());
// SAFETY: the enumerated device remains alive through its EGL display.
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
// SAFETY: this thread exclusively owns the renderer and its fresh context.
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let output = Output::new(
"capture-benchmark".into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: (3840, 2160).into(),
refresh: 60_000,
}),
Some(Transform::Normal),
None,
None,
);
let mut texture = None;
let background_id = Id::new();
let cursor_id = Id::new();
let mut commit = CommitCounter::default();
let panes: Vec<_> = (0..20)
.map(|i| {
SolidColorRenderElement::new(
Id::new(),
Rectangle::new((i * 150, i * 75).into(), (800, 600).into()),
CommitCounter::default(),
Color32F::new(0.1, 0.2, 0.3, 0.5),
Kind::Unspecified,
)
})
.collect();
for readback in [false, true] {
let size = if readback { (3840, 2160) } else { (1, 1) };
let mut pixels = vec![0u8; size.0 as usize * size.1 as usize * 4];
for workload in ["static", "cursor", "full"] {
for run in 0..=3 {
let started = Instant::now();
commit.increment();
for tick in 0..60 {
let color = if workload == "full" {
commit.increment();
tick as f32 / 60.
} else {
0.1
};
let mut elements = vec![SolidColorRenderElement::new(
cursor_id.clone(),
Rectangle::new(
(if workload == "cursor" { tick * 30 } else { 0 }, 0).into(),
(24, 24).into(),
),
CommitCounter::default(),
Color32F::new(1., 1., 1., 1.),
Kind::Cursor,
)];
elements.extend_from_slice(&panes);
elements.push(SolidColorRenderElement::new(
background_id.clone(),
Rectangle::from_size((3840, 2160).into()),
commit,
Color32F::new(color, 0.1, 0.2, 1.),
Kind::Unspecified,
));
let target =
render_capture(&mut renderer, &mut texture, &output, &elements)
.unwrap();
// Mapping waits for GPU completion in both workloads. The full
// readback also includes the copy into the client's SHM buffer.
let mapping = renderer
.copy_framebuffer(
&target,
Rectangle::from_size(size.into()),
Fourcc::Argb8888,
)
.unwrap();
let source = renderer.map_texture(&mapping).unwrap();
let bytes = pixels.len();
pixels.copy_from_slice(&source[..bytes]);
std::hint::black_box(&pixels);
}
if run != 0 {
eprintln!(
"output_capture_benchmark readback={readback} workload={workload} run={run} frames=60 elapsed_us={}",
started.elapsed().as_micros()
);
}
}
}
}
}
#[test]
#[ignore = "requires an EGL rendering device"]
fn capture_recomposes_cursor_without_touching_the_display_target() {
let device = EGLDevice::enumerate()
.unwrap()
.last()
.expect("an EGL device");
// SAFETY: The native display comes from a live enumerated EGLDevice and its lifetime is
// retained by the EGL objects created here.
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
// SAFETY: The fresh EGL context is exclusively owned by this renderer; this test issues
// GL work only through its renderer methods.
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let output = Output::new(
"capture".into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
@@ -109,7 +803,7 @@ mod tests {
let mut displayed = None;
let mut capture = None;
{
let _ = render_capture(
let _ = render_texture(
&mut renderer,
&mut displayed,
&output,
@@ -124,18 +818,32 @@ mod tests {
] {
let target = render_capture(&mut renderer, &mut capture, &output, &elements).unwrap();
let mapping = renderer
.copy_framebuffer(&target, Rectangle::from_size((32, 24).into()), Fourcc::Abgr8888)
.copy_framebuffer(
&target,
Rectangle::from_size((32, 24).into()),
Fourcc::Abgr8888,
)
.unwrap();
let pixels = renderer.map_texture(&mapping).unwrap();
assert_eq!(&pixels[..4], &expected);
assert_eq!(&pixels[(12 * 32 + 16) * 4..(12 * 32 + 16) * 4 + 4], &[255, 0, 0, 255]);
assert_eq!(
&pixels[(12 * 32 + 16) * 4..(12 * 32 + 16) * 4 + 4],
&[255, 0, 0, 255]
);
drop(target);
let target = renderer.bind(displayed.as_mut().unwrap()).unwrap();
let mapping = renderer
.copy_framebuffer(&target, Rectangle::from_size((32, 24).into()), Fourcc::Abgr8888)
.copy_framebuffer(
&target,
Rectangle::from_size((32, 24).into()),
Fourcc::Abgr8888,
)
.unwrap();
assert_eq!(&renderer.map_texture(&mapping).unwrap()[..4], &[255, 0, 0, 255]);
assert_eq!(
&renderer.map_texture(&mapping).unwrap()[..4],
&[255, 0, 0, 255]
);
}
output.change_current_state(
@@ -147,7 +855,110 @@ mod tests {
None,
None,
);
let _ = render_capture::<SolidColorRenderElement>(&mut renderer, &mut capture, &output, &[]).unwrap();
assert_eq!(capture.as_ref().unwrap().size(), (48, 16).into());
let _ =
render_capture::<SolidColorRenderElement>(&mut renderer, &mut capture, &output, &[])
.unwrap();
assert_eq!(capture.as_ref().unwrap().texture.size(), (48, 16).into());
}
}
#[cfg(test)]
mod failure_tests {
use std::cell::Cell;
use calloop::channel::channel;
use smithay::output::Output;
use smithay::utils::{Buffer, Rectangle};
use super::*;
#[test]
fn capture_allocation_failure_fails_one_request_after_display_submission() {
if !crate::startup_tests::private_runtime(
"backends::direct::capture::failure_tests::capture_allocation_failure_fails_one_request_after_display_submission",
) {
return;
}
let mut events = calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let output = Output::new(
"capture-failure".into(),
smithay::output::PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: smithay::output::Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
let (sender, receiver) = channel();
state
.pending_screencopies
.push(crate::handlers::screencopy::PendingScreencopy::owned(
41,
0,
sender,
output.clone(),
Rectangle::<i32, Buffer>::from_size((16, 16).into()),
));
let other_output = Output::new(
"other-capture".into(),
smithay::output::PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: smithay::output::Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
let (other_sender, other_receiver) = channel();
state
.pending_screencopies
.push(crate::handlers::screencopy::PendingScreencopy::owned(
42,
0,
other_sender,
other_output.clone(),
Rectangle::<i32, Buffer>::from_size((16, 16).into()),
));
let submitted = Cell::new(false);
let queued = {
submitted.set(true);
Ok::<(), ()>(())
};
after_queue(queued, || {
assert!(
submitted.get(),
"capture must run after the display queue succeeds"
);
assert!(
capture_request(&mut state, &output, false, || Err::<(), _>(
"injected allocation failure"
))
.is_none()
);
})
.unwrap();
assert!(
submitted.get(),
"capture failure must not undo queued display work"
);
assert_eq!(state.pending_screencopies.len(), 1);
assert_eq!(state.pending_screencopies[0].output, other_output);
let outcome = receiver.try_recv().unwrap();
assert_eq!(outcome.request, 41);
assert_eq!(outcome.part, 0);
assert!(outcome.result.is_err());
assert!(
receiver.try_recv().is_err(),
"request should fail exactly once"
);
assert!(
other_receiver.try_recv().is_err(),
"other output's request must stay pending"
);
}
}
@@ -0,0 +1,185 @@
//! Capture invalidation is independent of display damage and consumer requests.
use std::sync::atomic::{AtomicU64, Ordering};
use smithay::output::Output;
use smithay::utils::{Logical, Physical, Rectangle, Size, Transform};
#[derive(Default)]
struct Changes {
content: AtomicU64,
cursor: AtomicU64,
}
pub(crate) fn content_changed(output: &Output) {
if let Some(changes) = output.user_data().get::<Changes>() {
changes.content.fetch_add(1, Ordering::Relaxed);
}
}
pub(crate) fn cursor_changed(output: &Output) {
if let Some(changes) = output.user_data().get::<Changes>() {
changes.cursor.fetch_add(1, Ordering::Relaxed);
}
}
pub(crate) fn tracked(output: &Output) -> bool {
output.user_data().get::<Changes>().is_some()
}
/// Also compare output configuration: mode changes can precede a redraw request.
/// Keeping the source identity prevents a remapped mirror from reusing its old view.
#[derive(Debug, PartialEq)]
pub(super) struct Stamp {
output: Output,
content: u64,
cursor: Option<u64>,
epoch: u64,
cursor_protected: bool,
size: Option<Size<i32, Physical>>,
scale: f64,
transform: Transform,
geometry: Option<Rectangle<i32, Logical>>,
}
impl Stamp {
pub fn new(state: &crate::Ferese, output: &Output, include_cursor: bool) -> Self {
let changes = output
.user_data()
.get_or_insert_threadsafe(Changes::default);
Self {
output: output.clone(),
content: changes.content.load(Ordering::Relaxed),
cursor: include_cursor.then(|| changes.cursor.load(Ordering::Relaxed)),
epoch: state.capture_epoch,
cursor_protected: include_cursor && state.capture_cursor_protected(),
size: output.current_mode().map(|mode| mode.size),
scale: output.current_scale().fractional_scale(),
transform: output.current_transform(),
geometry: state.space.output_geometry(output),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use smithay::output::{Mode, PhysicalProperties, Scale, Subpixel};
fn output() -> Output {
let output = Output::new(
"capture-test".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: String::new(),
model: String::new(),
serial_number: String::new(),
},
);
output.change_current_state(
Some(Mode {
size: (640, 480).into(),
refresh: 60_000,
}),
Some(Transform::Normal),
Some(Scale::Fractional(1.25)),
Some((0, 0).into()),
);
output
}
#[test]
fn consumer_demand_does_not_invalidate_but_scene_redraws_do() {
if !crate::startup_tests::private_runtime(
"backends::direct::capture::changes::tests::consumer_demand_does_not_invalidate_but_scene_redraws_do",
) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let first = output();
let second = output();
state.space.map_output(&first, (0, 0));
state.space.map_output(&second, (640, 0));
let before = Stamp::new(&state, &first, false);
let other = Stamp::new(&state, &second, false);
crate::backends::direct::schedule_capture(&mut state, std::slice::from_ref(&first));
assert_eq!(before, Stamp::new(&state, &first, false));
crate::backends::direct::render_on(&mut state, std::slice::from_ref(&first));
assert_ne!(before, Stamp::new(&state, &first, false));
assert_eq!(other, Stamp::new(&state, &second, false));
let before = Stamp::new(&state, &first, false);
state.defer_output_redraw(first.clone());
assert_ne!(before, Stamp::new(&state, &first, false));
crate::backends::direct::render_all(&mut state);
assert_ne!(other, Stamp::new(&state, &second, false));
}
#[test]
fn cursor_privacy_and_output_configuration_change_the_appropriate_view() {
if !crate::startup_tests::private_runtime(
"backends::direct::capture::changes::tests::cursor_privacy_and_output_configuration_change_the_appropriate_view",
) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let output = output();
state.space.map_output(&output, (0, 0));
let hidden = Stamp::new(&state, &output, false);
let embedded = Stamp::new(&state, &output, true);
crate::backends::direct::render_cursor(&mut state);
assert_eq!(hidden, Stamp::new(&state, &output, false));
assert_ne!(embedded, Stamp::new(&state, &output, true));
state.capture_epoch += 1;
assert_ne!(hidden, Stamp::new(&state, &output, false));
let before = Stamp::new(&state, &output, false);
output.change_current_state(None, Some(Transform::_180), None, None);
assert_ne!(before, Stamp::new(&state, &output, false));
let before = Stamp::new(&state, &output, false);
output.change_current_state(None, None, Some(Scale::Fractional(1.5)), None);
assert_ne!(before, Stamp::new(&state, &output, false));
let before = Stamp::new(&state, &output, false);
state.space.map_output(&output, (100, 0));
assert_ne!(before, Stamp::new(&state, &output, false));
}
#[test]
fn commits_and_the_final_animation_frame_invalidate_capture() {
if !crate::startup_tests::private_runtime(
"backends::direct::capture::changes::tests::commits_and_the_final_animation_frame_invalidate_capture",
) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let output = output();
state.space.map_output(&output, (0, 0));
state.register_output(&output, "capture-animation".into());
let (_window, mut client) = crate::state::test_window(&mut state, &mut events, 0xffff0000);
let before = Stamp::new(&state, &output, false);
// Even a callback-only commit goes through the mutation path. Damage
// tracking can subsequently decide that the retained pixels still match.
crate::state::test_client_request(&mut client, 2, 6, &[], None);
crate::state::dispatch_test_client(&mut events, &mut state);
assert_ne!(before, Stamp::new(&state, &output, false));
let mut now = std::time::Instant::now();
state.advance_animations(now);
assert!(state.output_has_animations(&output));
let mut settled = false;
for _ in 0..500 {
let before = Stamp::new(&state, &output, false);
now += std::time::Duration::from_millis(16);
state.advance_animations(now);
assert_ne!(before, Stamp::new(&state, &output, false));
if !state.output_has_animations(&output) {
settled = true;
break;
}
}
assert!(
settled,
"the opening/focus animation must reach its final frame"
);
}
}
+134
View File
@@ -0,0 +1,134 @@
use std::fmt::Display;
use smithay::backend::egl::context::ContextPriority;
use smithay::backend::egl::{EGLContext, EGLDisplay, Error};
pub(super) fn try_new(display: &EGLDisplay) -> Result<EGLContext, Error> {
// Smithay omits the hint without EGL_IMG_context_priority and logs the
// queried priority when available. A successful request need not grant High.
try_with_high_priority(|priority| match priority {
Some(priority) => EGLContext::new_with_priority(display, priority),
None => EGLContext::new(display),
})
}
fn try_with_high_priority<T, E: Display>(
mut create: impl FnMut(Option<ContextPriority>) -> Result<T, E>,
) -> Result<T, E> {
create(Some(ContextPriority::High)).or_else(|error| {
tracing::warn!(
%error,
"EGL context creation with a high-priority hint failed; retrying without the hint"
);
create(None)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requests_high_without_recreating_a_successful_context() {
let mut requests = Vec::new();
let context = try_with_high_priority(|priority| {
requests.push(priority);
Ok::<_, &str>("context")
});
assert_eq!(context, Ok("context"));
assert_eq!(requests, [Some(ContextPriority::High)]);
}
#[test]
fn retries_without_a_hint_when_priority_creation_fails() {
let mut requests = Vec::new();
let context = try_with_high_priority(|priority| {
requests.push(priority);
match priority {
Some(_) => Err("priority rejected"),
None => Ok("fallback context"),
}
});
assert_eq!(context, Ok("fallback context"));
assert_eq!(requests, [Some(ContextPriority::High), None]);
}
#[test]
fn propagates_fallback_failure_without_retrying_again() {
let mut requests = Vec::new();
let context = try_with_high_priority(|priority| {
requests.push(priority);
match priority {
Some(_) => Err::<(), _>("priority rejected"),
None => Err("default creation failed"),
}
});
assert_eq!(context, Err("default creation failed"));
assert_eq!(requests, [Some(ContextPriority::High), None]);
}
#[test]
#[ignore = "requires an EGL device (software Mesa is supported)"]
fn creates_a_usable_egl_context() {
use smithay::backend::allocator::Fourcc;
use smithay::backend::egl::EGLDevice;
use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture};
use smithay::backend::renderer::{Bind, Color32F, ExportMem, Frame, Offscreen, Renderer};
use smithay::utils::{Physical, Rectangle, Transform};
let software_only = std::env::var("LIBGL_ALWAYS_SOFTWARE").as_deref() == Ok("1");
let mut tested = 0;
for device in EGLDevice::enumerate().unwrap() {
let software = device
.extensions()
.iter()
.any(|name| name == "EGL_MESA_device_software");
if software_only && !software {
continue;
}
eprintln!(
"EGL context test: software={software}, render_node={:?}",
device.render_device_path().ok()
);
// SAFETY: the enumerated EGL device remains owned by the display.
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = try_new(&display).unwrap();
// SAFETY: this fresh context is not current on another thread.
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let mut texture: GlesTexture = renderer
.create_buffer(Fourcc::Abgr8888, (8, 8).into())
.unwrap();
let rect = Rectangle::<i32, Physical>::from_size((8, 8).into());
{
let mut target = renderer.bind(&mut texture).unwrap();
{
let mut frame = renderer
.render(&mut target, (8, 8).into(), Transform::Normal)
.unwrap();
frame.clear(Color32F::new(1., 0., 0., 1.), &[rect]).unwrap();
frame.finish().unwrap().wait().unwrap();
}
let mapping = renderer
.copy_framebuffer(
&target,
Rectangle::from_size((8, 8).into()),
Fourcc::Abgr8888,
)
.unwrap();
let pixels = renderer.map_texture(&mapping).unwrap();
assert_eq!(pixels.len(), 8 * 8 * 4);
assert!(
pixels
.chunks_exact(4)
.all(|pixel| pixel == [255, 0, 0, 255])
);
}
renderer.egl_context().unbind().unwrap();
tested += 1;
}
assert!(tested > 0, "no matching EGL device was exercised");
}
}
@@ -0,0 +1,360 @@
use smithay::reexports::drm::control::crtc;
pub(super) struct Candidates {
pub applied: Option<crtc::Handle>,
pub kernel: Option<crtc::Handle>,
pub compatible: Vec<crtc::Handle>,
}
/// Maximum-cardinality, minimum-cost bipartite matching. Cardinality wins over
/// preferences: augment until no path remains, even if its cost is positive.
/// For that cardinality, retain the most valid applied assignments, then the
/// most kernel assignments. Stable connector/compatible-CRTC order breaks ties.
/// One applied edge outweighs every possible kernel edge, since applied ownership
/// is authoritative and encoder routing can be stale after disabling an output.
pub(super) fn assign(requests: &[Candidates]) -> Vec<Option<crtc::Handle>> {
let mut crtcs = Vec::new();
for request in requests {
for crtc in &request.compatible {
if !crtcs.contains(crtc) {
crtcs.push(*crtc);
}
}
}
let source = requests.len() + crtcs.len();
let sink = source + 1;
let mut edges = Vec::new();
let mut assignments = Vec::new();
let applied_weight = requests.len() as i64 + 1;
for (index, request) in requests.iter().enumerate() {
add_edge(&mut edges, source, index, 0);
for (crtc_index, crtc) in crtcs.iter().enumerate() {
if request.compatible.contains(crtc) {
let cost = -applied_weight * i64::from(request.applied == Some(*crtc))
- i64::from(request.kernel == Some(*crtc));
assignments.push((index, *crtc, edges.len()));
add_edge(&mut edges, index, requests.len() + crtc_index, cost);
}
}
}
for crtc_index in 0..crtcs.len() {
add_edge(&mut edges, requests.len() + crtc_index, sink, 0);
}
while augment(&mut edges, source, sink) {}
let mut assigned = vec![None; requests.len()];
for (index, crtc, edge) in assignments {
if !edges[edge].available {
assigned[index] = Some(crtc);
}
}
assigned
}
struct Edge {
from: usize,
to: usize,
cost: i64,
available: bool,
}
fn add_edge(edges: &mut Vec<Edge>, from: usize, to: usize, cost: i64) {
edges.push(Edge {
from,
to,
cost,
available: true,
});
edges.push(Edge {
from: to,
to: from,
cost: -cost,
available: false,
});
}
/// Find the cheapest residual augmenting path. Reverse edges allow earlier
/// assignments to move. Bellman-Ford handles negative preference costs; starting
/// from an empty matching and using shortest paths leaves no negative cost cycle.
/// Failed searches leave the matching untouched. Strict improvements and stable
/// edge traversal make equal-cost choices deterministic without hash iteration.
fn augment(edges: &mut [Edge], source: usize, sink: usize) -> bool {
let mut distances = vec![None; sink + 1];
let mut previous = vec![None; sink + 1];
distances[source] = Some(0);
for _ in 0..sink {
let mut changed = false;
for (index, edge) in edges.iter().enumerate() {
if edge.available
&& let Some(distance) = distances[edge.from]
&& distances[edge.to].is_none_or(|current| distance + edge.cost < current)
{
distances[edge.to] = Some(distance + edge.cost);
previous[edge.to] = Some(index);
changed = true;
}
}
if !changed {
break;
}
}
if previous[sink].is_none() {
return false;
}
let mut vertex = sink;
while vertex != source {
let index = previous[vertex].expect("reachable augmenting path");
edges[index].available = false;
edges[index ^ 1].available = true;
vertex = edges[index].from;
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
fn crtc(value: u32) -> crtc::Handle {
std::num::NonZeroU32::new(value).unwrap().into()
}
fn candidates(applied: Option<u32>, kernel: Option<u32>, compatible: &[u32]) -> Candidates {
Candidates {
applied: applied.map(crtc),
kernel: kernel.map(crtc),
compatible: compatible.iter().copied().map(crtc).collect(),
}
}
#[test]
fn every_connector_order_finds_a_complete_matching() {
let graph = [&[1, 2][..], &[1, 3][..], &[1, 3][..]];
for order in [
[0, 1, 2],
[0, 2, 1],
[1, 0, 2],
[1, 2, 0],
[2, 0, 1],
[2, 1, 0],
] {
let requests = order.map(|index| candidates(None, None, graph[index]));
let result = assign(&requests);
assert_eq!(result.iter().flatten().count(), 3, "order {order:?}");
assert_eq!(
assign(&requests),
result,
"deterministic for the same inventory"
);
}
}
#[test]
fn equal_preferences_use_connector_order() {
let result = assign(&[
candidates(Some(1), None, &[1, 2]),
candidates(Some(1), None, &[1, 2]),
]);
assert_eq!(result, vec![Some(crtc(1)), Some(crtc(2))]);
}
#[test]
fn preserving_two_applied_outputs_outweighs_preserving_the_first_one() {
assert_eq!(
assign(&[
candidates(Some(1), None, &[1, 4]),
candidates(Some(2), None, &[2, 4]),
candidates(Some(3), None, &[2, 3]),
candidates(None, None, &[1, 3]),
]),
vec![Some(crtc(4)), Some(crtc(2)), Some(crtc(3)), Some(crtc(1))]
);
}
#[test]
fn maximum_cardinality_and_preference_objective_match_exhaustive_oracle() {
fn score(
requests: &[Candidates],
assignment: &[Option<crtc::Handle>],
) -> (usize, usize, usize) {
let retained = [true, false].map(|applied| {
requests
.iter()
.zip(assignment)
.filter(|(request, actual)| {
let preferred = if applied {
request.applied
} else {
request.kernel
};
preferred.is_some() && preferred == **actual
})
.count()
});
(
assignment.iter().flatten().count(),
retained[0],
retained[1],
)
}
fn enumerate(
requests: &[Candidates],
prefix: &mut Vec<Option<crtc::Handle>>,
best: &mut (usize, usize, usize),
) {
if prefix.len() == requests.len() {
*best = std::cmp::max(*best, score(requests, prefix));
return;
}
let index = prefix.len();
prefix.push(None);
enumerate(requests, prefix, best);
prefix.pop();
for crtc in &requests[index].compatible {
if !prefix.contains(&Some(*crtc)) {
prefix.push(Some(*crtc));
enumerate(requests, prefix, best);
prefix.pop();
}
}
}
// All 512 three-by-three graphs, including empty and impossible ones.
// Vary both preference classes, including invalid/conflicting preferences.
for graph in 0..512 {
for seed in 0..8 {
let requests = (0..3)
.map(|index| {
let compatible = (1..=3)
.filter(|crtc| graph & (1 << (index * 3 + crtc - 1)) != 0)
.collect::<Vec<_>>();
let applied = ((seed + index) % 4 != 0).then_some((seed + index) % 4);
let kernel =
((seed * 3 + index) % 4 != 0).then_some((seed * 3 + index) % 4);
candidates(applied, kernel, &compatible)
})
.collect::<Vec<_>>();
let mut best = (0, 0, 0);
enumerate(&requests, &mut Vec::new(), &mut best);
let actual = assign(&requests);
let used = actual.iter().flatten().collect::<HashSet<_>>();
assert_eq!(used.len(), actual.iter().flatten().count());
assert!(requests.iter().zip(&actual).all(|(request, crtc)| {
crtc.is_none_or(|crtc| request.compatible.contains(&crtc))
}));
assert_eq!(
score(&requests, &actual),
best,
"graph {graph}, preferences {seed}"
);
}
}
}
#[test]
fn three_flexible_connectors_need_an_augmenting_path() {
let result = assign(&[
candidates(None, None, &[1, 2]),
candidates(None, None, &[1, 3]),
candidates(None, None, &[1, 3]),
]);
assert_eq!(result.iter().flatten().count(), 3);
}
#[test]
fn completeness_can_require_moving_two_applied_outputs() {
let result = assign(&[
candidates(Some(1), Some(1), &[1, 2]),
candidates(Some(2), Some(2), &[2, 3]),
candidates(None, None, &[1]),
]);
assert_eq!(result, vec![Some(crtc(2)), Some(crtc(3)), Some(crtc(1))]);
}
#[test]
fn external_to_two_outputs_keeps_external_crtc_regardless_of_connector_order() {
// Extend and mirror both require two independent scanout assignments.
// The internal panel appears first in the DRM connector inventory.
let internal = candidates(None, None, &[151, 270]);
let external = candidates(Some(151), Some(151), &[151, 270]);
assert_eq!(
assign(&[internal, external]),
vec![Some(crtc(270)), Some(crtc(151))]
);
let internal = candidates(None, None, &[151, 270]);
let external = candidates(Some(151), Some(151), &[151, 270]);
assert_eq!(
assign(&[external, internal]),
vec![Some(crtc(151)), Some(crtc(270))]
);
}
#[test]
fn stale_disabled_encoder_cannot_steal_applied_external_assignment() {
assert_eq!(
assign(&[
candidates(None, Some(151), &[151, 270]),
candidates(Some(151), Some(151), &[151, 270]),
]),
vec![Some(crtc(270)), Some(crtc(151))]
);
}
#[test]
fn kernel_assignments_are_preserved_when_no_outputs_have_been_applied() {
assert_eq!(
assign(&[
candidates(None, None, &[151, 270]),
candidates(None, Some(151), &[151, 270]),
]),
vec![Some(crtc(270)), Some(crtc(151))]
);
}
#[test]
fn returning_to_one_output_and_reconnecting_preserves_surviving_assignment() {
assert_eq!(
assign(&[candidates(Some(270), Some(270), &[151, 270])]),
vec![Some(crtc(270))]
);
assert_eq!(
assign(&[
candidates(None, None, &[151, 270]),
candidates(Some(270), Some(270), &[151, 270]),
]),
vec![Some(crtc(151)), Some(crtc(270))]
);
}
#[test]
fn incompatible_previous_assignment_is_not_reserved() {
assert_eq!(
assign(&[candidates(Some(151), Some(151), &[270])]),
vec![Some(crtc(270))]
);
}
#[test]
fn restricted_connector_gets_its_only_free_crtc() {
assert_eq!(
assign(&[
candidates(None, None, &[151, 270]),
candidates(None, None, &[151])
]),
vec![Some(crtc(270)), Some(crtc(151))]
);
}
#[test]
fn exhausted_crtcs_do_not_displace_surviving_output_or_assign_twice() {
assert_eq!(
assign(&[
candidates(None, None, &[151]),
candidates(Some(151), Some(151), &[151])
]),
vec![None, Some(crtc(151))]
);
assert!(assign(&[]).is_empty());
assert_eq!(assign(&[candidates(None, None, &[])]), vec![None]);
}
}
@@ -0,0 +1,309 @@
//! Opt-in accounting of observable DRM results, independent of scanout policy.
use std::time::{Duration, Instant};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum PrimaryOutcome {
DirectScanout,
Composed,
ReusedSwapchain,
}
impl PrimaryOutcome {
pub fn classify(direct: bool, composition_damage: bool) -> Self {
if direct {
Self::DirectScanout
} else if composition_damage {
Self::Composed
} else {
Self::ReusedSwapchain
}
}
}
#[derive(Debug, Default, Eq, PartialEq)]
struct Counters {
render_attempts: u64,
primary_allowed: u64,
blocked_locked: u64,
blocked_animation: u64,
blocked_not_fullscreen: u64,
render_failures: u64,
sync_failures: u64,
no_damage_results: u64,
queue_failures: u64,
submissions: u64,
scanout_submissions: u64,
composed_submissions: u64,
reused_swapchain_submissions: u64,
allowed_composition_submissions: u64,
cursor_plane_submissions: u64,
no_cursor_plane_submissions: u64,
retired_frames: u64,
retirement_failures: u64,
capture_batches: u64,
capture_time: Duration,
}
#[derive(Debug)]
pub(super) struct Diagnostics {
enabled: bool,
output: String,
started: Instant,
counters: Counters,
}
impl Diagnostics {
pub fn from_environment(output: String) -> Self {
Self::new(
output,
std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some(),
)
}
fn new(output: String, enabled: bool) -> Self {
Self {
enabled,
output,
started: Instant::now(),
counters: Counters::default(),
}
}
pub fn attempt(&mut self, fullscreen: bool, animating: bool, locked: bool) {
if !self.enabled {
return;
}
self.counters.render_attempts += 1;
// Exclusive diagnostic precedence only; the policy itself is unchanged.
if locked {
self.counters.blocked_locked += 1;
} else if animating {
self.counters.blocked_animation += 1;
} else if !fullscreen {
self.counters.blocked_not_fullscreen += 1;
} else {
self.counters.primary_allowed += 1;
}
}
pub fn render_failed(&mut self) {
if self.enabled {
self.counters.render_failures += 1;
}
}
pub fn sync_failed(&mut self) {
if self.enabled {
self.counters.sync_failures += 1;
}
}
pub fn no_damage(&mut self) {
if self.enabled {
self.counters.no_damage_results += 1;
}
}
pub fn queue_result<T, E>(
&mut self,
result: &Result<T, E>,
primary: PrimaryOutcome,
cursor_plane: bool,
primary_allowed: bool,
) {
if !self.enabled {
return;
}
if result.is_err() {
self.counters.queue_failures += 1;
return;
}
self.counters.submissions += 1;
match primary {
PrimaryOutcome::DirectScanout => self.counters.scanout_submissions += 1,
PrimaryOutcome::Composed => self.counters.composed_submissions += 1,
PrimaryOutcome::ReusedSwapchain => self.counters.reused_swapchain_submissions += 1,
}
if primary_allowed && primary != PrimaryOutcome::DirectScanout {
// Smithay selected a swapchain despite permission. This API does not
// expose whether a candidate was tested, or why it was rejected.
self.counters.allowed_composition_submissions += 1;
}
if cursor_plane {
self.counters.cursor_plane_submissions += 1;
} else {
self.counters.no_cursor_plane_submissions += 1;
}
}
pub fn retirement(&mut self, completed: bool, failed: bool) {
if !self.enabled {
return;
}
self.counters.retired_frames += u64::from(completed && !failed);
self.counters.retirement_failures += u64::from(failed);
}
pub fn capture_start(&self, pending: impl FnOnce() -> bool) -> Option<Instant> {
(self.enabled && pending()).then(Instant::now)
}
pub fn capture_finished(&mut self, start: Option<Instant>) {
if let Some(start) = start {
self.record_capture(start.elapsed());
}
}
fn record_capture(&mut self, elapsed: Duration) {
if !self.enabled {
return;
}
self.counters.capture_batches += 1;
self.counters.capture_time += elapsed;
}
pub fn report_if_due(&mut self) {
if !self.enabled || self.started.elapsed() < Duration::from_secs(5) {
return;
}
let c = &self.counters;
tracing::info!(target: "ferese::render", output = %self.output,
interval_ms = self.started.elapsed().as_millis(),
render_attempts = c.render_attempts, primary_allowed = c.primary_allowed,
blocked_locked = c.blocked_locked, blocked_animation = c.blocked_animation,
blocked_not_fullscreen = c.blocked_not_fullscreen,
render_failures = c.render_failures, sync_failures = c.sync_failures,
no_damage_results = c.no_damage_results, queue_failures = c.queue_failures,
submissions = c.submissions, scanout_submissions = c.scanout_submissions,
composed_submissions = c.composed_submissions,
reused_swapchain_submissions = c.reused_swapchain_submissions,
allowed_composition_submissions = c.allowed_composition_submissions,
cursor_plane_submissions = c.cursor_plane_submissions,
no_cursor_plane_submissions = c.no_cursor_plane_submissions,
retired_frames = c.retired_frames, retirement_failures = c.retirement_failures,
capture_batches = c.capture_batches, capture_us = c.capture_time.as_micros(),
"DRM frame outcomes");
self.counters = Counters::default();
self.started = Instant::now();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn primary_classification_does_not_infer_scanout_from_zero_damage() {
assert_eq!(
PrimaryOutcome::classify(true, false),
PrimaryOutcome::DirectScanout
);
assert_eq!(
PrimaryOutcome::classify(false, true),
PrimaryOutcome::Composed
);
assert_eq!(
PrimaryOutcome::classify(false, false),
PrimaryOutcome::ReusedSwapchain
);
}
#[test]
fn policy_reasons_partition_attempts_with_documented_precedence() {
let mut d = Diagnostics::new("test".into(), true);
for fullscreen in [false, true] {
for animating in [false, true] {
for locked in [false, true] {
let allowed_before = d.counters.primary_allowed;
d.attempt(fullscreen, animating, locked);
assert_eq!(
d.counters.primary_allowed > allowed_before,
super::super::planes::frame_flags(fullscreen, animating, locked)
.contains(smithay::backend::drm::compositor::FrameFlags::ALLOW_PRIMARY_PLANE_SCANOUT),
);
}
}
}
assert_eq!(d.counters.render_attempts, 8);
assert_eq!(d.counters.primary_allowed, 1);
assert_eq!(d.counters.blocked_locked, 4);
assert_eq!(d.counters.blocked_animation, 2);
assert_eq!(d.counters.blocked_not_fullscreen, 1);
}
#[test]
fn only_successful_queues_count_outcomes_and_retirement_is_separate() {
let mut d = Diagnostics::new("test".into(), true);
let queued = Err::<(), _>("queue failed");
d.queue_result(&queued, PrimaryOutcome::DirectScanout, true, true);
assert!(
super::super::capture::after_queue(queued, || panic!("failed queues must not capture"))
.is_err()
);
assert_eq!(d.counters.submissions, 0);
assert_eq!(d.counters.scanout_submissions, 0);
assert_eq!(d.counters.cursor_plane_submissions, 0);
assert_eq!(d.counters.queue_failures, 1);
for (primary, cursor, allowed) in [
(PrimaryOutcome::DirectScanout, true, true),
(PrimaryOutcome::Composed, false, true),
(PrimaryOutcome::ReusedSwapchain, true, false),
] {
d.queue_result(&Ok::<(), ()>(()), primary, cursor, allowed);
}
assert_eq!(d.counters.submissions, 3);
assert_eq!(d.counters.scanout_submissions, 1);
assert_eq!(d.counters.composed_submissions, 1);
assert_eq!(d.counters.reused_swapchain_submissions, 1);
assert_eq!(d.counters.allowed_composition_submissions, 1);
assert_eq!(d.counters.cursor_plane_submissions, 2);
assert_eq!(d.counters.no_cursor_plane_submissions, 1);
assert_eq!(d.counters.retired_frames, 0);
d.retirement(false, false);
d.retirement(false, true);
assert_eq!(d.counters.retired_frames, 0);
d.retirement(true, false);
assert_eq!(d.counters.retired_frames, 1);
assert_eq!(d.counters.retirement_failures, 1);
}
#[test]
fn no_damage_and_failures_never_count_as_submissions() {
let mut d = Diagnostics::new("test".into(), true);
d.no_damage();
d.render_failed();
d.sync_failed();
d.record_capture(Duration::from_micros(123));
assert_eq!(d.counters.no_damage_results, 1);
assert_eq!(d.counters.render_failures, 1);
assert_eq!(d.counters.sync_failures, 1);
assert_eq!(d.counters.submissions, 0);
assert_eq!(d.counters.capture_time, Duration::from_micros(123));
assert_eq!(d.counters.capture_batches, 1);
}
#[test]
fn disabled_accounting_is_inert_and_outputs_are_independent() {
let mut d = Diagnostics::new("disabled".into(), false);
d.attempt(true, false, false);
d.render_failed();
d.sync_failed();
d.no_damage();
d.queue_result(&Ok::<(), ()>(()), PrimaryOutcome::DirectScanout, true, true);
d.retirement(true, false);
d.record_capture(Duration::from_secs(1));
assert_eq!(d.counters, Counters::default());
assert!(
d.capture_start(|| panic!("disabled diagnostics must not inspect capture requests"))
.is_none()
);
let mut other = Diagnostics::new("other".into(), true);
other.no_damage();
assert!(other.capture_start(|| false).is_none());
assert_eq!(d.counters.no_damage_results, 0);
assert_eq!(other.counters.no_damage_results, 1);
other.started = Instant::now() - Duration::from_secs(6);
other.report_if_due();
assert_eq!(other.counters, Counters::default());
}
}
+548
View File
@@ -0,0 +1,548 @@
//! Conservative output feedback for the existing primary-plane policy.
use std::collections::{HashMap, HashSet};
use smithay::backend::allocator::Format;
use smithay::backend::allocator::format::FormatSet;
use smithay::backend::renderer::element::{
Element, Id, RenderElement, RenderElementPresentationState, RenderElementStates, RenderingReason,
};
use smithay::backend::renderer::gles::GlesRenderer;
use smithay::backend::renderer::utils::with_renderer_surface_state;
use smithay::output::Output;
use smithay::reexports::wayland_protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_feedback_v1::TrancheFlags;
use smithay::utils::{Physical, Rectangle, Scale};
use smithay::wayland::dmabuf::{DmabufFeedback, DmabufFeedbackBuilder, DmabufGlobal};
use crate::Ferese;
use crate::render::AnimatedWindowRenderElement;
fn compatible(render: &FormatSet, primary: &FormatSet, observed: Format) -> bool {
// Do not equate implicit and explicit modifiers, alpha and opaque FourCCs,
// or formats from cursor/overlay planes. Exact intersection preserves import.
render.contains(&observed) && primary.contains(&observed)
}
pub(super) struct OutputFeedback {
render_device: libc::dev_t,
scanout_device: libc::dev_t,
render_formats: FormatSet,
primary_formats: FormatSet,
observed: Option<Format>,
preferred: Option<DmabufFeedback>,
visible: HashSet<Id>,
candidates: HashSet<Id>,
}
impl OutputFeedback {
pub fn new(
render_device: libc::dev_t,
scanout_device: libc::dev_t,
render_formats: FormatSet,
primary_formats: FormatSet,
) -> Self {
Self {
render_device,
scanout_device,
render_formats,
primary_formats,
observed: None,
preferred: None,
visible: HashSet::new(),
candidates: HashSet::new(),
}
}
pub fn invalidate(&mut self) {
self.observed = None;
self.preferred = None;
self.visible.clear();
self.candidates.clear();
}
fn observe_format(&mut self, format: Option<Format>) {
let Some(format) = format else {
return;
};
if self.observed == Some(format) {
return;
}
self.observed = Some(format);
self.preferred = None;
if compatible(&self.render_formats, &self.primary_formats, format) {
// Restrict the preference to the observed primary format/modifier:
// without ALLOW_PRIMARY_PLANE_SCANOUT_ANY Smithay compares the full
// format with the swapchain. Renderer-only formats remain in fallback.
match DmabufFeedbackBuilder::new(
self.render_device,
self.render_formats.iter().copied(),
)
.add_preference_tranche(self.scanout_device, TrancheFlags::Scanout, [format], 4..=6)
.build()
{
Ok(feedback) => self.preferred = Some(feedback),
Err(error) => {
tracing::warn!(%error, "output DMA-BUF feedback unavailable; retaining render fallback")
}
}
}
}
pub fn observed_frame(
&mut self,
format: Option<Format>,
states: &RenderElementStates,
candidates: HashSet<Id>,
) {
self.observe_format(format);
self.visible = states
.states
.iter()
.filter(|(_, state)| crate::display_presentation::is_visible(**state))
.map(|(id, _)| id.clone())
.collect();
self.candidates = candidates
.into_iter()
.filter(|id| {
states.states.get(id).is_some_and(|state| {
crate::display_presentation::is_visible(*state)
&& state.presentation_state
!= RenderElementPresentationState::Rendering {
reason: Some(RenderingReason::ScanoutFailed),
}
})
})
.collect();
}
}
pub(super) fn candidates(
renderer: &mut GlesRenderer,
elements: &[AnimatedWindowRenderElement],
output: &Output,
allowed: bool,
) -> HashSet<Id> {
if !allowed {
return HashSet::new();
}
let Some(mode) = output.current_mode() else {
return HashSet::new();
};
let bounds = Rectangle::from_size(output.current_transform().transform_size(mode.size));
let scale = output.current_scale().fractional_scale().into();
elements
.iter()
.filter(|&element| {
candidate_geometry(element, bounds, scale, || {
element.underlying_storage(renderer).is_some()
})
})
.map(|element| element.id().clone())
.collect()
}
fn candidate_geometry(
element: &impl Element,
bounds: Rectangle<i32, Physical>,
scale: Scale<f64>,
has_storage: impl FnOnce() -> bool,
) -> bool {
let geometry = element.geometry(scale);
if element.alpha() != 1.0 || !geometry.contains_rect(bounds) {
return false;
}
// A single opaque rectangle covering the output is deliberately more
// conservative than reconstructing an arbitrary union of client regions.
element
.opaque_regions(scale)
.iter()
.any(|region| Rectangle::new(region.loc + geometry.loc, region.size).contains_rect(bounds))
&& has_storage()
}
#[derive(Clone)]
struct OutputView<'a> {
global: DmabufGlobal,
feedback: &'a OutputFeedback,
eligible: Option<Id>,
}
fn select<'a>(
global: DmabufGlobal,
surface: &Id,
outputs: impl IntoIterator<Item = OutputView<'a>>,
) -> Option<&'a DmabufFeedback> {
let mut visible = outputs
.into_iter()
.filter(|output| output.feedback.visible.contains(surface));
let output = visible.next()?;
// A surface visible on multiple outputs gets render fallback rather than
// whichever output happened to render last. Cross-GPU import is not assumed.
if visible.next().is_some()
|| output.global != global
|| output.eligible.as_ref() != Some(surface)
|| !output.feedback.candidates.contains(surface)
{
return None;
}
output.feedback.preferred.as_ref()
}
pub(crate) fn refresh(state: &mut Ferese) {
if state.dmabuf_feedback.requested().next().is_none() {
return;
}
let mut preferred = HashMap::new();
if let Some(backend) = state.direct_backend.as_ref() {
// Eligibility belongs to an output, so compute it once for all subscribers.
let outputs = backend
.devices
.values()
.flat_map(|device| device.outputs.values().map(move |output| (device, output)))
.filter(|(_, output)| state.output_id(&output.output).is_some());
let views: Vec<_> = outputs
.map(|(device, output)| {
let eligible = (backend.can_render()
&& device.drm.is_active()
&& output.power.can_render()
&& output.mirror_source.is_none()
&& !state.session_lock.active()
&& !state.output_has_animations(&output.output)
&& state.output_has_fullscreen(&output.output))
.then(|| {
state
.output_id(&output.output)
.and_then(|id| state.output_workspaces.active_workspace(id))
.and_then(|workspace| state.workspaces.workspace(workspace))
.and_then(|workspace| workspace.fullscreen)
.filter(|id| {
state
.current_window_presentation(*id)
.is_some_and(|presentation| {
presentation.alpha() == 1.0 && presentation.dim == 0.0
})
})
.and_then(|id| state.windows.window(id))
.and_then(|window| window.toplevel())
.filter(|toplevel| {
with_renderer_surface_state(toplevel.wl_surface(), |state| {
state.buffer().is_some()
})
.unwrap_or(false)
})
.map(|toplevel| Id::from(toplevel.wl_surface()))
})
.flatten();
OutputView {
global: device.dmabuf_global,
feedback: &output.dmabuf_feedback,
eligible,
}
})
.collect();
if views.iter().any(|view| view.eligible.is_some()) {
for (surface, global) in state.dmabuf_feedback.requested() {
let id = Id::from(surface);
if let Some(feedback) = select(global, &id, views.iter().cloned()) {
preferred.insert(surface.clone(), feedback.clone());
}
}
}
}
state.dmabuf_feedback.refresh(&preferred);
}
#[cfg(test)]
mod tests {
use super::*;
use smithay::backend::allocator::{Fourcc, Modifier};
use smithay::backend::renderer::element::{RenderElementPresentationState, RenderElementState};
use smithay::reexports::wayland_server::Display;
use smithay::wayland::dmabuf::DmabufState;
fn format(code: Fourcc, modifier: Modifier) -> Format {
Format { code, modifier }
}
fn formats(values: &[Format]) -> FormatSet {
values.iter().copied().collect()
}
fn output(render: &[Format], primary: &[Format]) -> OutputFeedback {
OutputFeedback::new(10, 20, formats(render), formats(primary))
}
fn create_globals() -> (Display<Ferese>, DmabufGlobal, DmabufGlobal) {
let display = Display::new().unwrap();
let mut state = DmabufState::new();
let feedback = DmabufFeedbackBuilder::new(10, [format(Fourcc::Argb8888, Modifier::Linear)])
.build()
.unwrap();
let global =
state.create_global_with_default_feedback::<Ferese>(&display.handle(), &feedback);
let other =
state.create_global_with_default_feedback::<Ferese>(&display.handle(), &feedback);
(display, global, other)
}
fn visible(output: &mut OutputFeedback, id: &Id, f: Format) {
let mut states = RenderElementStates::default();
states.states.insert(
id.clone(),
RenderElementState {
needs_capture: false,
visible_area: 100,
presentation_state: RenderElementPresentationState::Rendering { reason: None },
},
);
output.observed_frame(Some(f), &states, HashSet::from([id.clone()]));
}
#[test]
fn preferences_require_exact_renderer_and_primary_intersection() {
let linear = format(Fourcc::Argb8888, Modifier::Linear);
let implicit = format(Fourcc::Argb8888, Modifier::Invalid);
let opaque = format(Fourcc::Xrgb8888, Modifier::Linear);
assert!(compatible(
&formats(&[linear, implicit]),
&formats(&[linear]),
linear
));
assert!(!compatible(
&formats(&[linear]),
&formats(&[implicit]),
linear
));
assert!(!compatible(
&formats(&[linear]),
&formats(&[opaque]),
linear
));
assert!(!compatible(
&formats(&[linear]),
&formats(&[implicit]),
implicit
));
assert!(!compatible(
&formats(&[opaque]),
&formats(&[linear]),
linear
));
}
#[test]
fn observed_format_limits_preference_and_retains_renderer_fallback() {
let linear = format(Fourcc::Argb8888, Modifier::Linear);
let other = format(Fourcc::Abgr8888, Modifier::Linear);
let mut o = output(&[linear, other], &[linear, other]);
assert!(o.preferred.is_none());
o.observe_format(Some(linear));
let expected = DmabufFeedbackBuilder::new(10, [linear, other])
.add_preference_tranche(20, TrancheFlags::Scanout, [linear], 4..=6)
.build()
.unwrap();
assert_eq!(o.preferred.as_ref(), Some(&expected));
o.observe_format(Some(other));
assert_ne!(o.preferred.as_ref(), Some(&expected));
o.observe_format(Some(format(Fourcc::Rgb565, Modifier::Linear)));
assert!(o.preferred.is_none());
}
#[test]
fn migration_multi_output_cross_gpu_and_policy_changes_fall_back() {
let (_display, global, other_global) = create_globals();
let id = Id::new();
let f = format(Fourcc::Argb8888, Modifier::Linear);
let mut a = output(&[f], &[f]);
let mut b = output(&[f], &[f]);
visible(&mut a, &id, f);
visible(&mut b, &id, f);
fn view(
global: DmabufGlobal,
feedback: &OutputFeedback,
eligible: Option<Id>,
) -> OutputView<'_> {
OutputView {
global,
feedback,
eligible,
}
}
assert!(select(global, &id, [view(global, &a, Some(id.clone()))]).is_some());
assert!(select(global, &id, [view(global, &a, None)]).is_none());
assert!(
select(
global,
&id,
[
view(global, &a, Some(id.clone())),
view(global, &b, Some(id.clone()))
]
)
.is_none()
);
assert!(select(other_global, &id, [view(global, &a, Some(id.clone()))]).is_none());
a.invalidate();
assert!(select(global, &id, [view(global, &a, Some(id.clone()))]).is_none());
assert!(
select(
global,
&id,
[
view(global, &a, Some(id.clone())),
view(global, &b, Some(id.clone()))
]
)
.is_some()
);
b.candidates.clear();
assert!(select(global, &id, [view(global, &b, Some(id.clone()))]).is_none());
}
#[test]
fn reconfiguration_invalidates_observations_until_a_new_frame() {
let (_display, global, _) = create_globals();
let id = Id::new();
let f = format(Fourcc::Argb8888, Modifier::Linear);
let mut o = output(&[f], &[f]);
visible(&mut o, &id, f);
o.invalidate();
assert!(
o.observed.is_none()
&& o.preferred.is_none()
&& o.visible.is_empty()
&& o.candidates.is_empty()
);
o.observe_format(None);
assert!(o.preferred.is_none());
visible(&mut o, &id, f);
assert!(
select(
global,
&id,
[OutputView {
global,
feedback: &o,
eligible: Some(id.clone())
}]
)
.is_some()
);
}
#[test]
fn candidate_geometry_requires_covering_opaque_content_and_storage() {
use smithay::backend::renderer::element::Kind;
use smithay::backend::renderer::element::solid::SolidColorRenderElement;
use smithay::backend::renderer::utils::CommitCounter;
use smithay::utils::Transform;
for transform in [Transform::Normal, Transform::_90] {
let bounds = Rectangle::from_size(transform.transform_size((100, 60).into()));
for scale in [1.0, 1.25, 2.0] {
let solid = |geometry, alpha| {
SolidColorRenderElement::new(
Id::new(),
geometry,
CommitCounter::default(),
[1., 1., 1., alpha],
Kind::Unspecified,
)
};
let opaque = solid(bounds, 1.0);
assert!(candidate_geometry(&opaque, bounds, scale.into(), || true));
assert!(
!candidate_geometry(&opaque, bounds, scale.into(), || false),
"clipped storage must remain hidden"
);
let faded = solid(bounds, 0.8);
assert!(!candidate_geometry(
&faded,
bounds,
scale.into(),
|| panic!("faded elements must not probe storage")
));
let mut partial = bounds;
partial.size.w -= 1;
assert!(!candidate_geometry(
&solid(partial, 1.0),
bounds,
scale.into(),
|| panic!("partial content must not probe storage")
));
}
}
}
#[test]
fn failed_scanout_and_occlusion_withdraw_preferences_but_format_hints_can_recover() {
let (_display, global, _) = create_globals();
let id = Id::new();
let f = format(Fourcc::Argb8888, Modifier::Linear);
let mut o = output(&[f], &[f]);
for (state, expected) in [
(
RenderElementState {
needs_capture: false,
visible_area: 100,
presentation_state: RenderElementPresentationState::Rendering {
reason: Some(RenderingReason::FormatUnsupported),
},
},
true,
),
(
RenderElementState {
needs_capture: false,
visible_area: 100,
presentation_state: RenderElementPresentationState::Rendering {
reason: Some(RenderingReason::ScanoutFailed),
},
},
false,
),
(
RenderElementState {
needs_capture: false,
visible_area: 0,
presentation_state: RenderElementPresentationState::Skipped,
},
false,
),
(
RenderElementState {
needs_capture: false,
visible_area: 100,
presentation_state: RenderElementPresentationState::ZeroCopy,
},
true,
),
] {
let states = RenderElementStates {
states: HashMap::from([(id.clone(), state)]),
};
o.observed_frame(Some(f), &states, HashSet::from([id.clone()]));
assert_eq!(
select(
global,
&id,
[OutputView {
global,
feedback: &o,
eligible: Some(id.clone())
}]
)
.is_some(),
expected
);
}
}
#[test]
fn queue_failure_never_publishes_prepared_feedback_observations() {
let f = format(Fourcc::Argb8888, Modifier::Linear);
let id = Id::new();
let mut o = output(&[f], &[f]);
let failed = super::super::capture::after_queue(Err::<(), _>("queue failed"), || {
visible(&mut o, &id, f)
});
assert!(failed.is_err());
assert!(o.visible.is_empty() && o.preferred.is_none());
super::super::capture::after_queue(Ok::<(), ()>(()), || visible(&mut o, &id, f)).unwrap();
assert!(o.visible.contains(&id) && o.preferred.is_some());
}
}
+442
View File
@@ -0,0 +1,442 @@
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use std::io;
use std::time::{Duration, Instant};
use smithay::backend::drm::{DrmDevice, DrmDeviceFd};
use smithay::output::Output;
use smithay::reexports::drm::control::{Device as _, crtc};
use smithay::reexports::wayland_protocols_wlr::gamma_control::v1::server::zwlr_gamma_control_v1::ZwlrGammaControlV1;
use crate::Ferese;
use crate::gamma::Ramp;
use ferese_config::night_light::Settings;
use ferese_ipc::night_light::Status;
const TRANSITION_DURATION: Duration = Duration::from_secs(1);
const TRANSITION_INTERVAL: Duration = Duration::from_millis(16);
struct Transition {
from: Ramp,
started: Instant,
duration: Duration,
}
// A single owner determines both the desired ramp and who loses control on failure.
enum Source {
Original,
Client {
control: ZwlrGammaControlV1,
ramp: Ramp,
},
NightLight {
temperature: u16,
ramp: Ramp,
},
}
#[derive(Default)]
pub(super) enum State {
#[default]
Unprobed,
Unsupported,
Ready(Box<Gamma>),
}
impl State {
fn probe(&mut self, drm: &DrmDevice, crtc: crtc::Handle) -> Option<&mut Gamma> {
if matches!(self, Self::Unprobed) {
*self = match Gamma::new(drm, crtc) {
Ok(gamma) => Self::Ready(Box::new(gamma)),
Err(error) => {
tracing::debug!(%error, ?crtc, "output gamma unavailable");
Self::Unsupported
}
};
}
self.controller_mut()
}
fn controller(&self) -> Option<&Gamma> {
match self {
Self::Ready(gamma) => Some(gamma),
_ => None,
}
}
fn controller_mut(&mut self) -> Option<&mut Gamma> {
match self {
Self::Ready(gamma) => Some(gamma),
_ => None,
}
}
pub(super) fn suspend(&mut self) {
if let Some(gamma) = self.controller_mut() {
gamma.suspend();
}
}
pub(super) fn retry_probe(&mut self) {
if matches!(self, Self::Unsupported) {
*self = Self::Unprobed;
}
}
}
pub(super) struct Gamma {
fd: DrmDeviceFd,
crtc: crtc::Handle,
original: Ramp,
current: Ramp,
transition: Option<Transition>,
source: Source,
suspended: bool,
modified: bool,
failed: bool,
}
impl Gamma {
fn new(drm: &DrmDevice, crtc: crtc::Handle) -> io::Result<Self> {
let mut original = Ramp::zeroed(drm.get_crtc(crtc)?.gamma_length() as usize)?;
let (red, green, blue) = original.channels_mut();
drm.get_gamma(crtc, red, green, blue)?;
Ok(Self {
fd: drm.device_fd().clone(),
crtc,
current: original.clone(),
transition: None,
original,
source: Source::Original,
suspended: false,
modified: false,
failed: false,
})
}
fn write(&self, ramp: &Ramp) -> io::Result<()> {
if self.fd.get_crtc(self.crtc)?.gamma_length() as usize != ramp.size() {
return Err(io::ErrorKind::InvalidData.into());
}
let (red, green, blue) = ramp.channels();
self.fd.set_gamma(self.crtc, red, green, blue)
}
fn restore(&mut self) {
if self.suspended || !self.modified {
return;
}
match self.write(&self.original) {
Ok(()) => {
self.modified = false;
self.current = self.original.clone();
}
Err(error) => tracing::warn!(%error, ?self.crtc, "could not restore output gamma"),
}
}
fn replace_source(&mut self, source: Source) {
if let Source::Client { control, .. } = std::mem::replace(&mut self.source, source) {
control.failed();
}
}
fn configure(&mut self, settings: Settings, duration: Duration, now: Instant) {
if settings.enabled {
if matches!(self.source, Source::NightLight { temperature, .. } if temperature == settings.temperature)
{
return;
}
self.replace_source(Source::NightLight {
temperature: settings.temperature,
ramp: self.original.at_temperature(settings.temperature),
});
} else if matches!(self.source, Source::NightLight { .. }) {
self.source = Source::Original;
} else {
return;
}
let target = match &self.source {
Source::Original => &self.original,
Source::NightLight { ramp, .. } | Source::Client { ramp, .. } => ramp,
};
self.transition =
(!duration.is_zero() && !self.suspended && self.current != *target).then(|| {
Transition {
from: self.current.clone(),
started: now,
duration,
}
});
}
fn owns(&self, owner: &ZwlrGammaControlV1) -> bool {
matches!(&self.source, Source::Client { control, .. } if control == owner)
}
pub(super) fn suspend(&mut self) {
self.transition = None;
self.restore();
self.suspended = true;
}
fn resume(&mut self, now: Instant) {
self.suspended = false;
let ramp = match &self.source {
Source::Original if !self.modified && self.transition.is_none() => {
self.failed = false;
return;
}
Source::Original => &self.original,
Source::Client { ramp, .. } | Source::NightLight { ramp, .. } => ramp,
};
let animated;
let ramp = if let Some(transition) = &self.transition {
let progress = now
.saturating_duration_since(transition.started)
.as_secs_f64()
/ transition.duration.as_secs_f64();
if progress >= 1. {
self.transition = None;
ramp
} else {
let eased = progress * progress * (3. - 2. * progress);
animated = transition.from.interpolate(ramp, eased);
&animated
}
} else {
ramp
};
// Even a failed driver call may need the original table restored.
self.modified |= ramp != &self.original;
match self.write(ramp) {
Ok(()) => {
self.current = ramp.clone();
self.modified = self.current != self.original;
self.failed = false;
}
Err(error) => {
self.transition = None;
self.failed = true;
tracing::warn!(%error, ?self.crtc, "could not apply output gamma");
if matches!(self.source, Source::Client { .. }) {
self.replace_source(Source::Original);
}
self.restore();
}
}
}
}
impl Drop for Gamma {
fn drop(&mut self) {
self.replace_source(Source::Original);
self.restore();
}
}
pub(crate) fn acquire(
state: &mut Ferese,
target: &Output,
owner: &ZwlrGammaControlV1,
) -> Option<usize> {
// Reconcile a just-reloaded configuration before assigning ownership.
sync_night_light(state);
if state.night_light.enabled {
return None;
}
let duration = state.animation_duration(TRANSITION_DURATION);
let backend = state
.direct_backend
.as_mut()
.filter(|backend| backend.active)?;
for device in backend
.devices
.values_mut()
.filter(|device| device.drm.is_active())
{
for (crtc, output) in &mut device.outputs {
if &output.output != target || output.global.is_none() {
continue;
}
let gamma = output.gamma.probe(&device.drm, *crtc)?;
gamma.configure(state.night_light, duration, Instant::now());
if !matches!(gamma.source, Source::Original) {
return None;
}
gamma.transition = None;
gamma.source = Source::Client {
control: owner.clone(),
ramp: gamma.original.clone(),
};
return Some(gamma.original.size());
}
}
None
}
pub(crate) fn sync_night_light(state: &mut Ferese) {
if !state.gamma_dirty {
return;
}
if !state
.direct_backend
.as_ref()
.is_some_and(|backend| backend.active)
{
return;
}
state.gamma_dirty = false;
if advance_night_light(state, true) && !state.gamma_timer_pending {
let result = state.loop_handle.insert_source(
Timer::from_duration(TRANSITION_INTERVAL),
|_, _, state| {
if advance_night_light(state, false) {
TimeoutAction::ToDuration(TRANSITION_INTERVAL)
} else {
state.gamma_timer_pending = false;
TimeoutAction::Drop
}
},
);
match result {
Ok(_) => state.gamma_timer_pending = true,
Err(error) => {
tracing::warn!(%error, "could not schedule night-light transition");
if let Some(backend) = &mut state.direct_backend {
for device in backend
.devices
.values_mut()
.filter(|device| device.drm.is_active())
{
for output in device
.outputs
.values_mut()
.filter(|output| !output.power.needs_wake())
{
if let Some(gamma) = output.gamma.controller_mut() {
gamma.transition = None;
gamma.resume(Instant::now());
}
}
}
}
}
}
}
}
fn advance_night_light(state: &mut Ferese, force: bool) -> bool {
let duration = state.animation_duration(TRANSITION_DURATION);
let now = Instant::now();
let Some(backend) = state
.direct_backend
.as_mut()
.filter(|backend| backend.active)
else {
return false;
};
let mut pending = false;
for device in backend
.devices
.values_mut()
.filter(|device| device.drm.is_active())
{
for (crtc, output) in &mut device.outputs {
let ready = !output.power.needs_wake();
let gamma = if ready {
output.gamma.probe(&device.drm, *crtc)
} else {
output.gamma.controller_mut()
};
if let Some(gamma) = gamma {
gamma.configure(state.night_light, duration, now);
let update = force || gamma.transition.is_some();
if duration.is_zero() {
gamma.transition = None;
}
if ready && update {
gamma.resume(now);
pending |= gamma.transition.is_some();
}
}
}
}
pending
}
pub(crate) fn night_light_status(state: &Ferese) -> Status {
let mut status = Status {
enabled: state.night_light.enabled,
temperature: state.night_light.temperature,
..Default::default()
};
if let Some(backend) = &state.direct_backend {
for device in backend.devices.values() {
for output in device.outputs.values() {
if let Some(gamma) = output.gamma.controller() {
status.supported_outputs += 1;
status.failed_outputs += usize::from(gamma.failed);
status.active_outputs += usize::from(
backend.active
&& !output.power.needs_wake()
&& matches!(gamma.source, Source::NightLight { .. })
&& !gamma.failed
&& !gamma.suspended,
);
}
}
}
}
status
}
fn owned<'a>(
state: &'a mut Ferese,
target: &Output,
owner: &ZwlrGammaControlV1,
) -> Option<(&'a mut Gamma, bool)> {
let duration = state.animation_duration(TRANSITION_DURATION);
let backend = state.direct_backend.as_mut()?;
for device in backend.devices.values_mut() {
for output in device.outputs.values_mut() {
if &output.output == target {
let ready = backend.active && device.drm.is_active() && !output.power.needs_wake();
let gamma = output.gamma.controller_mut()?;
gamma.configure(state.night_light, duration, Instant::now());
return gamma.owns(owner).then_some((gamma, ready));
}
}
}
None
}
pub(crate) fn size(
state: &mut Ferese,
target: &Output,
owner: &ZwlrGammaControlV1,
) -> Option<usize> {
owned(state, target, owner).map(|(gamma, _)| gamma.original.size())
}
pub(crate) fn set(state: &mut Ferese, target: &Output, owner: &ZwlrGammaControlV1, ramp: Ramp) {
if let Some((gamma, ready)) = owned(state, target, owner) {
gamma.transition = None;
gamma.source = Source::Client {
control: owner.clone(),
ramp,
};
if ready {
gamma.resume(Instant::now());
}
}
}
pub(crate) fn release(state: &mut Ferese, target: &Output, owner: &ZwlrGammaControlV1) {
if let Some((gamma, ready)) = owned(state, target, owner) {
gamma.transition = None;
gamma.source = Source::Original;
if ready {
gamma.resume(Instant::now());
}
}
}
+546
View File
@@ -0,0 +1,546 @@
//! Resume reads are asynchronous; newer switch events win over old replies.
use std::sync::{Arc, Mutex, mpsc};
use super::{
Duration, Error, Ferese, RegistrationToken, TimeoutAction, Timer, cancel_output_timers,
reconcile_outputs,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct Probe {
serial: u64,
revision: u64,
reactivate: bool,
wake: bool,
}
#[derive(Default)]
pub(super) struct LidState {
value: Option<bool>,
revision: u64,
serial: u64,
pending: Option<Probe>,
}
impl LidState {
pub fn closed(&self) -> bool {
self.value == Some(true)
}
pub fn pending(&self) -> bool {
self.pending.is_some()
}
pub fn observe(&mut self, closed: bool) -> bool {
let changed = self.value != Some(closed);
self.value = Some(closed);
self.revision = self.revision.wrapping_add(1);
changed
}
fn begin(&mut self, reactivate: bool, wake: bool) -> Probe {
self.serial = self.serial.wrapping_add(1);
let probe = Probe {
serial: self.serial,
revision: self.revision,
reactivate: reactivate || self.pending.is_some_and(|pending| pending.reactivate),
wake: wake || self.pending.is_some_and(|pending| pending.wake),
};
self.pending = Some(probe);
probe
}
fn complete(&mut self, probe: Probe, value: Option<bool>) -> Option<bool> {
if self.pending != Some(probe) {
return None;
}
self.pending = None;
if probe.revision == self.revision
&& let Some(value) = value
{
self.observe(value);
}
Some(probe.reactivate)
}
pub fn pause(&mut self) {
self.pending = None;
}
}
struct InhibitorLease<T> {
active: bool,
generation: u64,
descriptor: Option<T>,
suspend: bool,
}
impl<T> Default for InhibitorLease<T> {
fn default() -> Self {
Self {
active: false,
generation: 0,
descriptor: None,
suspend: false,
}
}
}
impl<T> InhibitorLease<T> {
fn activate(&mut self, active: bool) -> u64 {
self.generation = self.generation.wrapping_add(1);
self.active = active;
self.suspend = false;
// Closing here releases logind immediately, independently of worker progress.
self.descriptor = None;
self.generation
}
fn accept(&mut self, generation: u64, descriptor: T) {
if self.active && self.generation == generation {
self.descriptor = Some(descriptor);
}
}
}
enum PolicyRequest {
Activate(u64),
Suspend,
}
pub(super) struct Reader {
requests: mpsc::Sender<Probe>,
suspend: mpsc::Sender<PolicyRequest>,
lease: Arc<Mutex<InhibitorLease<zbus::zvariant::OwnedFd>>>,
suspend_requested: bool,
pub deadline: Option<RegistrationToken>,
}
fn read_logind(connection: &zbus::blocking::Connection) -> zbus::Result<bool> {
// A direct Get avoids retaining a property cache across suspend.
let value: zbus::zvariant::OwnedValue = connection
.call_method(
Some("org.freedesktop.login1"),
"/org/freedesktop/login1",
Some("org.freedesktop.DBus.Properties"),
"Get",
&("org.freedesktop.login1.Manager", "LidClosed"),
)?
.body()
.deserialize()?;
bool::try_from(value).map_err(Into::into)
}
fn read_acpi() -> Option<bool> {
let mut state = None;
for entry in std::fs::read_dir("/proc/acpi/button/lid").ok()?.flatten() {
let Ok(text) = std::fs::read_to_string(entry.path().join("state")) else {
continue;
};
match text.split_whitespace().last() {
Some("closed") => return Some(true),
Some("open") => state = Some(false),
_ => {}
}
}
state
}
impl Reader {
pub fn set_active(&mut self, active: bool) {
let generation = self
.lease
.lock()
.expect("lid policy lease must remain consistent across workers")
.activate(active);
self.suspend_requested = false;
if active {
let _ = self.suspend.send(PolicyRequest::Activate(generation));
}
}
pub fn start(state: &Ferese) -> Result<Self, Box<dyn Error>> {
let (suspend, suspend_requests) = mpsc::channel::<PolicyRequest>();
let lease = Arc::new(Mutex::new(InhibitorLease::default()));
let worker_lease = lease.clone();
std::thread::Builder::new()
.name("ferese-lid-policy".into())
.spawn(move || {
let connection = zbus::blocking::connection::Builder::system()
.and_then(|builder| builder.method_timeout(Duration::from_secs(5)).build());
let Ok(connection) = connection else { return };
while let Ok(request) = suspend_requests.recv() {
if let PolicyRequest::Activate(generation) = request {
let current = worker_lease
.lock()
.expect("lid policy lease must remain consistent across workers");
if !current.active || current.generation != generation {
continue;
}
drop(current);
let inhibitor = connection
.call_method(
Some("org.freedesktop.login1"),
"/org/freedesktop/login1",
Some("org.freedesktop.login1.Manager"),
"Inhibit",
&(
"handle-lid-switch",
"Ferese",
"Reconcile monitors before lid action",
"block",
),
)
.and_then(|reply| {
reply.body().deserialize::<zbus::zvariant::OwnedFd>()
});
match inhibitor {
Ok(descriptor) => worker_lease
.lock()
.expect("lid policy lease must remain consistent across workers")
.accept(generation, descriptor),
Err(error) => tracing::warn!(%error, "could not own logind lid policy"),
}
continue;
}
let current = worker_lease
.lock()
.expect("lid policy lease must remain consistent across workers");
if !current.active || !current.suspend {
continue;
}
drop(current);
if read_logind(&connection).ok() != Some(true) {
continue;
}
let allowed = connection
.call_method(
Some("org.freedesktop.login1"),
"/org/freedesktop/login1",
Some("org.freedesktop.DBus.Properties"),
"Get",
&("org.freedesktop.login1.Manager", "BlockInhibited"),
)
.and_then(|reply| reply.body().deserialize::<zbus::zvariant::OwnedValue>())
.and_then(|value| String::try_from(value).map_err(Into::into));
if allowed
.as_ref()
.is_ok_and(|inhibitors| inhibitors.split(':').any(|kind| kind == "sleep"))
{
tracing::debug!("lid suspend prevented by sleep inhibitor");
continue;
}
let current = worker_lease
.lock()
.expect("lid policy lease must remain consistent across workers");
let requested = current.active && current.suspend;
drop(current);
if !requested {
continue;
}
if let Err(error) = connection.call_method(
Some("org.freedesktop.login1"),
"/org/freedesktop/login1",
Some("org.freedesktop.login1.Manager"),
"Suspend",
&(false,),
) {
tracing::warn!(%error, "logind rejected lid suspend");
}
}
worker_lease
.lock()
.expect("lid policy lease must remain consistent across workers")
.activate(false);
})?;
let (requests, receiver) = mpsc::channel::<Probe>();
let (replies, events) = smithay::reexports::calloop::channel::channel();
state.loop_handle.insert_source(events, |event, _, state| {
if let smithay::reexports::calloop::channel::Event::Msg((probe, value)) = event {
complete_refresh(state, probe, value);
}
})?;
std::thread::Builder::new()
.name("ferese-lid-reader".into())
.spawn(move || {
while let Ok(mut probe) = receiver.recv() {
for newer in receiver.try_iter() {
probe = newer;
}
let value = zbus::blocking::connection::Builder::system()
.and_then(|builder| builder.method_timeout(Duration::from_secs(2)).build())
.and_then(|connection| read_logind(&connection))
.ok()
.or_else(read_acpi);
if replies.send((probe, value)).is_err() {
break;
}
}
})?;
let mut reader = Self {
requests,
suspend,
lease,
suspend_requested: false,
deadline: None,
};
reader.set_active(true);
Ok(reader)
}
}
pub(super) fn request_refresh(state: &mut Ferese, reactivate: bool) {
request(state, reactivate, true);
}
/// Listener recovery reconciles hardware without resetting idle activity.
pub(super) fn request_reconcile(state: &mut Ferese) {
request(state, false, false);
}
fn request(state: &mut Ferese, reactivate: bool, wake: bool) {
let Some(backend) = state.direct_backend.as_mut() else {
return;
};
if !backend.active {
backend.topology.dirty = true;
return;
}
if let Some(token) = backend.lid_reader.deadline.take() {
state.loop_handle.remove(token);
}
// A timer that fires while readiness is gated would otherwise leave a
// stale registration token on an unchanged output.
for device in backend.devices.values_mut() {
for output in device.outputs.values_mut() {
cancel_output_timers(&state.loop_handle, output);
}
}
backend.lid_reader.suspend_requested = false;
backend
.lid_reader
.lease
.lock()
.expect("lid policy lease must remain consistent across workers")
.suspend = false;
let probe = backend.lid.begin(reactivate, wake);
let sent = backend.lid_reader.requests.send(probe).is_ok();
let timer = state.loop_handle.insert_source(
Timer::from_duration(Duration::from_secs(3)),
move |_, _, state| {
if let Some(backend) = state.direct_backend.as_mut() {
backend.lid_reader.deadline = None;
}
tracing::warn!("lid refresh deadline elapsed; retaining the latest observed state");
complete_refresh(state, probe, None);
TimeoutAction::Drop
},
);
if sent && let Ok(token) = timer {
state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.lid_reader
.deadline = Some(token);
} else {
if let Ok(token) = timer {
state.loop_handle.remove(token);
}
complete_refresh(state, probe, None);
}
}
fn complete_refresh(state: &mut Ferese, probe: Probe, value: Option<bool>) {
let Some(backend) = state.direct_backend.as_mut() else {
return;
};
let Some(reactivate) = backend.lid.complete(probe, value) else {
return;
};
if let Some(token) = backend.lid_reader.deadline.take() {
state.loop_handle.remove(token);
}
if !backend.active {
return;
}
// Waking a locked session may request redraws. Hold them until the fresh
// device/connector snapshot has been reconciled.
backend.lid_reader.suspend_requested = false;
if probe.wake {
backend.topology.reconciling = true;
state.reset_animation_clock();
state.lock_input_activity();
state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.topology
.reconciling = false;
}
reconcile_outputs(state, reactivate);
}
/// Only logind requests suspend. Inactive events wait for fresh resume topology.
pub(super) fn apply_suspend_policy(state: &mut Ferese) {
let Some(backend) = state.direct_backend.as_mut() else {
return;
};
let requested = backend.active && backend.desired_outputs.suspend && backend.lid.closed();
backend
.lid_reader
.lease
.lock()
.expect("lid policy lease must remain consistent across workers")
.suspend = requested;
if backend.lid_reader.suspend_requested != requested {
backend.lid_reader.suspend_requested = requested;
let _ = backend.lid_reader.suspend.send(PolicyRequest::Suspend);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pause_releases_inhibitor_and_rejects_delayed_acquisition() {
let mut lease = InhibitorLease::default();
let first = lease.activate(true);
lease.accept(first, "fd");
assert_eq!(lease.descriptor, Some("fd"));
lease.activate(false);
assert_eq!(lease.descriptor, None);
lease.accept(first, "late fd");
assert_eq!(lease.descriptor, None);
let next = lease.activate(true);
lease.accept(first, "old session fd");
assert_eq!(lease.descriptor, None);
lease.accept(next, "new session fd");
assert_eq!(lease.descriptor, Some("new session fd"));
}
#[test]
#[ignore = "requires dbus-daemon and private test sockets"]
fn logind_reads_are_fresh_without_property_change_signals() {
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct Bus(std::process::Child);
impl Drop for Bus {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
struct Manager(Arc<AtomicBool>);
#[zbus::interface(name = "org.freedesktop.login1.Manager")]
impl Manager {
#[zbus(property)]
fn lid_closed(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
let mut bus = Bus(Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(Stdio::piped())
.spawn()
.unwrap());
let mut address = String::new();
BufReader::new(bus.0.stdout.take().unwrap())
.read_line(&mut address)
.unwrap();
let closed = Arc::new(AtomicBool::new(true));
let server = zbus::blocking::connection::Builder::address(address.trim())
.unwrap()
.name("org.freedesktop.login1")
.unwrap()
.serve_at("/org/freedesktop/login1", Manager(closed.clone()))
.unwrap()
.build()
.unwrap();
let client = zbus::blocking::connection::Builder::address(address.trim())
.unwrap()
.method_timeout(Duration::from_secs(1))
.build()
.unwrap();
assert!(read_logind(&client).unwrap());
closed.store(false, Ordering::Relaxed);
assert!(!read_logind(&client).unwrap());
server.release_name("org.freedesktop.login1").unwrap();
assert!(
read_logind(&client).is_err(),
"missing logind must not masquerade as an open lid"
);
}
#[test]
fn newer_input_wins_over_a_delayed_resume_read() {
let mut lid = LidState::default();
lid.observe(true);
let probe = lid.begin(true, true);
lid.observe(false);
assert_eq!(lid.complete(probe, Some(true)), Some(true));
assert!(!lid.closed());
}
#[test]
fn failed_reads_preserve_known_state_and_unknown_is_not_an_open_observation() {
let mut lid = LidState::default();
let probe = lid.begin(false, true);
lid.complete(probe, None);
assert_eq!(lid.value, None);
lid.observe(true);
let probe = lid.begin(true, true);
lid.complete(probe, None);
assert_eq!(lid.value, Some(true));
}
#[test]
fn pause_and_newer_resume_invalidate_old_replies() {
let mut lid = LidState::default();
let old = lid.begin(true, true);
lid.pause();
assert_eq!(lid.complete(old, Some(true)), None);
let current = lid.begin(true, true);
assert_eq!(lid.complete(old, Some(true)), None);
assert!(lid.pending());
assert_eq!(lid.complete(current, Some(false)), Some(true));
assert!(!lid.pending());
}
#[test]
fn listener_recovery_does_not_request_idle_wake_or_reactivation() {
let mut lid = LidState::default();
let recovery = lid.begin(false, false);
assert!(!recovery.wake);
assert_eq!(lid.complete(recovery, Some(false)), Some(false));
}
#[test]
fn listener_recovery_preserves_a_pending_real_wake() {
let mut lid = LidState::default();
let resume = lid.begin(true, true);
let recovery = lid.begin(false, false);
assert!(recovery.wake);
assert_eq!(lid.complete(resume, Some(false)), None);
assert_eq!(lid.complete(recovery, Some(false)), Some(true));
}
#[test]
fn system_wake_cannot_cancel_pending_session_reactivation() {
let mut lid = LidState::default();
lid.begin(true, true);
let probe = lid.begin(false, true);
assert_eq!(lid.complete(probe, Some(false)), Some(true));
}
}
+234
View File
@@ -0,0 +1,234 @@
//! A target displays a composition of the source, with no second workspace or input domain.
//! Source alone releases client callbacks/presentation feedback. Targets have independent KMS clocks.
#[cfg(test)]
use smithay::backend::allocator::Fourcc;
#[cfg(test)]
use smithay::output::Scale;
use super::{
CommitCounter, DirectOutput, Ferese, GlesRenderer, GlesTexture, Output, OutputMode,
PhysicalProperties, Subpixel, Transform, capture, sampled_output_elements,
};
use smithay::backend::renderer::Texture;
use smithay::utils::{Buffer, Physical, Rectangle};
/// Fit the complete source into the transformed target; black letterbox, never crop.
fn fit(source: (i32, i32), target: (i32, i32)) -> Rectangle<i32, Physical> {
let scale =
(target.0 as f64 / source.0.max(1) as f64).min(target.1 as f64 / source.1.max(1) as f64);
let width = (source.0 as f64 * scale).round().max(1.0) as i32;
let height = (source.1 as f64 * scale).round().max(1.0) as i32;
Rectangle::new(
((target.0 - width) / 2, (target.1 - height) / 2).into(),
(width, height).into(),
)
}
pub(super) fn elements(
state: &mut Ferese,
renderer: &mut GlesRenderer,
source: &Output,
frame: &crate::state::FrameScene,
target: &mut DirectOutput,
) -> Result<
Vec<crate::render::AnimatedWindowRenderElement>,
smithay::backend::renderer::gles::GlesError,
> {
let elements = sampled_output_elements(state, renderer, source, true, frame);
// Target renderer owns this texture, including when source and target use different GPUs.
let canvas = target.mirror_canvas.get_or_insert_with(|| {
Output::new(
"mirror-canvas".into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "Ferese".into(),
model: "Mirror composition".into(),
},
)
});
compose(
renderer,
&mut target.mirror_texture,
canvas,
source,
&elements,
)?;
let texture = target
.mirror_texture
.as_ref()
.expect("source composition has a texture")
.clone();
target.mirror_commit.increment();
Ok(vec![
fitted_texture(
texture,
&target.output,
target.mirror_id.clone(),
target.mirror_commit,
)
.into(),
])
}
pub(super) fn compose(
renderer: &mut GlesRenderer,
texture: &mut Option<GlesTexture>,
canvas: &Output,
source: &Output,
elements: &[crate::render::AnimatedWindowRenderElement],
) -> Result<(), smithay::backend::renderer::gles::GlesError> {
configure_canvas(canvas, source);
let framebuffer = capture::render_texture(renderer, texture, canvas, elements)?;
drop(framebuffer);
Ok(())
}
pub(super) fn configure_canvas(canvas: &Output, source: &Output) {
let source_mode = source.current_mode().expect("source has a mode");
canvas.change_current_state(
Some(OutputMode {
size: source.current_transform().transform_size(source_mode.size),
refresh: source_mode.refresh,
}),
Some(Transform::Normal),
Some(source.current_scale()),
None,
);
}
pub(super) fn fitted_texture(
texture: GlesTexture,
target: &Output,
id: smithay::backend::renderer::element::Id,
commit: CommitCounter,
) -> crate::presentation::NativeTextureElement {
let source_size = texture.size();
let size = target
.current_transform()
.transform_size(target.current_mode().expect("target has a mode").size);
crate::presentation::NativeTextureElement {
id,
commit,
source: Rectangle::<f64, Buffer>::from_size(source_size.to_f64()),
texture,
geometry: fit((source_size.w, source_size.h), (size.w, size.h)),
alpha: 1.0,
program: None,
uniforms: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn different_native_sizes_fit_without_cropping() {
assert_eq!(
fit((1920, 1080), (1280, 1024)),
Rectangle::new((0, 152).into(), (1280, 720).into())
);
assert_eq!(
fit((1920, 1080), (1080, 1920)),
Rectangle::new((0, 656).into(), (1080, 608).into())
);
}
#[test]
#[ignore = "requires an EGL rendering device"]
fn mirrored_pixels_fit_rotated_source_and_target_without_cropping() {
use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay};
use smithay::backend::renderer::element::solid::SolidColorRenderElement;
use smithay::backend::renderer::element::{Id, Kind};
use smithay::backend::renderer::{Color32F, ExportMem};
let device = EGLDevice::enumerate().unwrap().last().expect("EGL device");
// SAFETY: The native display comes from a live enumerated EGLDevice and its lifetime is
// retained by the EGL objects created here.
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
// SAFETY: The fresh EGL context is exclusively owned by this renderer; this test issues
// GL work only through its renderer methods.
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let output = |name: &str| {
Output::new(
name.into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
)
};
let source = output("source");
source.change_current_state(
Some(OutputMode {
size: (64, 32).into(),
refresh: 60_000,
}),
Some(Transform::_90),
Some(Scale::Fractional(2.0)),
None,
);
let canvas = output("canvas");
let colors: Vec<crate::render::AnimatedWindowRenderElement> =
[(0, [1., 0., 0., 1.]), (32, [0., 0., 1., 1.])]
.into_iter()
.map(|(y, color)| {
SolidColorRenderElement::new(
Id::new(),
Rectangle::new((0, y).into(), (32, 32).into()),
CommitCounter::default(),
Color32F::from(color),
Kind::Unspecified,
)
.into()
})
.collect();
let mut texture = None;
compose(&mut renderer, &mut texture, &canvas, &source, &colors).unwrap();
assert_eq!(texture.as_ref().unwrap().size(), (32, 64).into());
let target = output("target");
for (transform, colored_area) in [(Transform::Normal, 2048), (Transform::_90, 4608)] {
target.change_current_state(
Some(OutputMode {
size: (96, 64).into(),
refresh: 120_000,
}),
Some(transform),
Some(Scale::Fractional(1.5)),
None,
);
let element = fitted_texture(
texture.as_ref().unwrap().clone(),
&target,
Id::new(),
CommitCounter::default(),
);
let mut target_texture = None;
let framebuffer =
capture::render_capture(&mut renderer, &mut target_texture, &target, &[element])
.unwrap();
let mapping = renderer
.copy_framebuffer(
&framebuffer,
Rectangle::from_size((96, 64).into()),
Fourcc::Abgr8888,
)
.unwrap();
let pixels = renderer.map_texture(&mapping).unwrap();
let red = pixels
.chunks_exact(4)
.filter(|pixel| pixel[0] > 100 && pixel[0] > pixel[2])
.count();
let blue = pixels
.chunks_exact(4)
.filter(|pixel| pixel[2] > 100 && pixel[2] > pixel[0])
.count();
assert_eq!(red, colored_area / 2, "{transform:?}");
assert_eq!(blue, colored_area / 2, "{transform:?}");
}
}
}
@@ -0,0 +1,93 @@
//! Runtime power is independent of connector presence and configured enablement.
//! Only successful hardware transitions change the applied state.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum Power {
On,
Off,
}
#[derive(Debug)]
pub(super) struct OutputPower {
requested: Power,
applied: Option<Power>,
}
impl Default for OutputPower {
fn default() -> Self {
Self {
requested: Power::On,
applied: None,
}
}
}
impl OutputPower {
pub fn request(&mut self, power: Power) {
self.requested = power;
}
pub fn can_render(&self) -> bool {
self.requested == Power::On
}
pub fn needs_power_off(&self) -> bool {
self.requested == Power::Off && self.applied != Some(Power::Off)
}
pub fn needs_wake(&self) -> bool {
self.requested == Power::Off || self.applied != Some(Power::On)
}
pub fn powered_off(&mut self) {
self.applied = Some(Power::Off);
}
pub fn presented(&mut self) {
self.applied = Some(Power::On);
}
pub fn suspended(&mut self) {
self.applied = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failed_power_off_keeps_the_applied_state_and_remains_retryable() {
let mut power = OutputPower::default();
power.presented();
power.request(Power::Off);
assert!(!power.can_render());
assert_eq!(power.applied, Some(Power::On));
assert!(power.needs_power_off());
power.powered_off();
assert!(!power.needs_power_off());
}
#[test]
fn waking_does_not_claim_success_before_presentation() {
let mut power = OutputPower::default();
power.request(Power::Off);
power.powered_off();
power.request(Power::On);
assert!(power.can_render());
assert_eq!(power.applied, Some(Power::Off));
power.presented();
assert!(!power.needs_wake());
}
#[test]
fn pending_frame_retirement_and_suspend_do_not_lose_requested_power() {
let mut power = OutputPower::default();
power.request(Power::Off);
power.presented();
assert!(power.needs_power_off());
power.suspended();
assert_eq!(power.applied, None);
assert!(power.needs_power_off());
assert!(!power.can_render());
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Uncertain KMS completion requires device reconciliation, not buffer reuse.
use super::{
DirectOutput, DrmNode, Duration, Ferese, Instant, TimeoutAction, Timer, crtc, reconcile_outputs,
};
const COMPLETION_TIMEOUT: Duration = Duration::from_secs(2);
#[cfg(feature = "drm-fault-injection")]
pub(super) fn inject(mode: &str) -> bool {
use std::sync::LazyLock;
use std::sync::atomic::{AtomicBool, Ordering};
static MODE: LazyLock<Option<String>> =
LazyLock::new(|| std::env::var("FERESE_TEST_DRM_FAILURE").ok());
static USED: AtomicBool = AtomicBool::new(false);
MODE.as_deref() == Some(mode) && !USED.swap(true, Ordering::AcqRel)
}
#[derive(Default)]
pub(super) struct Completion {
pending: Option<Instant>,
}
impl Completion {
fn queued(&mut self, now: Instant) {
self.pending = Some(now);
}
pub fn retired(&mut self) {
self.pending = None;
}
fn remaining(&self, now: Instant) -> Option<Duration> {
self.pending
.map(|queued| COMPLETION_TIMEOUT.saturating_sub(now.saturating_duration_since(queued)))
}
}
// Keep the submission result and the recovery decision together. An error must
// not be mistaken for a completed frame with no presentation feedback.
pub(super) fn retirement<T, E>(result: Result<Option<T>, E>) -> (Option<T>, bool, Option<E>) {
match result {
Ok(feedback) => {
let retired = feedback.is_some();
(feedback, retired, None)
}
Err(error) => (None, false, Some(error)),
}
}
pub(super) fn arm_completion(
handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>,
node: DrmNode,
crtc: crtc::Handle,
output: &mut DirectOutput,
) -> bool {
output.completion.queued(Instant::now());
if output.completion_timer.is_some() {
return true;
}
let identity = output.output.clone();
match handle.insert_source(
Timer::from_duration(COMPLETION_TIMEOUT),
move |_, _, state| {
let Some(output) = state
.direct_backend
.as_mut()
.and_then(|backend| backend.devices.get_mut(&node))
.and_then(|device| device.outputs.get_mut(&crtc))
.filter(|output| output.output == identity)
else {
return TimeoutAction::Drop;
};
if let Some(remaining) = output.completion.remaining(Instant::now()) {
if !remaining.is_zero() {
return TimeoutAction::ToDuration(remaining);
}
output.completion_timer = None;
tracing::error!(
?node,
?crtc,
"DRM frame completion timed out; reconciling device"
);
reconcile_device(state, node);
} else {
output.completion_timer = None;
}
TimeoutAction::Drop
},
) {
Ok(token) => {
output.completion_timer = Some(token);
true
}
Err(error) => {
tracing::error!(%error, ?node, ?crtc, "could not arm DRM completion watchdog");
false
}
}
}
pub(super) fn reconcile_device(state: &mut Ferese, node: DrmNode) {
let Some(backend) = state.direct_backend.as_mut() else {
return;
};
backend.topology.dirty = true;
if !backend.topology.invalidated.insert(node) {
return;
}
// Defer teardown until the DRM notifier callback has returned. Inactive
// sessions retain the invalidation for fresh enumeration on activation.
let active = backend.active;
if active {
match state
.loop_handle
.insert_source(Timer::immediate(), |_, _, state| {
reconcile_outputs(state, false);
TimeoutAction::Drop
}) {
Ok(_) => (),
Err(error) => tracing::error!(%error, "could not schedule DRM device recovery"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failed_retirement_does_not_release_pending_buffer_ownership() {
let now = Instant::now();
let mut completion = Completion::default();
completion.queued(now);
let (feedback, retired, error) =
retirement::<(), _>(Err("injected KMS retirement failure"));
assert_eq!(feedback, None);
assert!(!retired);
assert!(
error.is_some(),
"the production handler must reconcile the device"
);
assert_eq!(completion.remaining(now), Some(COMPLETION_TIMEOUT));
assert_eq!(retirement::<(), &str>(Ok(None)), (None, false, None));
assert_eq!(retirement::<(), &str>(Ok(Some(()))), (Some(()), true, None));
// Only successful retirement or device teardown can end uncertainty.
completion = Completion::default();
assert_eq!(completion.remaining(now), None);
}
#[test]
fn missing_completion_remains_pending_until_hardware_reconciliation() {
let now = Instant::now();
let mut completion = Completion::default();
completion.queued(now);
assert_eq!(
completion.remaining(now + COMPLETION_TIMEOUT - Duration::from_millis(1)),
Some(Duration::from_millis(1))
);
// A timeout observes uncertainty; it never frees the current buffer.
assert_eq!(
completion.remaining(now + COMPLETION_TIMEOUT),
Some(Duration::ZERO)
);
assert_eq!(
completion.remaining(now + COMPLETION_TIMEOUT + Duration::from_secs(1)),
Some(Duration::ZERO)
);
completion.retired();
assert_eq!(completion.remaining(now), None);
completion.queued(now + Duration::from_secs(3));
assert_eq!(
completion.remaining(now + Duration::from_secs(4)),
Some(Duration::from_secs(1)),
"existing timer must respect the newer frame's deadline"
);
}
}
+448
View File
@@ -0,0 +1,448 @@
//! Redraw/fallback measurements and the orphaned-animation watchdog policy.
use std::collections::HashMap;
use std::panic::Location;
use std::time::{Duration, Instant};
#[derive(Default)]
pub(super) struct AnimationFallback {
orphaned_since: Option<Instant>,
}
impl AnimationFallback {
pub fn waiting_for_frame(&self) -> bool {
self.orphaned_since.is_some()
}
pub fn deadline(
&mut self,
now: Instant,
interval: Duration,
active: bool,
chained: bool,
) -> Option<Instant> {
if !active || chained {
self.orphaned_since = None;
return None;
}
Some(*self.orphaned_since.get_or_insert(now) + interval.saturating_mul(2))
}
}
#[derive(Debug, Default)]
struct RedrawCount {
calls: u64,
requested_outputs: u64,
connected_outputs: u64,
}
pub(super) struct SchedulingMetrics {
enabled: bool,
started: Instant,
redraws: HashMap<(&'static str, u32, bool), RedrawCount>,
pub fallback_arms: u64,
pub fallback_cancels: u64,
pub fallback_wakes: u64,
pub fallback_recoveries: u64,
pub chained_animation_observations: u64,
}
impl SchedulingMetrics {
pub fn new() -> Self {
Self {
enabled: std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some(),
started: Instant::now(),
redraws: HashMap::new(),
fallback_arms: 0,
fallback_cancels: 0,
fallback_wakes: 0,
fallback_recoveries: 0,
chained_animation_observations: 0,
}
}
pub fn redraw(
&mut self,
source: &'static Location<'static>,
global: bool,
requested: usize,
connected: usize,
) {
if !self.enabled {
return;
}
let count = self
.redraws
.entry((source.file(), source.line(), global))
.or_default();
count.calls += 1;
count.requested_outputs += requested as u64;
count.connected_outputs += connected as u64;
if self.started.elapsed() >= Duration::from_secs(5) {
self.dump();
self.started = Instant::now();
self.redraws.clear();
self.fallback_arms = 0;
self.fallback_cancels = 0;
self.fallback_wakes = 0;
self.fallback_recoveries = 0;
self.chained_animation_observations = 0;
}
}
pub fn dump(&self) {
if !self.enabled {
return;
}
for ((file, line, global), count) in &self.redraws {
tracing::info!(target: "ferese::render", file, line, global,
calls = count.calls, requested_outputs = count.requested_outputs,
connected_outputs = count.connected_outputs, "redraw requests");
}
tracing::info!(target: "ferese::render", fallback_arms = self.fallback_arms,
fallback_cancels = self.fallback_cancels, fallback_wakes = self.fallback_wakes,
fallback_recoveries = self.fallback_recoveries,
chained_animation_observations = self.chained_animation_observations,
"animation fallback scheduling");
}
}
pub(super) fn cursor_on_output(state: &crate::Ferese, output: &smithay::output::Output) -> bool {
use smithay::input::pointer::{CursorImageStatus, CursorImageSurfaceData};
use smithay::utils::{Logical, Rectangle};
use smithay::wayland::compositor::with_states;
if state.input_capture.active() {
return false;
}
let Some(pointer) = state.seat.get_pointer() else {
return false;
};
let Some(bounds) = state.space.output_geometry(output) else {
return false;
};
let location = pointer.current_location();
let rect = match &state.cursor_status {
CursorImageStatus::Surface(surface) => {
let hotspot = with_states(surface, |states| {
states
.data_map
.get::<CursorImageSurfaceData>()
.map(|attributes| {
attributes
.lock()
.expect(
"cursor surface attributes must remain consistent across callbacks",
)
.hotspot
})
.unwrap_or_default()
});
smithay::desktop::utils::bbox_from_surface_tree(
surface,
(location - hotspot.to_f64()).to_i32_round(),
)
.to_f64()
}
CursorImageStatus::Named(_) => {
let Some(cursor) = state.named_cursor_frame() else {
return false;
};
cursor.logical_rect(location)
}
CursorImageStatus::Hidden => return false,
};
// One logical pixel covers physical rounding at fractional scales.
let bounds = Rectangle::<f64, Logical>::new(
(f64::from(bounds.loc.x) - 1.0, f64::from(bounds.loc.y) - 1.0).into(),
(
f64::from(bounds.size.w) + 2.0,
f64::from(bounds.size.h) + 2.0,
)
.into(),
);
rect.overlaps(bounds)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mixed_refresh_only_orphaned_animations_need_a_watchdog() {
let now = Instant::now();
let fast = Duration::from_nanos(1_000_000_000 / 240);
let slow = Duration::from_nanos(1_000_000_000 / 60);
let mut idle_fast = AnimationFallback::default();
let mut moving_slow = AnimationFallback::default();
assert_eq!(idle_fast.deadline(now, fast, false, false), None);
assert_eq!(
moving_slow.deadline(now, slow, true, false),
Some(now + slow * 2)
);
assert_eq!(
moving_slow.deadline(now + slow, slow, true, false),
Some(now + slow * 2)
);
assert_eq!(moving_slow.deadline(now + slow, slow, true, true), None);
}
#[test]
fn disconnect_and_reconnect_do_not_keep_an_old_deadline() {
let now = Instant::now();
let interval = Duration::from_millis(16);
let mut output = AnimationFallback::default();
output.deadline(now, interval, true, false);
assert_eq!(
output.deadline(now + interval, interval, false, false),
None
);
let reconnected = now + Duration::from_secs(1);
assert_eq!(
output.deadline(reconnected, interval, true, false),
Some(reconnected + interval * 2)
);
}
#[test]
fn measured_legacy_fast_monitor_watchdog_wakes_with_an_intact_slow_chain() {
// The old watchdog wakes at last_animation_tick + 2*fast_interval,
// then advances the shared clock, even with a slow render in flight.
let mut last_tick_us = 0;
let mut old_wakes = 0;
let mut policy = AnimationFallback::default();
let start = Instant::now();
for now_us in 0..1_000_000 {
if now_us % 16_667 == 0 {
last_tick_us = now_us;
}
if now_us >= last_tick_us + 8_334 {
old_wakes += 1;
last_tick_us = now_us;
}
assert_eq!(
policy.deadline(start, Duration::from_micros(16_667), true, true),
None
);
}
assert_eq!(old_wakes, 60);
eprintln!("watchdog replay: legacy={old_wakes} unnecessary wakes/s, intact-chain policy=0");
}
#[test]
fn scheduled_and_in_flight_frames_suppress_fallback_until_the_chain_is_lost() {
use crate::frame_scheduler::FrameScheduler;
let start = Instant::now();
let interval = Duration::from_millis(16);
let mut scheduler = FrameScheduler::new(interval);
let mut fallback = AnimationFallback::default();
scheduler.request(Duration::ZERO);
let scheduled = scheduler.plan(Duration::ZERO).unwrap();
assert_eq!(fallback.deadline(start, interval, true, true), None);
let plan = scheduler.begin(scheduled.render_at).unwrap();
assert_eq!(plan, scheduled);
scheduler.rendered(
plan,
Duration::from_millis(2),
Duration::from_millis(2),
true,
);
assert_eq!(scheduler.plan(interval), None);
assert_eq!(
fallback.deadline(start + interval, interval, true, true),
None
);
scheduler.presented(interval);
assert_eq!(
fallback.deadline(start + interval, interval, true, false),
Some(start + interval * 3)
);
scheduler.request(interval);
assert!(scheduler.plan(interval).is_some());
assert_eq!(
fallback.deadline(start + interval, interval, true, true),
None
);
}
#[test]
fn settled_animation_still_recovers_its_requested_final_frame_after_scheduling_failure() {
use crate::frame_scheduler::FrameScheduler;
let start = Instant::now();
let interval = Duration::from_millis(16);
let mut scheduler = FrameScheduler::new(interval);
let mut fallback = AnimationFallback::default();
scheduler.request(Duration::ZERO);
scheduler.plan(Duration::ZERO).unwrap();
// Timer insertion failed: a scheduler plan without an event-loop
// source is not a live chain. Another output can settle the spring.
assert!(scheduler.has_pending_request());
let deadline = fallback.deadline(start, interval, true, false).unwrap();
let animation_active = false;
let needs_final_frame =
animation_active || (fallback.waiting_for_frame() && scheduler.has_pending_request());
assert_eq!(
fallback.deadline(deadline, interval, needs_final_frame, false),
Some(deadline)
);
// A successful retry installs the normal timer and cancels fallback.
assert_eq!(
fallback.deadline(deadline, interval, needs_final_frame, true),
None
);
scheduler.begin(deadline.duration_since(start)).unwrap();
assert!(!scheduler.has_pending_request());
}
#[test]
#[ignore = "requires private sockets; subprocess supplies its own runtime directory"]
fn cursor_footprints_limit_output_local_redraws_and_clear_both_sides_of_a_crossing() {
if std::env::var_os("FERESE_CURSOR_REDRAW_TEST_CHILD").is_none() {
let runtime = tempfile::tempdir().unwrap();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "backends::direct::redraw::tests::cursor_footprints_limit_output_local_redraws_and_clear_both_sides_of_a_crossing", "--include-ignored", "--nocapture"])
.env("XDG_RUNTIME_DIR", runtime.path())
.env("FERESE_CURSOR_REDRAW_TEST_CHILD", "1")
.output().unwrap();
assert!(
result.status.success(),
"{}{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
return;
}
use smithay::input::pointer::{CursorImageStatus, MotionEvent};
use smithay::output::{Mode, Output, PhysicalProperties, Subpixel};
use smithay::utils::SERIAL_COUNTER;
let mut event_loop = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let display = smithay::reexports::wayland_server::Display::new().unwrap();
let config = crate::config::Config::default().runtime_config().unwrap();
let mut state = crate::Ferese::new(&mut event_loop, display, config).unwrap();
let mut outputs = Vec::new();
for (index, refresh) in [60_000, 240_000].into_iter().enumerate() {
let output = Output::new(
format!("cursor-test-{index}"),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: if index == 0 {
(3840, 2160).into()
} else {
(1920, 1080).into()
},
refresh,
}),
None,
Some(smithay::output::Scale::Fractional(if index == 0 {
2.0
} else {
1.0
})),
Some(((index as i32) * 1920, 0).into()),
);
state.space.map_output(&output, ((index as i32) * 1920, 0));
state.register_output(&output, output.name());
outputs.push(output);
}
let pointer = state.seat.get_pointer().unwrap();
let mut old = [false; 2];
let mut local_requests = 0;
for x in [500.0, 501.0, 502.0] {
pointer.motion(
&mut state,
None,
&MotionEvent {
location: (x, 500.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(0),
},
);
let current = [
cursor_on_output(&state, &outputs[0]),
cursor_on_output(&state, &outputs[1]),
];
assert_eq!(current, [true, false]);
local_requests += old
.into_iter()
.zip(current)
.filter(|(before, after)| *before || *after)
.count();
old = current;
}
assert_eq!(local_requests, 3);
eprintln!("cursor replay: legacy=6 output requests, footprint policy={local_requests}");
pointer.motion(
&mut state,
None,
&MotionEvent {
location: (1919.0, 500.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(0),
},
);
assert!(cursor_on_output(&state, &outputs[0]));
assert!(
cursor_on_output(&state, &outputs[1]),
"cursor image straddles the seam"
);
if let Some(cursor) = state.named_cursor_frame() {
let x = 1920.0 - f64::from(cursor.size.w) / f64::from(cursor.buffer_scale) * 0.75
+ f64::from(cursor.hotspot.x) / f64::from(cursor.buffer_scale);
pointer.motion(
&mut state,
None,
&MotionEvent {
location: (x, 500.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(0),
},
);
assert!(
cursor_on_output(&state, &outputs[1]),
"fractional-scale footprint matches rendered cursor size"
);
}
old = [true, true];
state.cursor_status = CursorImageStatus::Hidden;
let current = [
cursor_on_output(&state, &outputs[0]),
cursor_on_output(&state, &outputs[1]),
];
assert_eq!(current, [false, false]);
assert_eq!(
old.into_iter()
.zip(current)
.filter(|(before, after)| *before || *after)
.count(),
2
);
// Grabs and held gestures defer window/output work independently of
// the cursor, which may be on another display or hidden entirely.
state.defer_output_redraw(outputs[0].clone());
state.defer_output_redraw(outputs[1].clone());
state.defer_output_redraw(outputs[1].clone());
assert_eq!(state.output_redraw_pending, outputs);
state.unregister_output(&outputs[1]);
assert_eq!(state.output_redraw_pending, vec![outputs[0].clone()]);
assert!(!cursor_on_output(&state, &outputs[1]));
}
}
+39 -19
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
DirectOutput, DrmNode, Duration, Ferese, Instant, Monotonic, Output, OutputMode, TimeoutAction,
Timer, arm_animation_timer, crtc, render_output, sleep_locked_outputs,
};
use crate::frame_scheduler::FramePlan;
pub(super) fn monotonic_now() -> Duration {
@@ -9,9 +12,14 @@ pub(super) fn cancel_output_timers(
handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>,
output: &mut DirectOutput,
) {
for token in [output.render_timer.take(), output.callback_timer.take()]
.into_iter()
.flatten()
output.completion.retired();
for token in [
output.render_timer.take(),
output.callback_timer.take(),
output.completion_timer.take(),
]
.into_iter()
.flatten()
{
handle.remove(token);
}
@@ -31,7 +39,7 @@ pub(super) fn request_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handl
let Some(output) = state
.direct_backend
.as_mut()
.filter(|backend| backend.active)
.filter(|backend| backend.can_render())
.and_then(|backend| backend.devices.get_mut(&node))
.filter(|device| device.drm.is_active())
.and_then(|device| device.outputs.get_mut(&crtc))
@@ -39,10 +47,13 @@ pub(super) fn request_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handl
return;
};
if output.power_off {
if !output.power.can_render() {
return;
}
output
.render_metrics
.record_request(output.render_timer.is_some(), output.frame_pending);
output.scheduler.request(now);
arm_frame(state, node, crtc);
}
@@ -52,7 +63,7 @@ pub(super) fn arm_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
let Some(output) = state
.direct_backend
.as_mut()
.filter(|backend| backend.active)
.filter(|backend| backend.can_render())
.and_then(|backend| backend.devices.get_mut(&node))
.filter(|device| device.drm.is_active())
.and_then(|device| device.outputs.get_mut(&crtc))
@@ -60,7 +71,11 @@ pub(super) fn arm_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
return;
};
if output.render_timer.is_some() || output.frame_pending || output.power_off || state.session_lock.sleeping {
if output.render_timer.is_some()
|| output.frame_pending
|| !output.power.can_render()
|| state.session_lock.sleeping
{
return;
}
@@ -97,7 +112,7 @@ fn dispatch_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, identit
let Some(output) = state
.direct_backend
.as_mut()
.filter(|backend| backend.active)
.filter(|backend| backend.can_render())
.and_then(|backend| backend.devices.get_mut(&node))
.filter(|device| device.drm.is_active())
.and_then(|device| device.outputs.get_mut(&crtc))
@@ -107,29 +122,34 @@ fn dispatch_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, identit
};
output.render_timer = None;
if output.frame_pending || output.power_off || state.session_lock.sleeping {
if output.frame_pending || !output.power.can_render() || state.session_lock.sleeping {
return;
}
let Some(plan) = output.scheduler.begin() else {
let Some(plan) = output.scheduler.begin(monotonic_now()) else {
return;
};
let animating = state.advance_animations(Instant::now());
if let Some(backend) = state.direct_backend.as_mut() {
backend.animation_active = animating;
}
state.advance_animations(Instant::now());
render_output(state, node, crtc, plan);
if state.output_has_animations(identity) {
let output_animating = render_output(state, node, crtc, plan);
if output_animating {
request_frame(state, node, crtc);
}
arm_animation_timer(state);
}
pub(super) fn record_frame_schedule(output: &mut DirectOutput, plan: FramePlan, cost: Duration, submitted: bool) {
output.scheduler.rendered(plan, monotonic_now(), cost, submitted);
pub(super) fn record_frame_schedule(
output: &mut DirectOutput,
plan: FramePlan,
cost: Duration,
submitted: bool,
) {
output
.scheduler
.rendered(plan, monotonic_now(), cost, submitted);
if std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some() {
let started = monotonic_now().saturating_sub(cost);
tracing::debug!(target: "ferese::render", output = %output.output.name(),
@@ -0,0 +1,111 @@
use smithay::backend::session::Session;
use smithay::reexports::rustix::fs::OFlags;
use smithay::utils::DeviceFd;
use std::io;
use std::os::fd::OwnedFd;
use std::path::Path;
pub(super) struct SessionDevice<S: Session> {
session: S,
fd: Option<OwnedFd>,
}
impl<S: Session> SessionDevice<S> {
pub fn open(mut session: S, path: &Path) -> Result<Self, S::Error> {
let fd = session.open(
path,
OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOCTTY | OFlags::NONBLOCK,
)?;
Ok(Self {
session,
fd: Some(fd),
})
}
pub fn device_fd(&self) -> io::Result<DeviceFd> {
self.fd
.as_ref()
.expect("the session device retains its descriptor until Drop")
.try_clone()
.map(DeviceFd::from)
}
}
impl<S: Session> Drop for SessionDevice<S> {
fn drop(&mut self) {
if let Err(error) = self.session.close(
self.fd
.take()
.expect("the session device retains its descriptor until Drop"),
) {
tracing::warn!(
?error,
"could not release DRM device through the session manager"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::fs::File;
use std::os::fd::AsRawFd;
use std::rc::Rc;
#[derive(Clone, Default)]
struct TestSession(Rc<Cell<Option<i32>>>);
impl Session for TestSession {
type Error = ();
fn open(&mut self, _: &Path, _: OFlags) -> Result<OwnedFd, ()> {
if self.0.get().is_some() {
return Err(());
}
let fd: OwnedFd = File::open("/dev/null").unwrap().into();
self.0.set(Some(fd.as_raw_fd()));
Ok(fd)
}
fn close(&mut self, fd: OwnedFd) -> Result<(), ()> {
assert_eq!(self.0.replace(None), Some(fd.as_raw_fd()));
Ok(())
}
fn change_vt(&mut self, _: i32) -> Result<(), ()> {
Ok(())
}
fn is_active(&self) -> bool {
true
}
fn seat(&self) -> String {
"seat0".into()
}
}
#[test]
fn teardown_releases_original_session_lease_before_reopening() {
let session = TestSession::default();
for _ in 0..3 {
let lease = SessionDevice::open(session.clone(), Path::new("/dev/null")).unwrap();
let rendering = lease.device_fd().unwrap();
assert_ne!(Some(rendering.as_raw_fd()), session.0.get());
drop(rendering);
assert!(session.0.get().is_some());
drop(lease);
assert!(session.0.get().is_none());
}
}
#[test]
fn failed_renderer_initialization_releases_lease() {
fn initialize(session: TestSession) -> Result<(), ()> {
let lease = SessionDevice::open(session, Path::new("/dev/null"))?;
let _rendering = lease.device_fd().unwrap();
Err(())
}
let session = TestSession::default();
assert!(initialize(session.clone()).is_err());
assert!(session.0.get().is_none());
assert!(SessionDevice::open(session, Path::new("/dev/null")).is_ok());
}
}
+889
View File
@@ -0,0 +1,889 @@
//! Desired device identities survive temporary loss of their DRM resources.
use std::path::PathBuf;
use super::{
DrmNode, Duration, Ferese, HashMap, HashSet, Instant, OutputMode, Path, RegistrationToken,
Scale, Session, TimeoutAction, Timer, all_gpus, cancel_output_timers, drm_device_key, lid,
monotonic_now, open_device, output_transform, refresh_connected_info, refresh_monitor_policy,
remember_applied_configuration, remove_device, rescan_device, sync_battery_timer, transaction,
validate_desired_outputs,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Availability<Node = DrmNode> {
Ready(Node),
Degraded(Node),
Missing,
Unavailable,
}
pub(super) struct DeviceState<Node = DrmNode> {
pub availability: Availability<Node>,
identity: Option<PathBuf>,
failures: u32,
retry_at: Option<Instant>,
}
impl<Node> Default for DeviceState<Node> {
fn default() -> Self {
Self {
availability: Availability::Missing,
identity: None,
failures: 0,
retry_at: None,
}
}
}
pub(super) fn device_identity(path: &Path) -> Option<PathBuf> {
// The parent hardware identity survives cardN renumbering after unplug.
std::fs::canonicalize(
Path::new("/sys/class/drm")
.join(path.file_name()?)
.join("device"),
)
.ok()
}
impl<Node> DeviceState<Node> {
fn current_path<'a>(
&self,
original: &Path,
observed: &'a [(PathBuf, Option<PathBuf>)],
) -> Option<&'a Path> {
observed
.iter()
.find(|(path, identity)| match &self.identity {
Some(expected) => identity.as_ref() == Some(expected),
None => path == original,
})
.map(|(path, _)| path.as_path())
}
pub fn failed(&mut self, now: Instant) {
self.availability = Availability::Unavailable;
self.failures = self.failures.saturating_add(1);
self.retry_at =
Some(now + Duration::from_secs(1u64 << self.failures.saturating_sub(1).min(5)));
}
pub fn ready(&mut self, node: Node) {
self.availability = Availability::Ready(node);
self.failures = 0;
self.retry_at = None;
}
pub fn missing(&mut self) {
self.availability = Availability::Missing;
self.failures = 0;
self.retry_at = None;
}
fn degraded(&mut self, node: Node, now: Instant) {
self.failed(now);
self.availability = Availability::Degraded(node);
}
}
pub(super) struct Topology<Node = DrmNode> {
// Hardware paths are stable keys; use the card path only when sysfs identity
// is unavailable. A reused card number must not replace another GPU's slot.
pub devices: HashMap<PathBuf, DeviceState<Node>>,
pub invalidated: HashSet<Node>,
pub reconciling: bool,
pub dirty: bool,
pub layout_dirty: bool,
pub remember_applied: bool,
pub retry_timer: Option<RegistrationToken>,
pub settle_timer: Option<RegistrationToken>,
pub coalescer: crate::output_policy::Debouncer,
}
impl<Node> Default for Topology<Node> {
fn default() -> Self {
Self {
devices: HashMap::new(),
invalidated: HashSet::new(),
reconciling: false,
dirty: false,
layout_dirty: false,
remember_applied: false,
retry_timer: None,
settle_timer: None,
coalescer: Default::default(),
}
}
}
// Node identity is opaque to reconciliation; tests use integers without opening
// DRM devices or depending on which nodes happen to exist in the host's sysfs.
impl<Node: Copy + Eq + std::hash::Hash> Topology<Node> {
pub fn observe_devices(&mut self, observed: &[(PathBuf, Option<PathBuf>)]) {
for (path, identity) in observed {
let key = identity.as_ref().unwrap_or(path);
let fallback = if identity.is_some() {
self.devices.remove(path)
} else {
None
};
if self.devices.contains_key(key) {
continue;
}
// Preserve a slot first discovered before its sysfs identity was
// available, including its live DRM resources and retry state.
let mut slot = fallback.unwrap_or_default();
slot.identity = identity.clone();
self.devices.insert(key.clone(), slot);
}
}
fn retire_changed_devices(
&mut self,
observed: &[(PathBuf, Option<PathBuf>)],
node_for_path: impl Fn(&Path) -> Option<Node>,
) -> HashSet<Node> {
let mut retired = HashSet::new();
for (key, slot) in &mut self.devices {
if let Availability::Ready(previous) | Availability::Degraded(previous) =
slot.availability
&& slot.current_path(key, observed).and_then(&node_for_path) != Some(previous)
{
retired.insert(previous);
slot.missing();
}
}
retired
}
pub fn request(&mut self, active: bool) -> bool {
self.dirty = true;
active && !self.reconciling
}
pub fn retry_deadline(&self, active: bool) -> Option<Instant> {
active
.then(|| {
self.devices
.values()
.filter_map(|device| device.retry_at)
.min()
})
.flatten()
}
}
fn slot_mut<'a>(state: &'a mut Ferese, path: &Path) -> &'a mut DeviceState {
state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.topology
.devices
.get_mut(path)
.expect("the discovery path must have a topology slot")
}
/// The only entry point for applying the current output configuration to DRM.
pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
let Some(backend) = state.direct_backend.as_mut() else {
return;
};
if !backend
.topology
.request(backend.active && !backend.lid.pending())
{
return;
}
if let Some(token) = backend.topology.settle_timer.take() {
state.loop_handle.remove(token);
}
backend.topology.coalescer.clear();
backend.topology.reconciling = true;
backend.topology.dirty = false;
backend.output_error = None;
if reactivate {
for clock in backend.presentation.values_mut() {
clock.reset_timing();
}
}
if let Some(token) = backend.topology.retry_timer.take() {
state.loop_handle.remove(token);
}
let mut invalidated = std::mem::take(&mut backend.topology.invalidated);
let observed = all_gpus(backend.session.seat()).map(|paths| {
paths
.into_iter()
.map(|path| {
let identity = device_identity(&path);
(path, identity)
})
.collect::<Vec<_>>()
});
let automatic = state
.output_profiles
.iter()
.any(|profile| profile.outputs.iter().any(|output| output.auto_refresh));
backend.low_power = automatic
&& super::super::power::low_power(
backend.low_power,
super::super::power::battery_percent(),
);
if let Ok(observed) = &observed {
backend.topology.observe_devices(observed);
// Retire every obsolete node before opening any replacement. Otherwise
// a later missing slot could remove a new GPU that reused its dev_t.
invalidated.extend(
backend
.topology
.retire_changed_devices(observed, |path| DrmNode::from_path(path).ok()),
);
}
let mut paths = backend.topology.devices.keys().cloned().collect::<Vec<_>>();
paths.sort();
let mut ready = Vec::new();
state.begin_desktop_transition();
for node in invalidated {
remove_device(state, node);
}
for path in paths {
let slot = &state
.direct_backend
.as_ref()
.expect("DRM callbacks require an installed direct backend")
.topology
.devices[&path];
let current_path = observed
.as_ref()
.ok()
.and_then(|devices| slot.current_path(&path, devices));
let existing = current_path
.and_then(|path| DrmNode::from_path(path).ok())
.filter(|node| {
state
.direct_backend
.as_ref()
.expect("DRM callbacks require an installed direct backend")
.devices
.contains_key(node)
});
let previous = match slot.availability {
Availability::Ready(node) | Availability::Degraded(node) => Some(node),
_ => existing,
};
let found = observed.as_ref().map(|_| current_path.is_some());
if !matches!(found, Ok(true)) {
if let Some(node) = previous {
remove_device(state, node);
}
let entry = slot_mut(state, &path);
match found {
Ok(false) => entry.missing(),
Err(error) => {
tracing::warn!(%error, ?path, "could not enumerate DRM topology");
entry.failed(Instant::now());
}
Ok(true) => unreachable!(),
}
continue;
}
if let Some(node) = previous.filter(|node| Some(*node) != existing) {
remove_device(state, node);
}
let node = match existing {
Some(node) => node,
None => match open_device(
state,
current_path.expect("device discovery found a path before attempting to open it"),
) {
Ok(node) => node,
Err(error) => {
tracing::warn!(%error, ?path, device_path = ?current_path, "DRM device unavailable; will retry");
slot_mut(state, &path).failed(Instant::now());
continue;
}
},
};
let mut failed_outputs = Vec::new();
let activation = if reactivate && existing.is_some() {
let device = state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.devices
.get_mut(&node)
.expect("the selected DRM device must remain registered during this operation");
device.drm.activate(true).map(|()| {
for (crtc, output) in &mut device.outputs {
cancel_output_timers(&state.loop_handle, output);
output.surface.reset_buffers();
output.dmabuf_feedback.invalidate();
output.primary_commit = None;
output.capture_texture = Default::default();
output.frame_pending = false;
output.lock_frame_pending = false;
output.power.suspended();
state.session_lock.output_added(&output.output);
state.display_presentation.remove_output(&output.output);
state
.desktop_transition
.as_mut()
.expect("desktop transition must be started before publication or retirement")
.redraw
.insert(output.output.clone());
if let Err(error) = output.surface.clear().and_then(|()| output.surface.reset_state()) {
tracing::warn!(%error, ?node, ?crtc, "output reset failed; recreating output");
failed_outputs.push(*crtc);
}
}
})
} else {
Ok(())
};
if let Err(error) = activation {
tracing::warn!(%error, ?node, "DRM reactivation failed; retiring unavailable outputs");
remove_device(state, node);
slot_mut(state, &path).failed(Instant::now());
continue;
}
for crtc in failed_outputs {
let mut output = state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.devices
.get_mut(&node)
.expect("the selected DRM device must remain registered during this operation")
.outputs
.remove(&crtc)
.expect("the selected CRTC must remain registered during this operation");
cancel_output_timers(&state.loop_handle, &mut output);
if let Some(global) = output.global.take() {
state.display_handle.disable_global::<Ferese>(global);
}
state.unregister_output(&output.output);
state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.presentation
.remove(&(node, crtc));
}
ready.push((path, node));
}
let known_good = state
.direct_backend
.as_ref()
.expect("DRM callbacks require an installed direct backend")
.applied_outputs
.clone();
if let Err(error) = refresh_monitor_policy(state).and_then(|()| {
transaction::retire_disconnected(state);
validate_desired_outputs(state)
}) {
tracing::warn!(%error, "monitor policy validation failed; retaining applied outputs");
state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.output_error = Some(error);
for (path, node) in &ready {
slot_mut(state, path).degraded(*node, Instant::now());
}
} else {
// Activate destinations before disabling former owners, including across GPUs.
ready.sort_by_key(|(_, node)| {
let backend = state
.direct_backend
.as_ref()
.expect("DRM callbacks require an installed direct backend");
let prefix = format!("{}/", drm_device_key(&backend.devices[node].drm));
let desired = backend
.desired_outputs
.outputs
.iter()
.filter(|output| output.settings.enabled && output.key.starts_with(&prefix));
let logical_sources = desired
.clone()
.filter(|output| output.mirror_source.is_none())
.count();
std::cmp::Reverse((logical_sources, desired.count()))
});
let mut succeeded = Vec::new();
for (path, node) in &ready {
let complete = rescan_device(state, *node);
let entry = slot_mut(state, path);
if complete == Ok(true) {
entry.ready(*node);
succeeded.push(*node);
} else {
entry.degraded(*node, Instant::now());
// Device boundaries are not cross-GPU atomic. Compensate already
// applied devices using their last actual hardware settings.
if !known_good.outputs.is_empty() {
let backend = state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend");
let requested = backend.desired_outputs.clone();
backend.desired_outputs =
crate::output_policy::restore_configuration(&known_good, &backend.monitors);
for previous in succeeded {
let _ = rescan_device(state, previous);
}
state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.desired_outputs = requested;
}
break;
}
}
if state
.direct_backend
.as_ref()
.expect("DRM callbacks require an installed direct backend")
.output_error
.is_none()
{
remember_applied_configuration(state);
}
}
transaction::recover_last_output(state);
let mut desktop_changes = publish_runtime_desktop(state);
if std::mem::take(
&mut state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.topology
.layout_dirty,
) {
desktop_changes.layout = true;
desktop_changes.shell = true;
desktop_changes
.outputs
.extend(state.space.outputs().cloned());
}
let backend = state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend");
backend.topology.reconciling = false;
let retry = backend.topology.retry_deadline(backend.active);
if std::mem::take(&mut backend.topology.remember_applied) {
remember_applied_configuration(state);
}
refresh_connected_info(state);
if let Some(deadline) = retry {
match state
.loop_handle
.insert_source(Timer::from_deadline(deadline), |_, _, state| {
if let Some(backend) = state.direct_backend.as_mut() {
backend.topology.retry_timer = None;
}
reconcile_outputs(state, false);
TimeoutAction::Drop
}) {
Ok(token) => {
state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.topology
.retry_timer = Some(token)
}
Err(error) => tracing::warn!(%error, "could not arm DRM recovery deadline"),
}
}
sync_battery_timer(state);
state.finish_desktop_transition(desktop_changes);
state.notify_monitor_state();
lid::apply_suspend_policy(state);
}
/// Publish only hardware present after application, compensation and recovery.
fn publish_runtime_desktop(state: &mut Ferese) -> crate::state::DesktopChanges {
let mut outputs = state
.direct_backend
.as_ref()
.expect("DRM callbacks require an installed direct backend")
.devices
.values()
.flat_map(|device| device.outputs.values())
.filter(|output| output.mirror_source.is_none())
.map(|output| {
(
output.identity.clone(),
output.output.clone(),
output.mode,
output.settings.clone(),
)
})
.collect::<Vec<_>>();
outputs.sort_by(|left, right| left.0.cmp(&right.0));
let mut right_edge = 0;
let runtime = outputs
.into_iter()
.map(|(identity, output, mode, settings)| {
let position = settings.position.unwrap_or_else(|| {
state
.space
.output_geometry(&output)
.map(|geometry| [geometry.loc.x, geometry.loc.y])
.unwrap_or([right_edge, 0])
});
let mode = OutputMode::from(mode);
let transform = output_transform(settings.transform);
let width =
(transform.transform_size(mode.size).w as f64 / settings.scale).ceil() as i32;
right_edge = right_edge.max(position[0].saturating_add(width));
crate::state::DesktopOutput {
output,
identity,
mode,
transform,
scale: Scale::Fractional(settings.scale),
position: (position[0], position[1]).into(),
}
})
.collect();
let changes = state
.publish_desktop(runtime)
.expect("validated usable output inventory");
for device in state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.devices
.values_mut()
{
for output in device.outputs.values_mut() {
if output.mirror_source.is_none() {
if output.global.is_none() {
output.global =
Some(output.output.create_global::<Ferese>(&state.display_handle));
}
} else {
// Mirrors are physical scanouts, not workspace-owning outputs.
output.output.change_current_state(
Some(OutputMode::from(output.mode)),
Some(output_transform(output.settings.transform)),
Some(Scale::Fractional(output.settings.scale)),
None,
);
}
output
.surface
.set_output_mode_source((&output.output).into());
}
}
changes
}
const SETTLE: Duration = Duration::from_millis(150);
/// Trailing-edge coalescing; inactive events retain dirtiness without arming a timer.
pub(super) fn hotplug(state: &mut Ferese) {
let Some(backend) = state.direct_backend.as_mut() else {
return;
};
backend.topology.dirty = true;
if let Some(token) = backend.topology.settle_timer.take() {
state.loop_handle.remove(token);
}
let deadline = backend
.topology
.coalescer
.request(monotonic_now(), backend.active && !backend.lid.pending());
if deadline.is_none() {
return;
}
match state
.loop_handle
.insert_source(Timer::from_duration(SETTLE), |_, _, state| {
if let Some(backend) = state.direct_backend.as_mut() {
backend.topology.settle_timer = None;
if !backend.topology.coalescer.take_due(monotonic_now()) {
return TimeoutAction::Drop;
}
}
reconcile_outputs(state, false);
TimeoutAction::Drop
}) {
Ok(token) => {
state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.topology
.settle_timer = Some(token)
}
Err(error) => {
tracing::warn!(%error, "could not debounce output events");
reconcile_outputs(state, false);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replacement_gpu_at_the_same_card_path_gets_its_own_slot() {
let card = PathBuf::from("/dev/dri/card0");
let a = PathBuf::from("/sys/devices/gpu-a");
let b = PathBuf::from("/sys/devices/gpu-b");
let node = 1;
let mut topology = Topology::<u64>::default();
topology.observe_devices(&[(card.clone(), Some(a.clone()))]);
topology.devices.get_mut(&a).unwrap().ready(node);
// A policy failure keeps the device open, so it must retain ownership
// of its node until replacement retirement runs.
topology
.devices
.get_mut(&a)
.unwrap()
.degraded(node, Instant::now());
let observed = [(card.clone(), Some(b.clone()))];
topology.observe_devices(&observed);
assert_eq!(
topology.retire_changed_devices(&observed, |_| Some(node)),
HashSet::from([node])
);
assert_eq!(topology.devices[&a].availability, Availability::Missing);
assert_eq!(topology.devices[&a].current_path(&a, &observed), None);
assert_eq!(
topology.devices[&b].current_path(&b, &observed),
Some(card.as_path())
);
topology.devices.get_mut(&b).unwrap().ready(node);
assert!(
topology
.retire_changed_devices(&observed, |_| Some(node))
.is_empty()
);
}
#[test]
fn renumbered_and_returning_gpus_keep_independent_slots() {
let a = PathBuf::from("/sys/devices/gpu-a");
let b = PathBuf::from("/sys/devices/gpu-b");
let card0 = PathBuf::from("/dev/dri/card0");
let card2 = PathBuf::from("/dev/dri/card2");
let node0 = 1;
let node2 = 2;
let mut topology = Topology::<u64>::default();
topology.observe_devices(&[(card0.clone(), Some(a.clone()))]);
topology.devices.get_mut(&a).unwrap().ready(node0);
let observed = [
(card0.clone(), Some(b.clone())),
(card2.clone(), Some(a.clone())),
];
for _ in 0..2 {
topology.observe_devices(&observed);
}
assert_eq!(topology.devices.len(), 2);
assert_eq!(
topology.devices[&a].current_path(&a, &observed),
Some(card2.as_path())
);
assert_eq!(
topology.devices[&b].current_path(&b, &observed),
Some(card0.as_path())
);
assert_eq!(
topology.retire_changed_devices(&observed, |path| Some(if path == card0 {
node0
} else {
node2
})),
HashSet::from([node0])
);
}
#[test]
fn discovering_sysfs_identity_preserves_the_existing_slot() {
let card = PathBuf::from("/dev/dri/card0");
let identity = PathBuf::from("/sys/devices/gpu");
let node = 1;
let mut topology = Topology::<u64>::default();
topology.observe_devices(&[(card.clone(), None)]);
topology.devices.get_mut(&card).unwrap().ready(node);
topology.observe_devices(&[(card.clone(), Some(identity.clone()))]);
assert_eq!(topology.devices.len(), 1);
assert_eq!(
topology.devices[&identity].availability,
Availability::Ready(node)
);
}
#[test]
fn temporary_identity_loss_reconciles_through_the_card_path() {
let card = PathBuf::from("/dev/dri/card0");
let identity = PathBuf::from("/sys/devices/gpu");
let known = [(card.clone(), Some(identity.clone()))];
let unknown = [(card.clone(), None)];
let mut topology = Topology::<u64>::default();
topology.observe_devices(&known);
topology.devices.get_mut(&identity).unwrap().ready(1);
topology.observe_devices(&unknown);
assert_eq!(
topology.retire_changed_devices(&unknown, |_| Some(1)),
HashSet::from([1])
);
assert_eq!(
topology.devices[&identity].availability,
Availability::Missing
);
assert_eq!(
topology.devices[&card].current_path(&card, &unknown),
Some(card.as_path())
);
// Reconciliation reopens the node using its card-path fallback.
topology.devices.get_mut(&card).unwrap().ready(1);
topology.observe_devices(&known);
assert_eq!(topology.devices.len(), 1);
assert!(!topology.devices.contains_key(&card));
assert_eq!(
topology.devices[&identity].current_path(&identity, &known),
Some(card.as_path())
);
// The reopened node is still valid and can be reused for this identity.
assert!(
topology
.retire_changed_devices(&known, |_| Some(1))
.is_empty()
);
topology.devices.get_mut(&identity).unwrap().ready(1);
assert!(
topology
.retire_changed_devices(&known, |_| Some(1))
.is_empty()
);
}
#[test]
fn swapping_device_numbers_retires_both_before_reopening_either() {
let a = PathBuf::from("/sys/devices/gpu-a");
let b = PathBuf::from("/sys/devices/gpu-b");
let card0 = PathBuf::from("/dev/dri/card0");
let card1 = PathBuf::from("/dev/dri/card1");
let mut topology = Topology::<u64>::default();
topology.observe_devices(&[
(card0.clone(), Some(a.clone())),
(card1.clone(), Some(b.clone())),
]);
topology.devices.get_mut(&a).unwrap().ready(0);
topology.devices.get_mut(&b).unwrap().ready(1);
let swapped = [
(card0.clone(), Some(b.clone())),
(card1.clone(), Some(a.clone())),
];
let node_for_path = |path: &Path| Some(u64::from(path == card1));
topology.observe_devices(&swapped);
assert_eq!(
topology.retire_changed_devices(&swapped, node_for_path),
HashSet::from([0, 1])
);
assert_eq!(topology.devices[&a].availability, Availability::Missing);
assert_eq!(topology.devices[&b].availability, Availability::Missing);
topology.devices.get_mut(&a).unwrap().ready(1);
topology.devices.get_mut(&b).unwrap().ready(0);
assert!(
topology
.retire_changed_devices(&swapped, node_for_path)
.is_empty()
);
}
#[test]
fn inactive_changes_coalesce_and_reconciliation_cannot_reenter() {
let mut topology = Topology::<u64>::default();
assert!(!topology.request(false));
assert!(!topology.request(false));
assert!(topology.dirty);
assert!(topology.request(true));
topology.reconciling = true;
assert!(!topology.request(true));
}
#[test]
fn replug_matches_hardware_even_when_card_numbers_change() {
let original = Path::new("/dev/dri/card0");
let identity = PathBuf::from("/sys/devices/pci0000:00/0000:00:02.0");
let mut slot = DeviceState::<u64> {
identity: Some(identity.clone()),
..DeviceState::default()
};
slot.failed(Instant::now());
slot.missing();
let observed = vec![
(
PathBuf::from("/dev/dri/card0"),
Some(PathBuf::from("/sys/devices/other-gpu")),
),
(PathBuf::from("/dev/dri/card2"), Some(identity)),
];
assert_eq!(
slot.current_path(original, &observed),
Some(Path::new("/dev/dri/card2"))
);
assert!(slot.current_path(original, &observed[..1]).is_none());
assert!(slot.current_path(original, &[]).is_none());
}
#[test]
fn unavailable_devices_retry_independently_with_capped_backoff() {
let now = Instant::now();
let mut topology = Topology::<u64>::default();
topology
.devices
.insert("/dev/dri/card0".into(), DeviceState::default());
let healthy = 1;
let mut second = DeviceState::default();
second.ready(healthy);
topology.devices.insert("/dev/dri/card1".into(), second);
let device = topology
.devices
.get_mut(Path::new("/dev/dri/card0"))
.unwrap();
device.failed(now);
assert_eq!(device.retry_at, Some(now + Duration::from_secs(1)));
for _ in 0..20 {
device.failed(now);
}
assert_eq!(device.retry_at, Some(now + Duration::from_secs(32)));
assert_eq!(
topology.devices[Path::new("/dev/dri/card1")].availability,
Availability::Ready(healthy)
);
assert!(topology.retry_deadline(false).is_none());
assert_eq!(
topology.retry_deadline(true),
Some(now + Duration::from_secs(32))
);
topology
.devices
.get_mut(Path::new("/dev/dri/card0"))
.unwrap()
.missing();
assert!(topology.retry_deadline(true).is_none());
}
}
@@ -0,0 +1,748 @@
//! Validate a device-wide desired topology, then use Smithay for every real commit.
//! Smithay exposes TEST_ONLY per surface, but not a combined CRTC request. The
//! one raw request here is validation only; mode/plane ownership stays in Smithay.
use super::{
ControlDevice, DesiredOutputConfiguration, DirectDevice, DirectOutput, DrmDevice, DrmDeviceFd,
DrmMode, DrmNode, Error, Ferese, Fourcc, GbmAllocator, GbmBufferFlags, GbmFramebufferExporter,
GlesRenderer, HashMap, HashSet, OutputCompositor, OutputMode, OutputModeSource, OutputScan,
OutputSelection, OutputSettings, PrimaryPlaneElement, cancel_output_timers,
connected_mode_info, connector, create_direct_output, create_output, crtc, drm_device_key,
internal_connector, io, output_transform, property, remember_applied_configuration,
rescan_device, validate_desired_outputs,
};
use smithay::backend::allocator::Allocator;
use smithay::backend::drm::exporter::{ExportBuffer, ExportFramebuffer};
use smithay::reexports::drm::control::atomic::AtomicModeReq;
use smithay::reexports::drm::control::{AtomicCommitFlags, ResourceHandle};
fn property<D: ControlDevice, H: ResourceHandle>(
drm: &D,
handle: H,
name: &str,
) -> io::Result<property::Handle> {
for (property, _) in drm.get_properties(handle)?.iter() {
if drm.get_property(*property)?.name().to_bytes() == name.as_bytes() {
return Ok(*property);
}
}
Err(io::Error::other(format!("missing DRM property {name}")))
}
fn add<H: ResourceHandle>(
drm: &DrmDevice,
request: &mut AtomicModeReq,
handle: H,
name: &str,
value: u64,
) -> io::Result<()> {
request.add_raw_property(handle.into(), property(drm, handle, name)?, value);
Ok(())
}
fn clear_secondary_planes(
drm: &DrmDevice,
request: &mut AtomicModeReq,
crtc: crtc::Handle,
) -> Result<(), Box<dyn Error>> {
let planes = drm.planes(&crtc)?;
for plane in planes.cursor.iter().chain(&planes.overlay) {
let properties = drm.get_properties(plane.handle)?;
let owned = properties.iter().any(|(handle, value)| {
drm.get_property(*handle).is_ok_and(|info| {
info.name().to_bytes() == b"CRTC_ID" && *value == u32::from(crtc) as u64
})
});
if owned {
add(drm, request, plane.handle, "FB_ID", 0)?;
add(drm, request, plane.handle, "CRTC_ID", 0)?;
}
}
Ok(())
}
struct Blobs {
fd: DrmDeviceFd,
ids: Vec<u64>,
}
impl Drop for Blobs {
fn drop(&mut self) {
for id in &self.ids {
let _ = self.fd.destroy_property_blob(*id);
}
}
}
pub(super) fn validate_device(
device: &DirectDevice,
scan: &OutputScan,
) -> Result<(), Box<dyn Error>> {
if scan.selections.iter().all(|selection| {
device.outputs.get(&selection.crtc).is_some_and(|output| {
output.connector == selection.connector.handle() && output.mode == selection.mode
})
}) && scan.selections.len() == device.outputs.len()
{
return Ok(());
}
// Legacy KMS has no atomic test. Smithay validates its surface/mode as far as
// the API permits; commit failures still go through the restoration path.
if !device.drm.is_atomic() {
return Ok(());
}
let drm = &device.drm;
let mut request = AtomicModeReq::new();
let mut blobs = Blobs {
fd: drm.device_fd().clone(),
ids: Vec::new(),
};
let mut buffers = Vec::new();
let mut framebuffers = Vec::new();
let mut allocator = GbmAllocator::new(
device.gbm.clone(),
GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT,
);
let exporter = GbmFramebufferExporter::new(device.gbm.clone(), Some(device.render_node).into());
let mut used_planes = HashSet::new();
let desired_crtcs = scan
.selections
.iter()
.map(|selection| selection.crtc)
.collect::<HashSet<_>>();
let desired_connectors = scan
.selections
.iter()
.map(|selection| selection.connector.handle())
.collect::<HashSet<_>>();
for output in device.outputs.values() {
if !desired_connectors.contains(&output.connector) {
add(drm, &mut request, output.connector, "CRTC_ID", 0)?;
}
if !desired_crtcs.contains(&output.surface.crtc()) {
clear_secondary_planes(drm, &mut request, output.surface.crtc())?;
add(drm, &mut request, output.surface.crtc(), "ACTIVE", 0)?;
add(drm, &mut request, output.surface.crtc(), "MODE_ID", 0)?;
add(drm, &mut request, output.surface.plane(), "FB_ID", 0)?;
add(drm, &mut request, output.surface.plane(), "CRTC_ID", 0)?;
}
}
for selection in &scan.selections {
clear_secondary_planes(drm, &mut request, selection.crtc)?;
let planes = drm.planes(&selection.crtc)?;
let primary = device
.outputs
.get(&selection.crtc)
.map(|output| output.surface.plane())
.or_else(|| {
planes
.primary
.iter()
.find(|plane| !used_planes.contains(&plane.handle))
.map(|plane| plane.handle)
})
.ok_or("no usable primary plane")?;
used_planes.insert(primary);
if let Ok(rotation) = property(drm, primary, "rotation") {
request.add_raw_property(primary.into(), rotation, 1);
}
let blob: u64 = drm.create_property_blob(&selection.mode)?.into();
blobs.ids.push(blob);
add(drm, &mut request, selection.crtc, "MODE_ID", blob)?;
add(drm, &mut request, selection.crtc, "ACTIVE", 1)?;
add(
drm,
&mut request,
selection.connector.handle(),
"CRTC_ID",
u32::from(selection.crtc) as u64,
)?;
let (width, height) = selection.mode.size();
let formats = &planes
.primary
.iter()
.find(|plane| plane.handle == primary)
.ok_or("primary plane disappeared")?
.formats;
let mut allocated = None;
for format in formats.iter().filter(|format| {
matches!(
format.code,
Fourcc::Argb8888 | Fourcc::Abgr8888 | Fourcc::Xrgb8888 | Fourcc::Xbgr8888
)
}) {
if let Ok(buffer) = allocator.create_buffer(
width.into(),
height.into(),
format.code,
&[format.modifier],
) && let Ok(Some(fb)) =
exporter.add_framebuffer(drm.device_fd(), ExportBuffer::Allocator(&buffer), false)
{
allocated = Some((buffer, fb));
break;
}
}
let (buffer, fb) = allocated.ok_or("unable to allocate a DRM validation buffer")?;
add(
drm,
&mut request,
primary,
"FB_ID",
u32::from(*fb.as_ref()) as u64,
)?;
add(
drm,
&mut request,
primary,
"CRTC_ID",
u32::from(selection.crtc) as u64,
)?;
for (name, value) in [
("SRC_X", 0),
("SRC_Y", 0),
("SRC_W", (width as u64) << 16),
("SRC_H", (height as u64) << 16),
("CRTC_X", 0),
("CRTC_Y", 0),
("CRTC_W", width as u64),
("CRTC_H", height as u64),
] {
add(drm, &mut request, primary, name, value)?;
}
buffers.push(buffer);
framebuffers.push(fb);
}
drm.atomic_commit(
AtomicCommitFlags::TEST_ONLY | AtomicCommitFlags::ALLOW_MODESET,
request,
)?;
Ok(())
}
fn prepare(
surface: &mut OutputCompositor,
renderer: &mut GlesRenderer,
mode: DrmMode,
settings: &OutputSettings,
) -> Result<(), Box<dyn Error>> {
surface.use_mode(mode)?;
surface.set_output_mode_source(OutputModeSource::Static {
size: OutputMode::from(mode).size,
scale: settings.scale.into(),
transform: output_transform(settings.transform),
});
surface.reset_buffer_ages();
let elements: Vec<crate::render::AnimatedWindowRenderElement> = Vec::new();
let result = surface.render_frame(
renderer,
&elements,
[0.0, 0.0, 0.0, 1.0],
smithay::backend::drm::compositor::FrameFlags::empty(),
)?;
if let PrimaryPlaneElement::Swapchain(primary) = &result.primary_element {
primary.sync.wait()?;
}
Ok(())
}
/// Retire a logical output only after its hardware is unavailable or confirmed disabled.
fn retire(state: &mut Ferese, output: &mut DirectOutput, node: DrmNode, crtc: crtc::Handle) {
output.gamma = Default::default();
cancel_output_timers(&state.loop_handle, output);
if let Some(global) = output.global.take() {
state.display_handle.disable_global::<Ferese>(global);
}
state.session_lock.output_removed(&output.output);
state.unregister_output(&output.output);
state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.presentation
.remove(&(node, crtc));
}
struct DeviceTransaction<'a> {
state: &'a mut Ferese,
device: &'a mut DirectDevice,
selections: &'a HashMap<crtc::Handle, OutputSelection>,
staged: HashMap<crtc::Handle, DirectOutput>,
replaced: HashSet<crtc::Handle>,
}
impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> {
type Key = crtc::Handle;
fn stage(&mut self, crtc: crtc::Handle) -> Result<(), String> {
let selection = &self.selections[&crtc];
if self
.device
.outputs
.get(&crtc)
.is_none_or(|output| output.connector != selection.connector.handle())
{
let output = create_direct_output(
self.state,
&mut self.device.drm,
&self.device.gbm,
&self.device.renderer,
selection.clone(),
false,
)
.map_err(|error| error.to_string())?;
self.staged.insert(crtc, output);
}
let output = self
.staged
.get_mut(&crtc)
.or_else(|| self.device.outputs.get_mut(&crtc))
.expect("the selected CRTC has a live or staged output in this transaction");
output
.surface
.set_connectors(&[selection.connector.handle()])
.map_err(|error| error.to_string())?;
output.dmabuf_feedback.invalidate();
prepare(
&mut output.surface,
&mut self.device.renderer,
selection.mode,
&selection.settings,
)
.map_err(|error| error.to_string())
}
fn commit(&mut self, crtc: crtc::Handle) -> Result<(), String> {
if self.staged.contains_key(&crtc) && self.device.outputs.contains_key(&crtc) {
// Connector and mode cannot be changed together through use_mode /
// set_connectors: each tests against the other's old value. Stage a
// replacement surface, retire the old one *before* its first commit
// (Smithay clears the CRTC on surface drop), then publish metadata.
let replacement = self
.staged
.remove(&crtc)
.expect("the selected CRTC has a live or staged output in this transaction");
let previous = self
.device
.outputs
.get_mut(&crtc)
.expect("the selected CRTC has a live or staged output in this transaction");
previous.surface = replacement.surface;
previous.dmabuf_feedback = replacement.dmabuf_feedback;
self.replaced.insert(crtc);
}
self.staged
.get_mut(&crtc)
.or_else(|| self.device.outputs.get_mut(&crtc))
.expect("the selected CRTC has a live or staged output in this transaction")
.surface
.commit_frame()
.map_err(|error| error.to_string())
}
fn disable(&mut self, crtc: crtc::Handle) -> Result<(), String> {
self.device
.outputs
.get_mut(&crtc)
.expect("the selected CRTC has a live or staged output in this transaction")
.surface
.clear()
.map_err(|error| error.to_string())
}
fn abort_staged(&mut self) {
for output in self.staged.values_mut() {
let _ = output.surface.clear();
}
self.staged.clear();
}
fn restore(&mut self, crtc: crtc::Handle) -> Result<(), String> {
if self.replaced.contains(&crtc) {
let output = &self.device.outputs[&crtc];
let connector = self
.device
.drm
.get_connector(output.connector, true)
.map_err(|error| error.to_string())?;
if connector.state() != connector::State::Connected {
return Err("previous connector is no longer available".into());
}
let selection = OutputSelection {
connector,
crtc,
mode: output.mode,
settings: output.settings.clone(),
identity: output.identity.clone(),
mirror_source: output.mirror_source.clone(),
};
let mut replacement = create_direct_output(
self.state,
&mut self.device.drm,
&self.device.gbm,
&self.device.renderer,
selection,
false,
)
.map_err(|error| error.to_string())?;
prepare(
&mut replacement.surface,
&mut self.device.renderer,
replacement.mode,
&replacement.settings,
)
.map_err(|error| error.to_string())?;
let previous = self
.device
.outputs
.get_mut(&crtc)
.expect("the selected CRTC has a live or staged output in this transaction");
previous.surface = replacement.surface;
previous.dmabuf_feedback = replacement.dmabuf_feedback;
previous
.surface
.commit_frame()
.map_err(|error| error.to_string())?;
}
let Some(output) = self.device.outputs.get_mut(&crtc) else {
return Ok(());
};
output.dmabuf_feedback.invalidate();
output
.surface
.reset_state()
.map_err(|error| error.to_string())?;
let restored = output
.surface
.set_connectors(&[output.connector])
.map_err(|error| error.to_string())
.and_then(|()| {
prepare(
&mut output.surface,
&mut self.device.renderer,
output.mode,
&output.settings,
)
.map_err(|error| error.to_string())
})
.and_then(|()| {
output
.surface
.commit_frame()
.map_err(|error| error.to_string())
});
self.state.session_lock.output_added(&output.output);
self.state
.display_presentation
.remove_output(&output.output);
self.state
.desktop_transition
.as_mut()
.expect("desktop transition must be started before publication or retirement")
.redraw
.insert(output.output.clone());
output.lock_frame_pending = false;
output
.surface
.set_output_mode_source((&output.output).into());
cancel_output_timers(&self.state.loop_handle, output);
output.frame_pending = false;
restored
}
}
/// A complete device test precedes application. Release former connector owners
/// before per-surface tests; publish only after the final inventory is usable.
pub(super) fn apply_device(
state: &mut Ferese,
node: DrmNode,
device: &mut DirectDevice,
scan: OutputScan,
) -> Result<(), String> {
let selections = scan
.selections
.into_iter()
.map(|selection| (selection.crtc, selection))
.collect::<HashMap<_, _>>();
let mut changed = selections
.iter()
.filter_map(|(crtc, selection)| {
let same = device.outputs.get(crtc).is_some_and(|output| {
output.connector == selection.connector.handle()
&& output.identity == selection.identity
&& output.mode == selection.mode
&& output.settings.same_configuration(&selection.settings)
&& output.mirror_source == selection.mirror_source
});
(!same).then_some(*crtc)
})
.collect::<Vec<_>>();
changed.sort_by_key(|crtc| (selections[crtc].mirror_source.is_some(), u32::from(*crtc)));
let removals = device
.outputs
.keys()
.filter(|crtc| !selections.contains_key(crtc))
.copied()
.collect::<Vec<_>>();
// A connector replacement can reuse the same CRTC, so it is not a removal
// from the keyed inventory. Its old routing/resources must still be released
// before staging any other output's new mode (for example HDMI -> DP).
let mut releases = removals.clone();
releases.extend(selections.iter().filter_map(|(crtc, selection)| {
device
.outputs
.get(crtc)
.and_then(|output| (output.connector != selection.connector.handle()).then_some(*crtc))
}));
releases.sort_by_key(|crtc| u32::from(*crtc));
let hardware_changes = changed
.iter()
.filter(|crtc| {
!device.outputs.get(crtc).is_some_and(|output| {
let selection = &selections[crtc];
output.connector == selection.connector.handle() && output.mode == selection.mode
})
})
.copied()
.collect::<Vec<_>>();
let mut driver = DeviceTransaction {
state,
device,
selections: &selections,
staged: HashMap::new(),
replaced: HashSet::new(),
};
let result = crate::output_transaction::apply(&mut driver, &hardware_changes, &releases);
let mut staged = std::mem::take(&mut driver.staged);
if let Err(failure) = result {
for crtc in failure.unavailable {
if let Some(mut output) = device.outputs.remove(&crtc) {
retire(state, &mut output, node, crtc);
}
}
return Err(failure.error);
}
for crtc in &changed {
let selection = &selections[crtc];
let mut output = staged
.remove(crtc)
.or_else(|| device.outputs.remove(crtc))
.expect("the selected CRTC has a live or staged output in this transaction");
if output.connector != selection.connector.handle() || output.identity != selection.identity
{
retire(state, &mut output, node, *crtc);
let (handle, _) = create_output(
state,
&selection.connector,
selection.mode,
selection.identity.clone(),
&selection.settings,
false,
);
output.output = handle;
}
if selection.mirror_source.is_some() && output.global.is_some() {
retire(state, &mut output, node, *crtc);
}
output.connector = selection.connector.handle();
output.internal = internal_connector(selection.connector.interface());
output.identity = selection.identity.clone();
output.settings = selection.settings.clone();
output.mode = selection.mode;
output.mirror_source = selection.mirror_source.clone();
// Resource metadata describes applied hardware. Wayland output state,
// workspace ownership and floating positions publish once after all GPUs.
state
.desktop_transition
.as_mut()
.expect("output reconciliation boundary")
.redraw
.insert(output.output.clone());
state.session_lock.output_added(&output.output);
if hardware_changes.contains(crtc) {
state.display_presentation.remove_output(&output.output);
}
output.lock_frame_pending = false;
output
.surface
.set_output_mode_source((&output.output).into());
cancel_output_timers(&state.loop_handle, &mut output);
output.frame_pending = false;
output.primary_commit = None;
output.capture_texture = Default::default();
output.power.suspended();
let clock = state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.presentation
.entry((node, *crtc))
.or_default();
clock.set_refresh(OutputMode::from(output.mode).refresh);
clock.reset_timing();
device.outputs.insert(*crtc, output);
}
// Retire resources now; the final inventory determines evacuation once.
for crtc in removals {
let mut output = device
.outputs
.remove(&crtc)
.expect("the selected CRTC has a live or staged output in this transaction");
retire(state, &mut output, node, crtc);
}
device.connected_outputs = scan.connected_outputs;
update_applied_info(device);
Ok(())
}
pub(super) fn update_applied_info(device: &mut DirectDevice) {
for info in &mut device.connected_outputs {
info.enabled = false;
info.current_mode = None;
if let Some(output) = device
.outputs
.values()
.find(|output| output.identity == info.identity)
{
info.enabled = true;
info.current_mode = Some(connected_mode_info(output.mode));
info.scale = output.settings.scale;
info.transform = output.settings.transform;
info.configured_position = output.settings.position;
}
}
}
/// Disconnected hardware cannot remain logically live, even if a new policy fails validation.
pub(super) fn retire_disconnected(state: &mut Ferese) {
let missing = state
.direct_backend
.as_ref()
.expect("DRM callbacks require an installed direct backend")
.devices
.iter()
.flat_map(|(node, device)| {
device.outputs.iter().filter_map(|(crtc, output)| {
device
.drm
.get_connector(output.connector, true)
.ok()
.filter(|connector| connector.state() != connector::State::Connected)
.map(|_| (*node, *crtc))
})
})
.collect::<Vec<_>>();
for (node, crtc) in missing {
if let Some(mut output) = state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.devices
.get_mut(&node)
.expect("the selected DRM device must remain registered during this operation")
.outputs
.remove(&crtc)
{
let _ = output.surface.clear();
retire(state, &mut output, node, crtc);
}
}
}
/// If initial activation or rollback leaves no usable desktop, try a preferred
/// mode on each physical monitor independently. Never disable a surviving output.
pub(super) fn recover_last_output(state: &mut Ferese) {
if state
.direct_backend
.as_ref()
.expect("DRM callbacks require an installed direct backend")
.devices
.values()
.any(|device| {
device
.outputs
.values()
.any(|output| output.mirror_source.is_none())
})
{
return;
}
let backend = state
.direct_backend
.as_ref()
.expect("DRM callbacks require an installed direct backend");
let requested = backend.desired_outputs.clone();
let original_error = backend.output_error.clone();
let mut candidates = backend
.monitors
.iter()
.filter(|monitor| monitor.usable)
.cloned()
.collect::<Vec<_>>();
candidates.sort_by_key(|monitor| (!monitor.internal, monitor.key.clone()));
for candidate in candidates {
let backend = state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend");
backend.desired_outputs = DesiredOutputConfiguration {
profile: None,
suspend: requested.suspend,
outputs: backend
.monitors
.iter()
.map(|monitor| {
let mut settings = crate::output_policy::defaults(monitor.identity.clone());
settings.enabled = monitor.key == candidate.key;
crate::output_policy::DesiredOutput {
key: monitor.key.clone(),
settings,
mirror_source: None,
}
})
.collect(),
};
let nodes = backend
.devices
.iter()
.filter(|(_, device)| {
candidate
.key
.starts_with(&format!("{}/", drm_device_key(&device.drm)))
})
.map(|(node, _)| *node)
.collect::<Vec<_>>();
if validate_desired_outputs(state).is_ok() {
for node in nodes {
let _ = rescan_device(state, node);
}
if state
.direct_backend
.as_ref()
.expect("DRM callbacks require an installed direct backend")
.devices
.values()
.any(|device| {
device
.outputs
.values()
.any(|output| output.mirror_source.is_none())
})
{
remember_applied_configuration(state);
state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend")
.output_error = original_error;
tracing::warn!(identity = %candidate.identity, "recovered last usable output with preferred mode");
return;
}
}
}
let backend = state
.direct_backend
.as_mut()
.expect("DRM callbacks require an installed direct backend");
backend.desired_outputs = requested;
backend.output_error = original_error;
}
+85 -8
View File
@@ -7,6 +7,7 @@ use smithay::reexports::calloop::EventLoop;
use crate::Ferese;
use crate::private_client::ClientCapabilities;
use crate::session_environment::SessionPolicy;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BackendKind {
@@ -17,22 +18,29 @@ pub enum BackendKind {
#[derive(Debug, Eq, PartialEq)]
pub struct LaunchConfig {
pub backend: BackendKind,
pub(crate) session_policy: SessionPolicy,
pub client: Vec<OsString>,
pub client_capabilities: ClientCapabilities,
pub supervise_client: bool,
}
impl LaunchConfig {
pub fn from_environment() -> Result<Self, String> {
let graphical_session = std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some();
let graphical_session =
std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some();
Self::parse(std::env::args_os().skip(1), graphical_session)
}
fn parse(args: impl IntoIterator<Item = OsString>, graphical_session: bool) -> Result<Self, String> {
fn parse(
args: impl IntoIterator<Item = OsString>,
graphical_session: bool,
) -> Result<Self, String> {
let mut args = args.into_iter().peekable();
let mut requested_backend = None;
let mut client = Vec::new();
let mut client_capabilities = ClientCapabilities::default();
let mut supervise_client = false;
while let Some(argument) = args.next() {
if argument == "--" {
@@ -50,6 +58,10 @@ impl LaunchConfig {
client_capabilities.insert(ClientCapabilities::EFFECTS);
continue;
}
if argument == "--supervise-client" {
supervise_client = true;
continue;
}
if argument == "--grant-shell-control" {
client_capabilities.insert(ClientCapabilities::SHELL_CONTROL);
continue;
@@ -73,10 +85,18 @@ impl LaunchConfig {
BackendKind::Drm
});
if supervise_client && client.is_empty() {
return Err("--supervise-client requires a client command".into());
}
Ok(Self {
backend,
session_policy: match backend {
BackendKind::Drm => SessionPolicy::Desktop,
BackendKind::Nested => SessionPolicy::Embedded,
},
client,
client_capabilities,
supervise_client,
})
}
}
@@ -97,7 +117,10 @@ fn parse_backend(value: &OsString) -> Result<Option<BackendKind>, String> {
Some("auto") => Ok(None),
Some("nested") => Ok(Some(BackendKind::Nested)),
Some("drm") => Ok(Some(BackendKind::Drm)),
_ => Err(format!("unknown backend {:?}; expected auto, nested, or drm", value)),
_ => Err(format!(
"unknown backend {:?}; expected auto, nested, or drm",
value
)),
}
}
@@ -110,8 +133,33 @@ mod tests {
let config = LaunchConfig::parse([OsString::from("foot")], true).unwrap();
assert_eq!(config.backend, BackendKind::Nested);
assert_eq!(config.session_policy, SessionPolicy::Embedded);
assert_eq!(config.client, [OsString::from("foot")]);
assert!(config.client_capabilities.is_empty());
assert!(!config.supervise_client);
}
#[test]
fn supervision_requires_an_explicit_client() {
assert!(LaunchConfig::parse([OsString::from("--supervise-client")], true).is_err());
let config = LaunchConfig::parse(
[
"--supervise-client",
"--grant-shell-control",
"--",
"ferese-session-shell",
]
.map(OsString::from),
true,
)
.unwrap();
assert!(config.supervise_client);
assert_eq!(config.client, [OsString::from("ferese-session-shell")]);
assert!(
config
.client_capabilities
.contains(ClientCapabilities::SHELL_CONTROL)
);
}
#[test]
@@ -119,6 +167,7 @@ mod tests {
let config = LaunchConfig::parse(Vec::<OsString>::new(), false).unwrap();
assert_eq!(config.backend, BackendKind::Drm);
assert_eq!(config.session_policy, SessionPolicy::Desktop);
assert!(config.client.is_empty());
assert!(config.client_capabilities.is_empty());
}
@@ -156,26 +205,54 @@ mod tests {
.unwrap();
assert_eq!(config.client, [OsString::from("ferese-effects-probe")]);
assert!(config.client_capabilities.contains(ClientCapabilities::EFFECTS));
assert!(
config
.client_capabilities
.contains(ClientCapabilities::EFFECTS)
);
}
#[test]
fn explicitly_grants_shell_control_to_the_launched_private_client() {
let config = LaunchConfig::parse(
[OsString::from("--grant-shell-control"), OsString::from("ferese-shell")],
[
OsString::from("--grant-shell-control"),
OsString::from("ferese-shell"),
],
true,
)
.unwrap();
assert_eq!(config.client, [OsString::from("ferese-shell")]);
assert!(config.client_capabilities.contains(ClientCapabilities::SHELL_CONTROL));
assert!(!config.client_capabilities.contains(ClientCapabilities::EFFECTS));
assert!(
config
.client_capabilities
.contains(ClientCapabilities::SHELL_CONTROL)
);
assert!(
!config
.client_capabilities
.contains(ClientCapabilities::EFFECTS)
);
}
#[test]
fn rejects_an_unknown_backend() {
let error = LaunchConfig::parse([OsString::from("--backend"), OsString::from("other")], true).unwrap_err();
let error =
LaunchConfig::parse([OsString::from("--backend"), OsString::from("other")], true)
.unwrap_err();
assert!(error.contains("unknown backend"));
}
#[test]
fn explicit_backend_selects_the_default_session_policy() {
for (backend, graphical, policy) in [
("--backend=drm", true, SessionPolicy::Desktop),
("--backend=nested", false, SessionPolicy::Embedded),
] {
let config = LaunchConfig::parse([OsString::from(backend)], graphical).unwrap();
assert_eq!(config.session_policy, policy);
}
}
}
+11 -6
View File
@@ -17,10 +17,10 @@ fn read_battery_percent(root: &Path) -> Option<u8> {
.ok()
.map(|text| text.trim().to_owned())
};
if entries
.iter()
.any(|path| read(path, "online").as_deref() == Some("1") && read(path, "type").as_deref() != Some("Battery"))
{
if entries.iter().any(|path| {
read(path, "online").as_deref() == Some("1")
&& read(path, "type").as_deref() != Some("Battery")
}) {
return None;
}
entries.iter().find_map(|path| {
@@ -57,7 +57,9 @@ pub(super) fn low_refresh_mode(size: (u16, u16), modes: &[(u16, u16, i32)]) -> O
modes
.iter()
.enumerate()
.filter(|(_, (width, height, refresh))| (*width, *height) == size && (59_000..=61_000).contains(refresh))
.filter(|(_, (width, height, refresh))| {
(*width, *height) == size && (59_000..=61_000).contains(refresh)
})
.min_by_key(|(_, (_, _, refresh))| refresh.abs_diff(60_000))
.map(|(index, _)| index)
}
@@ -96,7 +98,10 @@ mod tests {
assert_eq!(low_refresh_mode((2880, 1800), &modes), Some(3));
assert_eq!(low_refresh_mode((2880, 1800), &modes[..3]), Some(2));
assert_eq!(low_refresh_mode((2880, 1800), &modes[..2]), None);
assert_eq!(low_refresh_mode((2880, 1800), &[(2880, 1800, -60_000)]), None);
assert_eq!(
low_refresh_mode((2880, 1800), &[(2880, 1800, -60_000)]),
None
);
}
#[test]
+646 -174
View File
File diff suppressed because it is too large. Load diff
+122 -53
View File
@@ -1,45 +1,72 @@
use super::*;
use super::{
Config, ConfigError, Deserialize, default_border_width, default_focus_ring_width,
default_shadow_blur, default_shadow_offset_y, default_shadow_opacity, default_window_radius,
};
impl Config {
pub(crate) fn inactive_dim_settings(&self) -> Result<InactiveDimSettings, ConfigError> {
Ok(InactiveDimSettings {
enabled: self.appearance.inactive_dim.enabled,
amount: unit_theme_value(self.appearance.inactive_dim.amount, "appearance.inactive_dim.amount")?,
pub(crate) fn focus_effect_settings(&self) -> Result<FocusEffectSettings, ConfigError> {
let effect = &self.appearance.focus_effect;
Ok(FocusEffectSettings {
enabled: effect.enabled,
blur: effect.blur,
active_opacity: unit_theme_value(
effect.active_opacity,
"appearance.focus_effect.active_opacity",
)?,
inactive_opacity: unit_theme_value(
effect.inactive_opacity,
"appearance.focus_effect.inactive_opacity",
)?,
inactive_dim: unit_theme_value(
effect.inactive_dim,
"appearance.focus_effect.inactive_dim",
)?,
duration_ms: nonnegative_theme_value(
self.appearance.inactive_dim.duration_ms,
"appearance.inactive_dim.duration_ms",
effect.duration_ms,
"appearance.focus_effect.duration_ms",
)?,
})
}
pub fn theme_settings(&self) -> Result<ThemeSettings, ConfigError> {
let border_width = nonnegative_theme_value(self.theme.geometry.border_width, "geometry.border_width")?;
let focus_ring_width =
nonnegative_theme_value(self.theme.geometry.focus_ring_width, "geometry.focus_ring_width")?;
let window_radius = nonnegative_theme_value(self.theme.geometry.window_radius, "geometry.window_radius")?;
let shadow_offset_y = finite_theme_value(self.theme.shadow.soft.offset_y, "shadow.soft.offset_y")?;
let border_width =
nonnegative_theme_value(self.theme.geometry.border_width, "geometry.border_width")?;
let focus_ring_width = nonnegative_theme_value(
self.theme.geometry.focus_ring_width,
"geometry.focus_ring_width",
)?;
let window_radius =
nonnegative_theme_value(self.theme.geometry.window_radius, "geometry.window_radius")?;
let shadow_offset_y =
finite_theme_value(self.theme.shadow.soft.offset_y, "shadow.soft.offset_y")?;
let shadow_blur = nonnegative_theme_value(self.theme.shadow.soft.blur, "shadow.soft.blur")?;
let shadow_opacity = unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?;
let shadow_opacity =
unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?;
let material_radius = nonnegative_theme_value(
self.theme
.geometry
.shell_radius
.or(self.appearance.corner_radius)
.or(self.theme.geometry.top_bar_radius)
.unwrap_or(14.0),
self.theme.geometry.shell_radius.unwrap_or(14.0),
"geometry.shell_radius",
)?;
Ok(ThemeSettings {
border_width,
focus_ring_width,
border_color: parse_color(&self.theme.colors.border, "colors.border")?,
text_primary_color: parse_color(&self.theme.colors.text_primary, "colors.text_primary")?,
text_primary_color: parse_color(
&self.theme.colors.text_primary,
"colors.text_primary",
)?,
accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?,
border_gradient: self.theme.border.settings("border")?,
focus_ring_gradient: self.theme.focus_ring.settings("focus_ring")?,
shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?,
surface_base_color: parse_color(&self.theme.colors.surface_base, "colors.surface_base")?,
surface_base_color: parse_color(
&self.theme.colors.surface_base,
"colors.surface_base",
)?,
surface_raised_color: parse_color(
&self.theme.colors.surface_raised,
"colors.surface_raised",
)?,
bar_background_color: parse_color(
self.theme
.surface
@@ -55,10 +82,18 @@ impl Config {
shadow_blur,
shadow_opacity,
material_style: self.theme.material.style,
backdrop_blur: nonnegative_theme_value(self.theme.material.blur_radius, "material.blur_radius")?.min(32.0),
material_tint_strength: unit_theme_value(self.theme.material.tint_strength, "material.tint_strength")?,
backdrop_blur: nonnegative_theme_value(
self.theme.material.blur_radius,
"material.blur_radius",
)?
.min(32.0),
material_tint_strength: unit_theme_value(
self.theme.material.tint_strength,
"material.tint_strength",
)?,
material_radius,
panel_radius: material_radius,
reduce_transparency: false,
})
}
}
@@ -145,6 +180,10 @@ pub(super) fn default_surface_base_color() -> String {
"#111821".to_owned()
}
pub(super) fn default_surface_raised_color() -> String {
ferese_config::theme::default_tokens().colors.surface_raised
}
pub(super) fn default_accent_color() -> String {
"#3D7BE6".to_owned()
}
@@ -163,12 +202,14 @@ pub struct BorderGradient {
pub angle: f64,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct BorderPaintConfig {
#[serde(default)]
pub(super) style: ferese_config::theme::PaintStyle,
pub(super) gradient: Option<BorderGradientConfig>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct BorderGradientConfig {
pub(super) from: String,
pub(super) to: String,
@@ -185,9 +226,14 @@ impl BorderPaintConfig {
"focus_ring.gradient.angle",
)
} else {
("border.gradient.from", "border.gradient.to", "border.gradient.angle")
(
"border.gradient.from",
"border.gradient.to",
"border.gradient.angle",
)
};
self.gradient
let gradient = self
.gradient
.as_ref()
.map(|gradient| {
Ok(BorderGradient {
@@ -196,7 +242,13 @@ impl BorderPaintConfig {
angle: finite_theme_value(gradient.angle, angle_name)?.rem_euclid(360.0),
})
})
.transpose()
.transpose()?;
Ok(if self.style == ferese_config::theme::PaintStyle::Solid {
None
} else {
gradient
})
}
}
@@ -210,6 +262,7 @@ pub struct ThemeSettings {
pub focus_ring_gradient: Option<BorderGradient>,
pub shadow_color: RgbaColor,
pub surface_base_color: RgbaColor,
pub surface_raised_color: RgbaColor,
pub bar_background_color: RgbaColor,
pub text_primary_color: RgbaColor,
pub shell_opacity: f64,
@@ -222,9 +275,12 @@ pub struct ThemeSettings {
pub material_tint_strength: f64,
pub material_radius: f64,
pub panel_radius: f64,
pub reduce_transparency: bool,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
#[derive(
Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(rename_all = "snake_case")]
pub enum MaterialStyle {
Translucent,
@@ -232,39 +288,48 @@ pub enum MaterialStyle {
Solid,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub(super) struct AppearanceConfig {
pub(super) corner_radius: Option<f64>,
#[serde(default)]
pub(super) inactive_dim: InactiveDimConfig,
pub(super) focus_effect: FocusEffectConfig,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct InactiveDimSettings {
pub struct FocusEffectSettings {
pub enabled: bool,
pub amount: f64,
pub blur: bool,
pub active_opacity: f64,
pub inactive_opacity: f64,
pub inactive_dim: f64,
pub duration_ms: f64,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub(super) struct InactiveDimConfig {
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)]
pub(super) struct FocusEffectConfig {
pub(super) enabled: bool,
pub(super) amount: f64,
pub(super) blur: bool,
pub(super) active_opacity: f64,
pub(super) inactive_opacity: f64,
pub(super) inactive_dim: f64,
pub(super) duration_ms: f64,
}
impl Default for InactiveDimConfig {
impl Default for FocusEffectConfig {
fn default() -> Self {
Self {
enabled: false,
amount: 0.15,
enabled: true,
blur: true,
active_opacity: 1.0,
inactive_opacity: 1.0,
inactive_dim: 0.0,
duration_ms: 150.0,
}
}
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeConfig {
#[serde(default)]
pub(super) border: BorderPaintConfig,
@@ -286,23 +351,23 @@ pub(super) struct ThemeConfig {
pub(super) surface: ThemeSurfaceConfig,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeSurfaceConfig {
#[serde(default)]
pub(super) bar: ThemeBarConfig,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeBarConfig {
pub(super) background: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct OverviewTypographyConfig {
pub(super) font_family: Option<String>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeMaterialConfig {
#[serde(default = "default_shell_opacity")]
pub(super) opacity: f64,
@@ -337,12 +402,14 @@ fn default_backdrop_blur() -> f64 {
12.0
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeColorsConfig {
#[serde(default = "default_text_primary_color")]
pub(super) text_primary: String,
#[serde(default = "default_surface_base_color")]
pub(super) surface_base: String,
#[serde(default = "default_surface_raised_color")]
pub(super) surface_raised: String,
#[serde(default = "default_border_color")]
pub(super) border: String,
#[serde(default = "default_accent_color")]
@@ -355,6 +422,7 @@ impl Default for ThemeColorsConfig {
fn default() -> Self {
Self {
surface_base: default_surface_base_color(),
surface_raised: default_surface_raised_color(),
text_primary: default_text_primary_color(),
border: default_border_color(),
accent: default_accent_color(),
@@ -363,13 +431,13 @@ impl Default for ThemeColorsConfig {
}
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeShadowConfig {
#[serde(default)]
pub(super) soft: SoftShadowConfig,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct SoftShadowConfig {
#[serde(default = "default_shadow_offset_y")]
pub(super) offset_y: f64,
@@ -389,8 +457,11 @@ impl Default for SoftShadowConfig {
}
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub(super) struct ThemeGeometryConfig {
#[serde(default, rename = "control_gap")]
_control_gap: Option<f64>,
#[serde(default = "default_border_width")]
pub(super) border_width: f64,
#[serde(default = "default_focus_ring_width")]
@@ -398,18 +469,16 @@ pub(super) struct ThemeGeometryConfig {
#[serde(default = "default_window_radius")]
pub(super) window_radius: f64,
#[serde(default)]
pub(super) top_bar_radius: Option<f64>,
#[serde(default)]
pub(super) shell_radius: Option<f64>,
}
impl Default for ThemeGeometryConfig {
fn default() -> Self {
Self {
_control_gap: None,
border_width: default_border_width(),
focus_ring_width: default_focus_ring_width(),
window_radius: default_window_radius(),
top_bar_radius: None,
shell_radius: None,
}
}
+301 -47
View File
@@ -1,16 +1,37 @@
use super::*;
use std::sync::Arc;
use super::{
BindingIdentity, Config, ConfigError, Deserialize, Direction, HashMap, HashSet, InputSettings,
Keycode, keysyms, xkb,
};
mod index;
mod modes;
mod pointer;
pub(crate) use index::BindingSet;
pub(super) use modes::BindingModeConfig;
pub(crate) use modes::BindingModes;
pub(crate) use pointer::{PointerTrigger, ScrollDirection};
impl Config {
pub fn bindings(&self, input: &InputSettings) -> Result<Vec<Binding>, ConfigError> {
fn binding_commands(&self) -> HashMap<String, Vec<String>> {
let mut commands = HashMap::from([
("terminal".to_owned(), vec!["foot".to_owned()]),
("screenshot".to_owned(), vec!["ferese-screenshot".to_owned()]),
(
"screenshot".to_owned(),
vec!["ferese-screenshot".to_owned()],
),
(
"screenshot-full".to_owned(),
vec!["ferese-screenshot".to_owned(), "--full".to_owned()],
),
]);
commands.extend(self.commands.clone());
commands
}
pub fn bindings(&self, input: &InputSettings) -> Result<Vec<Binding>, ConfigError> {
let commands = self.binding_commands();
validate_commands(&commands)?;
let keymap = physical_keymap(input)?;
@@ -23,7 +44,10 @@ impl Config {
for configured in &self.bindings {
let identity = binding_identity(configured, &keymap)?;
if !supplied.insert(identity.clone()) {
return Err(ConfigError::Binding(format!("duplicate binding {:?}", configured.keys)));
return Err(ConfigError::Binding(format!(
"duplicate binding {:?}",
configured.keys
)));
}
bindings.retain(|binding| binding_identity_for_runtime(binding) != identity);
@@ -43,13 +67,19 @@ impl Config {
}
}
pub(super) fn validate_commands(commands: &HashMap<String, Vec<String>>) -> Result<(), ConfigError> {
pub(super) fn validate_commands(
commands: &HashMap<String, Vec<String>>,
) -> Result<(), ConfigError> {
for (name, argv) in commands {
if name.trim().is_empty() {
return Err(ConfigError::Binding("command names cannot be empty".to_owned()));
return Err(ConfigError::Binding(
"command names cannot be empty".to_owned(),
));
}
if argv.is_empty() || argv[0].is_empty() {
return Err(ConfigError::Binding(format!("command {name:?} must contain a program")));
return Err(ConfigError::Binding(format!(
"command {name:?} must contain a program"
)));
}
}
@@ -85,14 +115,15 @@ pub(super) fn parse_binding(
}
let identity = binding_identity(configured, keymap)?;
let action = configured
.action
.as_deref()
.ok_or_else(|| ConfigError::Binding(format!("binding {:?} has no action", configured.keys)))?;
let action = configured.action.as_deref().ok_or_else(|| {
ConfigError::Binding(format!("binding {:?} has no action", configured.keys))
})?;
let action = parse_action(action, configured.argument.as_deref(), commands)?;
let trigger = match identity.match_mode {
BindingMatch::Keysym => BindingTrigger::Keysym(identity.key),
BindingMatch::Physical => BindingTrigger::Physical(Keycode::new(identity.key)),
BindingMatch::Pointer => BindingTrigger::Pointer(PointerTrigger::from_key(identity.key)),
BindingMatch::SuperTap => BindingTrigger::SuperTap,
BindingMatch::Swipe => BindingTrigger::Swipe {
fingers: identity.key / 4,
direction: [
@@ -104,6 +135,15 @@ pub(super) fn parse_binding(
},
};
if matches!(
action,
BindingAction::PointerMove | BindingAction::PointerResize
) && !matches!(trigger, BindingTrigger::Pointer(PointerTrigger::Button(_)))
{
return Err(ConfigError::Binding(
"pointer-move and pointer-resize require a mouse button".into(),
));
}
Ok(Binding {
modifiers: identity.modifiers,
trigger,
@@ -115,9 +155,38 @@ pub(super) fn binding_identity(
configured: &BindingConfig,
keymap: &xkb::Keymap,
) -> Result<BindingIdentity, ConfigError> {
if matches!(
configured.keys.trim().to_ascii_lowercase().as_str(),
"super" | "logo" | "mod4"
) {
if configured.match_mode != BindingMatch::Keysym {
return Err(ConfigError::Binding(
"Super tap cannot use physical key matching".into(),
));
}
return Ok(BindingIdentity {
modifiers: BindingModifiers::default(),
match_mode: BindingMatch::SuperTap,
key: 0,
});
}
let (modifiers, key) = parse_chord(&configured.keys)?;
if let Some(trigger) = PointerTrigger::parse(&key)? {
if configured.match_mode != BindingMatch::Keysym {
return Err(ConfigError::Binding(
"mouse bindings cannot use physical key matching".into(),
));
}
return Ok(BindingIdentity {
modifiers,
match_mode: BindingMatch::Pointer,
key: trigger.key(),
});
}
if let Some((fingers, direction)) = parse_swipe(&key) {
if configured.match_mode == BindingMatch::Physical || modifiers != BindingModifiers::default() {
if configured.match_mode == BindingMatch::Physical
|| modifiers != BindingModifiers::default()
{
return Err(ConfigError::Binding(
"swipes cannot use keyboard modifiers or physical key matching".into(),
));
@@ -139,7 +208,7 @@ pub(super) fn binding_identity(
configured.keys
))
})?,
BindingMatch::Swipe => {
BindingMatch::Swipe | BindingMatch::Pointer | BindingMatch::SuperTap => {
return Err(ConfigError::Binding(
"gesture keys must use Swipe3Left/Right/Up/Down (3–5 fingers)".into(),
));
@@ -157,7 +226,11 @@ pub(super) fn binding_identity_for_runtime(binding: &Binding) -> BindingIdentity
let (match_mode, key) = match binding.trigger {
BindingTrigger::Keysym(key) => (BindingMatch::Keysym, key),
BindingTrigger::Physical(key) => (BindingMatch::Physical, key.raw()),
BindingTrigger::Swipe { fingers, direction } => (BindingMatch::Swipe, fingers * 4 + direction as u32),
BindingTrigger::Pointer(trigger) => (BindingMatch::Pointer, trigger.key()),
BindingTrigger::SuperTap => (BindingMatch::SuperTap, 0),
BindingTrigger::Swipe { fingers, direction } => {
(BindingMatch::Swipe, fingers * 4 + direction as u32)
}
};
BindingIdentity {
@@ -185,7 +258,9 @@ pub(super) fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), Con
};
if let Some(slot) = slot {
if *slot {
return Err(ConfigError::Binding(format!("duplicate modifier in {chord:?}")));
return Err(ConfigError::Binding(format!(
"duplicate modifier in {chord:?}"
)));
}
*slot = true;
} else if key.replace(component.to_owned()).is_some() {
@@ -195,7 +270,9 @@ pub(super) fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), Con
}
}
let key = key.ok_or_else(|| ConfigError::Binding(format!("key chord {chord:?} does not contain a key")))?;
let key = key.ok_or_else(|| {
ConfigError::Binding(format!("key chord {chord:?} does not contain a key"))
})?;
Ok((modifiers, key))
}
@@ -251,17 +328,30 @@ pub(super) fn parse_action(
Ok(())
}
};
let required_argument =
|| argument.ok_or_else(|| ConfigError::Binding(format!("action {action:?} requires an argument")));
let required_argument = || {
argument
.ok_or_else(|| ConfigError::Binding(format!("action {action:?} requires an argument")))
};
match action {
"pointer-move" | "pointer-resize" => {
no_argument()?;
Ok(if action == "pointer-move" {
BindingAction::PointerMove
} else {
BindingAction::PointerResize
})
}
"binding-mode" => Ok(BindingAction::BindingMode(required_argument()?.to_owned())),
"none" => {
no_argument()?;
Ok(BindingAction::None)
}
"workspace-next" | "workspace-previous" => {
no_argument()?;
Ok(BindingAction::SwitchRelativeWorkspace(action == "workspace-next"))
Ok(BindingAction::SwitchRelativeWorkspace(
action == "workspace-next",
))
}
"workspace-back-and-forth" => {
no_argument()?;
@@ -271,6 +361,10 @@ pub(super) fn parse_action(
no_argument()?;
Ok(BindingAction::FocusLastWindow)
}
"focus-floating" => {
no_argument()?;
Ok(BindingAction::FocusFloating)
}
"focus-mru-next" | "focus-mru-previous" => {
no_argument()?;
Ok(BindingAction::FocusMru(action == "focus-mru-previous"))
@@ -280,7 +374,7 @@ pub(super) fn parse_action(
let argv = commands
.get(command)
.ok_or_else(|| ConfigError::Binding(format!("unknown command {command:?}")))?;
Ok(BindingAction::Spawn(argv.clone()))
Ok(BindingAction::Spawn(argv.clone().into()))
}
"close" => {
no_argument()?;
@@ -292,9 +386,15 @@ pub(super) fn parse_action(
}
"focus" => Ok(BindingAction::Focus(parse_direction(required_argument()?)?)),
"move" => Ok(BindingAction::Move(parse_direction(required_argument()?)?)),
"resize" => Ok(BindingAction::Resize(parse_direction(required_argument()?)?)),
"workspace" => Ok(BindingAction::SwitchWorkspace(parse_workspace(required_argument()?)?)),
"move-to-workspace" => Ok(BindingAction::MoveToWorkspace(parse_workspace(required_argument()?)?)),
"resize" => Ok(BindingAction::Resize(
parse_direction(required_argument()?)?,
)),
"workspace" => Ok(BindingAction::SwitchWorkspace(parse_workspace(
required_argument()?,
)?)),
"move-to-workspace" => Ok(BindingAction::MoveToWorkspace(parse_workspace(
required_argument()?,
)?)),
"toggle-maximized" => {
no_argument()?;
Ok(BindingAction::ToggleMaximized)
@@ -315,6 +415,25 @@ pub(super) fn parse_action(
no_argument()?;
Ok(BindingAction::CenterColumn)
}
"toggle-column-tabbed" => {
no_argument()?;
Ok(BindingAction::ToggleColumnTabbed)
}
"join-tab" => Ok(BindingAction::JoinTab(parse_direction(
required_argument()?
)?)),
"leave-tab" => {
no_argument()?;
Ok(BindingAction::LeaveTab)
}
"next-tab" => {
no_argument()?;
Ok(BindingAction::CycleTab(false))
}
"previous-tab" => {
no_argument()?;
Ok(BindingAction::CycleTab(true))
}
"consume" => {
no_argument()?;
Ok(BindingAction::Consume)
@@ -331,6 +450,22 @@ pub(super) fn parse_action(
no_argument()?;
Ok(BindingAction::ToggleOverview)
}
"media-play-pause" | "media-next" | "media-previous" => {
no_argument()?;
Ok(BindingAction::Media(match action {
"media-next" => "next",
"media-previous" => "previous",
_ => "play-pause",
}))
}
"toggle-display-mode" => {
no_argument()?;
Ok(BindingAction::ToggleDisplayMode)
}
"toggle-launcher" => {
no_argument()?;
Ok(BindingAction::ToggleLauncher)
}
"toggle-keybinding-guide" => {
no_argument()?;
Ok(BindingAction::ToggleKeybindingGuide)
@@ -345,19 +480,26 @@ pub(super) fn parse_direction(argument: &str) -> Result<Direction, ConfigError>
"right" => Ok(Direction::Right),
"up" => Ok(Direction::Up),
"down" => Ok(Direction::Down),
_ => Err(ConfigError::Binding(format!("invalid direction {argument:?}"))),
_ => Err(ConfigError::Binding(format!(
"invalid direction {argument:?}"
))),
}
}
pub(super) fn parse_workspace(argument: &str) -> Result<u8, ConfigError> {
match argument.parse() {
Ok(workspace) if workspace > 0 => Ok(workspace),
_ => Err(ConfigError::Binding(format!("invalid workspace {argument:?}"))),
_ => Err(ConfigError::Binding(format!(
"invalid workspace {argument:?}"
))),
}
}
pub(super) fn default_bindings() -> Vec<BindingConfig> {
let mut bindings = vec![
binding("Super+MouseLeft", "pointer-move", None),
binding("Super+MouseRight", "pointer-resize", None),
binding("Super+Space", "toggle-launcher", None),
binding("Super+Enter", "spawn", Some("terminal")),
binding("Super+Shift+S", "spawn", Some("screenshot")),
binding("Print", "spawn", Some("screenshot-full")),
@@ -373,8 +515,17 @@ pub(super) fn default_bindings() -> Vec<BindingConfig> {
binding("Super+C", "center-column", None),
binding("Super+[", "consume", None),
binding("Super+]", "expel", None),
binding("Super+Shift+T", "toggle-column-tabbed", None),
binding("Super+Ctrl+[", "previous-tab", None),
binding("Super+Ctrl+]", "next-tab", None),
binding("Super+Shift+Space", "toggle-floating", None),
binding("Super+V", "focus-floating", None),
binding("Super+Tab", "toggle-overview", None),
binding("XF86Display", "toggle-display-mode", None),
binding("XF86AudioPlay", "media-play-pause", None),
binding("XF86AudioPause", "media-play-pause", None),
binding("XF86AudioNext", "media-next", None),
binding("XF86AudioPrev", "media-previous", None),
binding("Super+F1", "toggle-keybinding-guide", None),
binding("Super+Escape", "workspace-back-and-forth", None),
binding("Super+BackSpace", "focus-last-window", None),
@@ -385,13 +536,25 @@ pub(super) fn default_bindings() -> Vec<BindingConfig> {
for (key, direction) in [("H", "left"), ("J", "down"), ("K", "up"), ("L", "right")] {
bindings.push(binding("Super+".to_owned() + key, "focus", Some(direction)));
bindings.push(binding("Super+Shift+".to_owned() + key, "move", Some(direction)));
bindings.push(binding("Super+Ctrl+".to_owned() + key, "resize", Some(direction)));
bindings.push(binding(
"Super+Shift+".to_owned() + key,
"move",
Some(direction),
));
bindings.push(binding(
"Super+Ctrl+".to_owned() + key,
"resize",
Some(direction),
));
}
for workspace in 1..=9 {
let workspace = workspace.to_string();
bindings.push(binding(format!("Super+{workspace}"), "workspace", Some(&workspace)));
bindings.push(binding(
format!("Super+{workspace}"),
"workspace",
Some(&workspace),
));
bindings.push(binding(
format!("Super+Shift+{workspace}"),
"move-to-workspace",
@@ -402,7 +565,11 @@ pub(super) fn default_bindings() -> Vec<BindingConfig> {
bindings
}
pub(super) fn binding(keys: impl Into<String>, action: impl Into<String>, argument: Option<&str>) -> BindingConfig {
pub(super) fn binding(
keys: impl Into<String>,
action: impl Into<String>,
argument: Option<&str>,
) -> BindingConfig {
BindingConfig {
keys: keys.into(),
match_mode: BindingMatch::Keysym,
@@ -423,6 +590,8 @@ pub struct Binding {
pub(super) enum BindingTrigger {
Keysym(u32),
Physical(Keycode),
Pointer(PointerTrigger),
SuperTap,
Swipe {
fingers: u32,
direction: crate::gestures::SwipeDirection,
@@ -440,12 +609,16 @@ pub(super) struct BindingModifiers {
#[derive(Clone, Debug, PartialEq)]
pub enum BindingAction {
None,
Spawn(Vec<String>),
BindingMode(String),
Spawn(Arc<[String]>),
Close,
Exit,
Focus(ferese_layout::Direction),
FocusLastWindow,
FocusFloating,
FocusMru(bool),
PointerMove,
PointerResize,
Move(ferese_layout::Direction),
Resize(ferese_layout::Direction),
SwitchWorkspace(u8),
@@ -457,11 +630,18 @@ pub enum BindingAction {
ToggleLayout,
CycleColumnWidth,
CenterColumn,
ToggleColumnTabbed,
JoinTab(ferese_layout::Direction),
LeaveTab,
CycleTab(bool),
Consume,
Expel,
ToggleFloating,
ToggleOverview,
ToggleKeybindingGuide,
ToggleLauncher,
ToggleDisplayMode,
Media(&'static str),
}
impl Binding {
@@ -487,7 +667,10 @@ impl Binding {
let symbol = parse_keysym(&key)?;
if (modifiers.ctrl
&& modifiers.alt
&& matches!(symbol, keysyms::KEY_Escape | keysyms::KEY_F1..=keysyms::KEY_F12))
&& matches!(
symbol,
keysyms::KEY_Escape | keysyms::KEY_F1..=keysyms::KEY_F12
))
|| (keysyms::KEY_XF86Switch_VT_1..=keysyms::KEY_XF86Switch_VT_12).contains(&symbol)
{
return Err(ConfigError::Binding("Reserved system shortcut".into()));
@@ -506,30 +689,53 @@ impl Binding {
match (&self.trigger, &other.trigger) {
(BindingTrigger::Keysym(left), BindingTrigger::Keysym(right)) => left == right,
(BindingTrigger::Keysym(symbol), BindingTrigger::Physical(code))
| (BindingTrigger::Physical(code), BindingTrigger::Keysym(symbol)) => (0..map.num_layouts_for_key(*code))
.any(|layout| {
| (BindingTrigger::Physical(code), BindingTrigger::Keysym(symbol)) => {
(0..map.num_layouts_for_key(*code)).any(|layout| {
map.key_get_syms_by_level(*code, layout, 0)
.iter()
.any(|key| key.raw() == *symbol)
}),
})
}
(BindingTrigger::Physical(left), BindingTrigger::Physical(right)) => left == right,
(BindingTrigger::SuperTap, BindingTrigger::SuperTap) => true,
_ => false,
}
}
pub fn matches(&self, keycode: Keycode, keysyms: &[u32], logo: bool, ctrl: bool, alt: bool, shift: bool) -> bool {
self.modifiers == BindingModifiers { logo, ctrl, alt, shift }
pub fn matches(
&self,
keycode: Keycode,
keysyms: &[u32],
logo: bool,
ctrl: bool,
alt: bool,
shift: bool,
) -> bool {
self.modifiers
== BindingModifiers {
logo,
ctrl,
alt,
shift,
}
&& match self.trigger {
BindingTrigger::Keysym(expected) => keysyms.contains(&expected),
BindingTrigger::Physical(expected) => expected == keycode,
BindingTrigger::Swipe { .. } => false,
BindingTrigger::Swipe { .. }
| BindingTrigger::Pointer(_)
| BindingTrigger::SuperTap => false,
}
}
pub(crate) fn guide_entry(&self, keymap: Option<&xkb::Keymap>) -> Option<serde_json::Value> {
let description = match &self.action {
BindingAction::BindingMode(name) => format!("Switch to binding mode {name}"),
BindingAction::None => return None,
BindingAction::Spawn(argv) if argv.first().is_some_and(|program| program == "ferese-screenshot") => {
BindingAction::Spawn(argv)
if argv
.first()
.is_some_and(|program| program == "ferese-screenshot") =>
{
if argv.iter().any(|arg| arg == "--full") {
"Capture whole screen"
} else {
@@ -548,29 +754,56 @@ impl Binding {
BindingAction::Exit => "Log out (with confirmation)".into(),
BindingAction::Focus(direction) => format!("Focus window {direction:?}"),
BindingAction::FocusLastWindow => "Return to last focused window".into(),
BindingAction::FocusFloating => {
"Switch focus between tiled and floating windows".into()
}
BindingAction::FocusMru(reverse) => if *reverse {
"Previous window in focus history"
} else {
"Next window in focus history"
}
.into(),
BindingAction::PointerMove => "Drag window".into(),
BindingAction::PointerResize => "Resize window with pointer".into(),
BindingAction::Move(direction) => format!("Move window {direction:?}"),
BindingAction::Resize(direction) => format!("Resize window {direction:?}"),
BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"),
BindingAction::WorkspaceBackAndForth => "Return to last workspace on this monitor".into(),
BindingAction::MoveToWorkspace(index) => format!("Move window to workspace {index}"),
BindingAction::SwitchRelativeWorkspace(next) => {
if *next { "Next workspace" } else { "Previous workspace" }.into()
BindingAction::WorkspaceBackAndForth => {
"Return to last workspace on this monitor".into()
}
BindingAction::MoveToWorkspace(index) => format!("Move window to workspace {index}"),
BindingAction::SwitchRelativeWorkspace(next) => if *next {
"Next workspace"
} else {
"Previous workspace"
}
.into(),
BindingAction::ToggleFullscreen => "Toggle fullscreen".into(),
BindingAction::ToggleMaximized => "Toggle full-width window".into(),
BindingAction::ToggleLayout => "Switch workspace layout".into(),
BindingAction::CycleColumnWidth => "Cycle column width".into(),
BindingAction::CenterColumn => "Center focused column".into(),
BindingAction::Consume => "Join window into column".into(),
BindingAction::Expel => "Move window out of column".into(),
BindingAction::ToggleColumnTabbed => "Toggle stacked / tabbed column".into(),
BindingAction::JoinTab(direction) => format!("Join tab group {direction:?}"),
BindingAction::LeaveTab => "Leave tab group".into(),
BindingAction::CycleTab(previous) => if *previous {
"Previous tab"
} else {
"Next tab"
}
.into(),
BindingAction::Consume => "Join window into column or tab group".into(),
BindingAction::Expel => "Leave column or tab group".into(),
BindingAction::ToggleFloating => "Toggle floating window".into(),
BindingAction::Media(action) => match *action {
"next" => "Next track",
"previous" => "Previous track",
_ => "Play or pause media",
}
.into(),
BindingAction::ToggleOverview => "Open or close overview".into(),
BindingAction::ToggleDisplayMode => "Open display mode chooser".into(),
BindingAction::ToggleLauncher => "Open or close launcher".into(),
BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(),
};
let mut parts = Vec::new();
@@ -585,6 +818,8 @@ impl Binding {
}
}
parts.push(match self.trigger {
BindingTrigger::SuperTap => "Super (tap)".into(),
BindingTrigger::Pointer(trigger) => trigger.label(),
BindingTrigger::Keysym(symbol) => {
let name = xkb::keysym_get_name(xkb::Keysym::new(symbol));
match name.as_str() {
@@ -610,21 +845,40 @@ impl Binding {
matches!(self.trigger, BindingTrigger::Swipe { fingers, .. } if fingers == count)
}
pub(crate) fn matches_swipe(&self, count: u32, target: crate::gestures::SwipeDirection) -> bool {
pub(crate) fn matches_swipe(
&self,
count: u32,
target: crate::gestures::SwipeDirection,
) -> bool {
matches!(self.trigger, BindingTrigger::Swipe { fingers, direction } if fingers == count && direction == target)
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq)]
#[derive(
Clone,
Copy,
Debug,
Default,
Deserialize,
Eq,
Hash,
PartialEq,
schemars::JsonSchema,
serde::Serialize,
)]
#[serde(rename_all = "lowercase")]
pub(super) enum BindingMatch {
#[default]
Keysym,
Physical,
Swipe,
#[serde(skip)]
Pointer,
#[serde(skip)]
SuperTap,
}
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct BindingConfig {
pub(super) keys: String,
#[serde(default, rename = "match")]
+263
View File
@@ -0,0 +1,263 @@
#[cfg(test)]
use crate::config::BindingAction;
#[cfg(test)]
use ferese_layout::Direction;
#[cfg(test)]
use smithay::input::keyboard::keysyms;
use super::{
Binding, BindingIdentity, BindingMatch, BindingModifiers, HashMap, Keycode,
binding_identity_for_runtime,
};
/// Immutable bindings and their lookup index are replaced together on reload.
/// The ordered slice remains available for gestures and the shortcut guide.
#[derive(Clone)]
pub(crate) struct BindingSet {
ordered: Vec<Binding>,
index: HashMap<BindingIdentity, usize>,
}
impl From<Vec<Binding>> for BindingSet {
fn from(ordered: Vec<Binding>) -> Self {
let mut index = HashMap::with_capacity(ordered.len());
for (position, binding) in ordered.iter().enumerate() {
let identity = binding_identity_for_runtime(binding);
if matches!(
identity.match_mode,
BindingMatch::Keysym
| BindingMatch::Physical
| BindingMatch::Pointer
| BindingMatch::SuperTap
) {
index.entry(identity).or_insert(position);
}
}
Self { ordered, index }
}
}
impl std::ops::Deref for BindingSet {
type Target = [Binding];
fn deref(&self) -> &Self::Target {
&self.ordered
}
}
impl BindingSet {
pub(crate) fn super_tap(&self) -> Option<&Binding> {
self.index
.get(&BindingIdentity {
modifiers: BindingModifiers::default(),
match_mode: BindingMatch::SuperTap,
key: 0,
})
.map(|index| &self.ordered[*index])
}
pub(crate) fn binding_for_pointer(
&self,
trigger: super::PointerTrigger,
modifiers: smithay::input::keyboard::ModifiersState,
) -> Option<&Binding> {
let modifiers = BindingModifiers {
logo: modifiers.logo,
ctrl: modifiers.ctrl,
alt: modifiers.alt,
shift: modifiers.shift,
};
self.index
.get(&BindingIdentity {
modifiers,
match_mode: BindingMatch::Pointer,
key: trigger.key(),
})
.map(|index| &self.ordered[*index])
}
pub(crate) fn binding_for_key(
&self,
keycode: Keycode,
symbols: &[u32],
logo: bool,
ctrl: bool,
alt: bool,
shift: bool,
) -> Option<&Binding> {
let modifiers = BindingModifiers {
logo,
ctrl,
alt,
shift,
};
let physical = self.index.get(&BindingIdentity {
modifiers,
match_mode: BindingMatch::Physical,
key: keycode.raw(),
});
let symbolic = symbols.iter().filter_map(|&key| {
self.index.get(&BindingIdentity {
modifiers,
match_mode: BindingMatch::Keysym,
key,
})
});
// Preserve list precedence across physical keys and all raw symbols;
// neither hash iteration order nor symbol order determines the winner.
physical
.into_iter()
.chain(symbolic)
.min()
.map(|&index| &self.ordered[index])
}
#[cfg(test)]
pub(crate) fn action_for_key(
&self,
keycode: Keycode,
symbols: &[u32],
logo: bool,
ctrl: bool,
alt: bool,
shift: bool,
) -> Option<&BindingAction> {
self.binding_for_key(keycode, symbols, logo, ctrl, alt, shift)
.map(|binding| &binding.action)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::config::bindings::BindingTrigger;
use std::sync::Arc;
fn binding_for(trigger: BindingTrigger, action: BindingAction) -> Binding {
Binding {
modifiers: BindingModifiers::default(),
trigger,
action,
}
}
#[test]
fn keyboard_index_preserves_order_across_symbols_and_physical_keys() {
let code = Keycode::new(38);
let bindings = vec![
binding_for(BindingTrigger::Keysym(keysyms::KEY_b), BindingAction::Close),
binding_for(
BindingTrigger::Physical(code),
BindingAction::ToggleFloating,
),
binding_for(BindingTrigger::Keysym(keysyms::KEY_a), BindingAction::Exit),
binding_for(BindingTrigger::Keysym(keysyms::KEY_b), BindingAction::None),
];
for ordered in [bindings.clone(), bindings.into_iter().rev().collect()] {
let index = BindingSet::from(ordered);
for symbols in [
vec![keysyms::KEY_a, keysyms::KEY_b],
vec![keysyms::KEY_b, keysyms::KEY_a],
vec![],
] {
let expected = index
.iter()
.find(|binding| binding.matches(code, &symbols, false, false, false, false));
assert_eq!(
index.action_for_key(code, &symbols, false, false, false, false),
expected.map(|binding| &binding.action)
);
}
}
}
#[test]
fn indexed_lookup_matches_linear_lookup_for_all_modifier_combinations() {
let config = Config::default();
let mut bindings = config.bindings(&config.input_settings().unwrap()).unwrap();
bindings.extend([
binding_for(
BindingTrigger::Physical(Keycode::new(38)),
BindingAction::None,
),
binding_for(BindingTrigger::Keysym(keysyms::KEY_a), BindingAction::Close),
binding_for(
BindingTrigger::Swipe {
fingers: 3,
direction: crate::gestures::SwipeDirection::Left,
},
BindingAction::Focus(Direction::Left),
),
]);
let index = BindingSet::from(bindings);
for bits in 0..16 {
let (logo, ctrl, alt, shift) =
(bits & 1 != 0, bits & 2 != 0, bits & 4 != 0, bits & 8 != 0);
for code in 8..100 {
for symbols in [
vec![],
vec![keysyms::KEY_Return],
vec![keysyms::KEY_b, keysyms::KEY_a],
vec![keysyms::KEY_a, keysyms::KEY_Tab],
vec![keysyms::KEY_NoSymbol],
] {
let code = Keycode::new(code);
let expected = index
.iter()
.find(|binding| binding.matches(code, &symbols, logo, ctrl, alt, shift));
assert_eq!(
index.action_for_key(code, &symbols, logo, ctrl, alt, shift),
expected.map(|binding| &binding.action)
);
}
}
}
}
#[test]
fn replacing_bindings_discards_old_index_entries() {
let code = Keycode::new(38);
let mut bindings = BindingSet::from(vec![binding_for(
BindingTrigger::Physical(code),
BindingAction::Close,
)]);
assert_eq!(
bindings.action_for_key(code, &[], false, false, false, false),
Some(&BindingAction::Close)
);
bindings = BindingSet::from(vec![binding_for(
BindingTrigger::Keysym(keysyms::KEY_b),
BindingAction::None,
)]);
assert_eq!(
bindings.action_for_key(code, &[], false, false, false, false),
None
);
assert_eq!(
bindings.action_for_key(code, &[keysyms::KEY_b], false, false, false, false),
Some(&BindingAction::None)
);
}
#[test]
fn cloning_spawn_actions_shares_the_command_storage() {
let action =
BindingAction::Spawn(vec!["foot".into(), "--app-id".into(), "work".into()].into());
let cloned = action.clone();
let (BindingAction::Spawn(original), BindingAction::Spawn(cloned)) = (&action, cloned)
else {
unreachable!();
};
assert!(Arc::ptr_eq(original, &cloned));
assert_eq!(original[0], "foot");
assert_eq!(original.len(), 3);
}
}
+231
View File
@@ -0,0 +1,231 @@
use super::{
Binding, BindingAction, BindingConfig, BindingMatch, BindingSet, Config, ConfigError,
Deserialize, HashMap, HashSet, InputSettings, Keycode, binding_identity, keysyms,
parse_binding, physical_keymap,
};
#[cfg(test)]
use ferese_layout::Direction;
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct BindingModeConfig {
name: String,
#[serde(default)]
bindings: Vec<BindingConfig>,
}
/// Named keyboard maps share the ordinary binding index. The default map and
/// gestures remain owned by the compositor; a mode never mutates either.
#[derive(Clone, Default)]
pub(crate) struct BindingModes {
maps: HashMap<String, BindingSet>,
active: Option<String>,
}
impl BindingModes {
pub(crate) fn active(&self) -> Option<&str> {
self.active.as_deref()
}
pub(crate) fn set(&mut self, name: &str) -> bool {
let next = if name == "default" {
None
} else if self.maps.contains_key(name) {
Some(name.to_owned())
} else {
return false;
};
if next == self.active {
return false;
}
self.active = next;
true
}
pub(crate) fn bindings<'a>(&'a self, default: &'a BindingSet) -> &'a BindingSet {
self.active
.as_ref()
.and_then(|name| self.maps.get(name))
.unwrap_or(default)
}
}
impl Config {
pub(crate) fn binding_modes(
&self,
input: &InputSettings,
default: &[Binding],
) -> Result<BindingModes, ConfigError> {
let keymap = physical_keymap(input)?;
let commands = self.binding_commands();
let mut modes = BindingModes::default();
for mode in &self.binding_modes {
if mode.name.is_empty()
|| mode.name.len() > 48
|| mode.name == "default"
|| !mode
.name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|| modes.maps.contains_key(&mode.name)
{
return Err(ConfigError::Binding(format!(
"invalid or duplicate binding mode {:?}",
mode.name
)));
}
let mut identities = HashSet::new();
let mut bindings = Vec::new();
for configured in &mode.bindings {
let identity = binding_identity(configured, &keymap)?;
if configured.disabled
|| identity.match_mode == BindingMatch::Swipe
|| !identities.insert(identity.clone())
|| (identity.match_mode == BindingMatch::Keysym
&& identity.key == keysyms::KEY_Escape)
|| (identity.match_mode == BindingMatch::Physical
&& (0..keymap.num_layouts_for_key(Keycode::new(identity.key))).any(
|layout| {
(0..keymap.num_levels_for_key(Keycode::new(identity.key), layout))
.any(|level| {
keymap
.key_get_syms_by_level(
Keycode::new(identity.key),
layout,
level,
)
.iter()
.any(|symbol| symbol.raw() == keysyms::KEY_Escape)
})
},
))
{
return Err(ConfigError::Binding(format!(
"invalid, duplicate or reserved mode binding {:?}",
configured.keys
)));
}
bindings.push(parse_binding(configured, &commands, &keymap)?);
}
modes.maps.insert(mode.name.clone(), bindings.into());
}
for binding in default
.iter()
.chain(modes.maps.values().flat_map(|map| map.iter()))
{
if let BindingAction::BindingMode(name) = &binding.action
&& name != "default"
&& !modes.maps.contains_key(name)
{
return Err(ConfigError::Binding(format!(
"unknown binding mode {name:?}"
)));
}
}
Ok(modes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modes_use_the_binding_index_without_inheriting_default_shortcuts() {
let config = Config::parse_source(
r#"
binding "Super+r" binding-mode resize
binding-mode resize {
binding h resize left
binding t binding-mode launch
binding AD02 resize up match="physical"
}
binding-mode launch { binding Return spawn terminal; }
"#,
)
.unwrap();
let runtime = config.runtime_config().unwrap();
let default = BindingSet::from(runtime.bindings);
let mut modes = runtime.binding_modes;
assert!(modes.set("resize"));
let selected = modes.bindings(&default);
assert_eq!(
selected.action_for_key(
Keycode::new(43),
&[keysyms::KEY_h],
false,
false,
false,
false
),
Some(&BindingAction::Resize(Direction::Left))
);
assert_eq!(
selected.action_for_key(Keycode::new(25), &[], false, false, false, false),
Some(&BindingAction::Resize(Direction::Up))
);
assert_eq!(
selected.action_for_key(
Keycode::new(43),
&[keysyms::KEY_h],
true,
false,
false,
false
),
None
);
assert_eq!(
selected.action_for_key(
Keycode::new(36),
&[keysyms::KEY_Return],
true,
false,
false,
false
),
None
);
assert!(!modes.set("missing"));
assert_eq!(modes.active(), Some("resize"));
assert!(modes.set("default"));
assert!(matches!(
modes.bindings(&default).action_for_key(
Keycode::new(36),
&[keysyms::KEY_Return],
true,
false,
false,
false
),
Some(BindingAction::Spawn(_))
));
}
#[test]
fn invalid_modes_and_targets_are_rejected_before_runtime_publication() {
for source in [
"binding-mode default {}",
"binding-mode \"\" {}",
"binding-mode \"two words\" {}",
"binding-mode resize {}\nbinding-mode resize {}",
"binding x binding-mode missing",
"binding-mode resize { binding x binding-mode missing; }",
"binding-mode resize { binding x close; binding x none; }",
"binding-mode resize { binding Escape none; }",
"binding-mode resize { binding Super+Escape none; }",
"binding-mode resize { binding ESC none match=\"physical\"; }",
"input { xkb-options \"caps:escape\"; }\nbinding-mode resize { binding CAPS none match=\"physical\"; }",
"binding-mode resize { binding Swipe3Left none; }",
"binding-mode resize { binding x disabled=#true; }",
"binding-mode resize fallback=#true {}",
] {
assert!(
Config::parse_source(source)
.and_then(|config| config.runtime_config())
.is_err(),
"{source}"
);
}
}
}
+161
View File
@@ -0,0 +1,161 @@
use super::ConfigError;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u32)]
pub(crate) enum ScrollDirection {
Left,
Right,
Up,
Down,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PointerTrigger {
Button(u32),
Wheel(ScrollDirection),
Touchpad(ScrollDirection),
}
impl PointerTrigger {
pub(super) fn parse(name: &str) -> Result<Option<Self>, ConfigError> {
let name = name.to_ascii_lowercase();
let button = match name.as_str() {
"mouseleft" => Some(272),
"mouseright" => Some(273),
"mousemiddle" => Some(274),
"mouseside" | "mouseback" => Some(275),
"mouseextra" | "mouseforward" => Some(276),
_ => None,
};
if let Some(button) = button {
return Ok(Some(Self::Button(button)));
}
if let Some(code) = name.strip_prefix("mouse:") {
let code = code
.parse::<u32>()
.ok()
.filter(|code| (0x110..=0x11f).contains(code))
.ok_or_else(|| {
ConfigError::Binding(
"mouse button must be a Linux mouse button code (272–287)".into(),
)
})?;
return Ok(Some(Self::Button(code)));
}
for (prefix, touchpad) in [
("wheelscroll", false),
("wheel", false),
("touchpadscroll", true),
] {
if let Some(direction) = name.strip_prefix(prefix) {
let direction = match direction {
"left" => ScrollDirection::Left,
"right" => ScrollDirection::Right,
"up" => ScrollDirection::Up,
"down" => ScrollDirection::Down,
_ => {
return Err(ConfigError::Binding(format!(
"invalid scroll direction {direction:?}"
)));
}
};
return Ok(Some(if touchpad {
Self::Touchpad(direction)
} else {
Self::Wheel(direction)
}));
}
}
Ok(None)
}
pub(super) fn key(self) -> u32 {
match self {
Self::Button(code) => code,
Self::Wheel(direction) => 1024 + direction as u32,
Self::Touchpad(direction) => 1028 + direction as u32,
}
}
pub(super) fn from_key(key: u32) -> Self {
let directions = [
ScrollDirection::Left,
ScrollDirection::Right,
ScrollDirection::Up,
ScrollDirection::Down,
];
match key {
1024..=1027 => Self::Wheel(directions[(key - 1024) as usize]),
1028..=1031 => Self::Touchpad(directions[(key - 1028) as usize]),
code => Self::Button(code),
}
}
pub(super) fn label(self) -> String {
match self {
Self::Button(272) => "Mouse Left".into(),
Self::Button(273) => "Mouse Right".into(),
Self::Button(274) => "Mouse Middle".into(),
Self::Button(275) => "Mouse Back".into(),
Self::Button(276) => "Mouse Forward".into(),
Self::Button(code) => format!("Mouse {code}"),
Self::Wheel(direction) => format!("Wheel {direction:?}"),
Self::Touchpad(direction) => format!("Touchpad scroll {direction:?}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{BindingAction, Config};
#[test]
fn pointer_names_round_trip_without_colliding_with_keyboard_keys() {
for name in [
"MouseLeft",
"MouseBack",
"MouseForward",
"mouse:287",
"WheelUp",
"WheelScrollLeft",
"TouchpadScrollDown",
] {
let trigger = PointerTrigger::parse(name).unwrap().unwrap();
assert_eq!(PointerTrigger::from_key(trigger.key()), trigger);
}
for name in ["mouse:12", "mouse:288", "mouse:foo", "WheelSideways"] {
assert!(PointerTrigger::parse(name).is_err(), "{name}");
}
assert_eq!(PointerTrigger::parse("Return").unwrap(), None);
}
#[test]
fn pointer_defaults_can_be_overridden_and_disabled() {
let config = Config::parse_source(
r#"
binding "Super+MouseLeft" action="close"
binding "Super+MouseRight" disabled=#true
binding "Alt+MouseMiddle" action="pointer-resize"
binding "Super+WheelDown" action="workspace-next"
"#,
)
.unwrap();
let bindings = config.bindings(&config.input_settings().unwrap()).unwrap();
assert!(
!bindings
.iter()
.any(|binding| binding.action == BindingAction::PointerMove)
);
assert_eq!(
bindings
.iter()
.filter(|binding| binding.action == BindingAction::PointerResize)
.count(),
1
);
let config =
Config::parse_source(r#"binding "Super+WheelUp" action="pointer-move""#).unwrap();
assert!(config.bindings(&config.input_settings().unwrap()).is_err());
}
}
+146 -96
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Config, ConfigError, Deserialize, SpringConfig, default_repeat_delay, default_repeat_rate,
enabled_by_default,
};
impl Config {
pub fn input_settings(&self) -> Result<InputSettings, ConfigError> {
@@ -27,7 +30,44 @@ impl Config {
value: self.input.touchpad.swipe_threshold.to_string(),
});
}
let mouse = self.input.mouse;
for (field, value, valid) in [
(
"touchpad.accel-speed",
self.input.touchpad.accel_speed,
(-1.0..=1.0).contains(&self.input.touchpad.accel_speed),
),
(
"mouse.accel-speed",
mouse.accel_speed,
(-1.0..=1.0).contains(&mouse.accel_speed),
),
(
"mouse.scroll-factor",
mouse.scroll_factor,
(0.01..=100.0).contains(&mouse.scroll_factor),
),
(
"mouse.drag-threshold",
mouse.drag_threshold,
(0.0..=100.0).contains(&mouse.drag_threshold),
),
] {
if !value.is_finite() || !valid {
return Err(ConfigError::InputValue {
field,
value: value.to_string(),
});
}
}
if mouse.scroll_cooldown_ms > 5000 {
return Err(ConfigError::InputValue {
field: "mouse.scroll-cooldown-ms",
value: mouse.scroll_cooldown_ms.to_string(),
});
}
Ok(InputSettings {
mouse,
focus_follows_mouse: self.input.focus_follows_mouse,
xkb_layout: self.input.xkb_layout.clone(),
xkb_variant: self.input.xkb_variant.clone(),
@@ -36,6 +76,8 @@ impl Config {
repeat_delay_ms: self.input.repeat_delay_ms,
touchpad: TouchpadSettings {
tap: self.input.touchpad.tap,
accel_speed: self.input.touchpad.accel_speed,
disable_while_mouse_connected: self.input.touchpad.disable_while_mouse_connected,
natural_scroll: self.input.touchpad.natural_scroll,
disable_while_typing: self.input.touchpad.disable_while_typing,
swipe_threshold: self.input.touchpad.swipe_threshold,
@@ -44,55 +86,28 @@ impl Config {
}
pub fn animations_enabled(&self) -> bool {
self.animations.enabled && !self.animations.reduced_motion
self.animations.motion_enabled()
}
pub fn animation_speed(&self) -> Result<f64, ConfigError> {
positive_animation_value(self.animations.speed, "speed")
self.animations
.validate_speed()
.map_err(|_| ConfigError::AnimationValue {
field: "speed",
value: self.animations.speed,
})
}
pub fn spring_config(&self) -> Result<SpringConfig, ConfigError> {
let mass = positive_animation_value(self.animations.spring.mass, "spring.mass")?;
let stiffness = positive_animation_value(self.animations.spring.stiffness, "spring.stiffness")?;
let damping = self.animations.spring.damping;
if !damping.is_finite() || damping < 0.0 {
return Err(ConfigError::AnimationValue {
field: "spring.damping",
value: damping,
});
}
Ok(SpringConfig {
mass,
stiffness,
damping,
..SpringConfig::default()
})
self.animations
.spring_config()
.map_err(ConfigError::Binding)
}
pub fn viewport_spring_config(&self) -> Result<SpringConfig, ConfigError> {
let mass = positive_animation_value(self.animations.viewport_spring.mass, "viewport_spring.mass")?;
let stiffness =
positive_animation_value(self.animations.viewport_spring.stiffness, "viewport_spring.stiffness")?;
let damping_ratio = positive_animation_value(
self.animations.viewport_spring.damping_ratio,
"viewport_spring.damping_ratio",
)?;
Ok(SpringConfig {
mass,
stiffness,
damping: 2.0 * damping_ratio * (stiffness * mass).sqrt(),
..SpringConfig::default()
})
}
}
pub(super) fn positive_animation_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
if value.is_finite() && value > 0.0 {
Ok(value)
} else {
Err(ConfigError::AnimationValue { field, value })
self.animations
.viewport_config()
.map_err(ConfigError::Binding)
}
}
@@ -109,11 +124,14 @@ pub struct InputSettings {
pub repeat_rate: i32,
pub repeat_delay_ms: i32,
pub touchpad: TouchpadSettings,
pub mouse: MouseSettings,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TouchpadSettings {
pub tap: bool,
pub accel_speed: f64,
pub disable_while_mouse_connected: bool,
pub natural_scroll: bool,
pub disable_while_typing: bool,
pub swipe_threshold: u16,
@@ -123,7 +141,7 @@ fn default_swipe_threshold() -> u16 {
80
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct InputConfig {
#[serde(default)]
pub(super) focus_follows_mouse: bool,
@@ -139,6 +157,8 @@ pub(super) struct InputConfig {
pub(super) repeat_delay_ms: i32,
#[serde(default)]
pub(super) touchpad: TouchpadConfig,
#[serde(default)]
pub(super) mouse: MouseSettings,
}
impl Default for InputConfig {
@@ -151,14 +171,19 @@ impl Default for InputConfig {
repeat_rate: default_repeat_rate(),
repeat_delay_ms: default_repeat_delay(),
touchpad: TouchpadConfig::default(),
mouse: MouseSettings::default(),
}
}
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct TouchpadConfig {
#[serde(default)]
pub(super) accel_speed: f64,
#[serde(default = "enabled_by_default")]
pub(super) tap: bool,
#[serde(default)]
pub(super) disable_while_mouse_connected: bool,
#[serde(default = "enabled_by_default")]
pub(super) natural_scroll: bool,
#[serde(default = "enabled_by_default")]
@@ -171,6 +196,8 @@ impl Default for TouchpadConfig {
fn default() -> Self {
Self {
tap: true,
accel_speed: 0.0,
disable_while_mouse_connected: false,
natural_scroll: true,
disable_while_typing: true,
swipe_threshold: default_swipe_threshold(),
@@ -178,68 +205,91 @@ impl Default for TouchpadConfig {
}
}
#[derive(Debug, Deserialize)]
pub(super) struct AnimationsConfig {
#[serde(default = "enabled_by_default")]
pub(super) enabled: bool,
#[serde(default)]
pub(super) reduced_motion: bool,
#[serde(default = "default_animation_speed")]
pub(super) speed: f64,
#[serde(default)]
pub(super) spring: SpringSettings,
#[serde(default)]
pub(super) viewport_spring: ViewportSpringSettings,
}
pub(super) type AnimationsConfig = ferese_animation::MotionSettings;
impl Default for AnimationsConfig {
fn default() -> Self {
Self {
enabled: true,
reduced_motion: false,
speed: 1.0,
spring: SpringSettings::default(),
viewport_spring: ViewportSpringSettings::default(),
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mouse_settings_are_separate_from_touchpad_and_validate_ranges() {
let defaults = Config::parse_source("").unwrap().input_settings().unwrap();
assert!(!defaults.mouse.natural_scroll);
assert!(defaults.touchpad.natural_scroll);
assert!(!defaults.touchpad.disable_while_mouse_connected);
let config = Config::parse_source(r#"
input {
mouse { accel-speed -0.4; accel-profile "flat"; scroll-factor 0.5; left-handed #true; }
touchpad { disable-while-mouse-connected #true; accel-speed 0.35; }
}
"#).unwrap().input_settings().unwrap();
assert!(config.touchpad.disable_while_mouse_connected);
assert_eq!(config.touchpad.accel_speed, 0.35);
assert_eq!(config.mouse.accel_profile, Some(AccelerationProfile::Flat));
assert_eq!(config.mouse.accel_speed, -0.4);
assert_eq!(config.mouse.scroll_factor, 0.5);
assert!(config.mouse.left_handed);
for speed in [-1.01, 1.01] {
let config =
Config::parse_source(&format!("input {{ touchpad {{ accel-speed {speed}; }} }}"))
.unwrap();
assert!(config.input_settings().is_err());
}
for (key, value) in [
("accel-speed", "1.1"),
("scroll-factor", "0"),
("drag-threshold", "-1"),
("scroll-cooldown-ms", "5001"),
] {
let config =
Config::parse_source(&format!("input {{ mouse {{ {key} {value}; }} }}")).unwrap();
assert!(config.input_settings().is_err(), "{key}={value}");
}
}
#[test]
fn compositor_speed_uses_shared_motion_bounds() {
for speed in [1e-100, 0.009, 101.0, 1e100] {
let config =
Config::parse_source(&format!("animations {{ speed {speed:e}; }}")).unwrap();
assert!(config.animation_speed().is_err());
}
}
}
#[derive(Debug, Deserialize)]
pub(super) struct ViewportSpringSettings {
#[serde(default = "default_viewport_mass")]
pub(super) mass: f64,
#[serde(default = "default_viewport_stiffness")]
pub(super) stiffness: f64,
#[serde(default = "default_viewport_damping_ratio")]
pub(super) damping_ratio: f64,
#[derive(
Clone, Copy, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(rename_all = "lowercase")]
pub enum AccelerationProfile {
Adaptive,
Flat,
}
impl Default for ViewportSpringSettings {
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct MouseSettings {
pub natural_scroll: bool,
pub accel_speed: f64,
pub accel_profile: Option<AccelerationProfile>,
pub left_handed: bool,
pub middle_emulation: bool,
pub scroll_factor: f64,
pub drag_threshold: f64,
pub scroll_cooldown_ms: u64,
}
impl Default for MouseSettings {
fn default() -> Self {
Self {
mass: default_viewport_mass(),
stiffness: default_viewport_stiffness(),
damping_ratio: default_viewport_damping_ratio(),
}
}
}
#[derive(Debug, Deserialize)]
pub(super) struct SpringSettings {
#[serde(default = "default_spring_mass")]
pub(super) mass: f64,
#[serde(default = "default_spring_stiffness")]
pub(super) stiffness: f64,
#[serde(default = "default_spring_damping")]
pub(super) damping: f64,
}
impl Default for SpringSettings {
fn default() -> Self {
Self {
mass: default_spring_mass(),
stiffness: default_spring_stiffness(),
damping: default_spring_damping(),
natural_scroll: false,
accel_speed: 0.0,
accel_profile: None,
left_handed: false,
middle_emulation: false,
scroll_factor: 1.0,
drag_threshold: 4.0,
scroll_cooldown_ms: 150,
}
}
}
+34 -6
View File
@@ -1,6 +1,20 @@
use super::*;
use super::{
ColumnDisplayValue, ColumnWidth, ColumnWidthValue, Config, ConfigError, FocusStrategyValue,
GapConfig, LayoutMode, LayoutModeValue, ViewportFocusStrategy,
};
impl Config {
pub fn default_column_display(&self) -> ferese_layout::ColumnDisplay {
match self
.scrolling
.default_column_display
.unwrap_or(ColumnDisplayValue::Stacked)
{
ColumnDisplayValue::Stacked => ferese_layout::ColumnDisplay::Stacked,
ColumnDisplayValue::Tabbed => ferese_layout::ColumnDisplay::Tabbed,
}
}
pub fn layout_mode(&self) -> LayoutMode {
match self.layout.mode.unwrap_or(LayoutModeValue::Scrolling) {
LayoutModeValue::Scrolling => LayoutMode::Scrolling,
@@ -47,7 +61,11 @@ impl Config {
}
pub fn scrolling_focus_strategy(&self) -> ViewportFocusStrategy {
match self.scrolling.focus_strategy.unwrap_or(FocusStrategyValue::Minimal) {
match self
.scrolling
.focus_strategy
.unwrap_or(FocusStrategyValue::Minimal)
{
FocusStrategyValue::Minimal => ViewportFocusStrategy::Minimal,
FocusStrategyValue::CenterOnFocus => ViewportFocusStrategy::Center,
FocusStrategyValue::Paged => ViewportFocusStrategy::Paged,
@@ -55,7 +73,10 @@ impl Config {
}
}
pub(super) fn nonnegative_layout_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
pub(super) fn nonnegative_layout_value(
value: f64,
field: &'static str,
) -> Result<f64, ConfigError> {
if value.is_finite() && value >= 0.0 {
Ok(value)
} else {
@@ -63,10 +84,17 @@ pub(super) fn nonnegative_layout_value(value: f64, field: &'static str) -> Resul
}
}
pub(super) fn parse_column_width(value: &ColumnWidthValue, field: &'static str) -> Result<ColumnWidth, ConfigError> {
pub(super) fn parse_column_width(
value: &ColumnWidthValue,
field: &'static str,
) -> Result<ColumnWidth, ConfigError> {
match value {
ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => Ok(ColumnWidth::Proportion(*value)),
ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => Ok(ColumnWidth::Full),
ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => {
Ok(ColumnWidth::Proportion(*value))
}
ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => {
Ok(ColumnWidth::Full)
}
value => Err(ConfigError::ColumnWidth {
field,
value: match value {
+148
View File
@@ -0,0 +1,148 @@
use std::io::{self, Read};
use std::path::Path;
use ferese_config::Document;
use ferese_config::theme::Candidate;
use super::Config;
use crate::RuntimeConfig;
pub(crate) fn read_source(path: &Path) -> Result<String, String> {
let mut source = String::new();
std::fs::File::open(path)
.map_err(|e| format!("{}: {e}", path.display()))?
.take(60 * 1024 + 1)
.read_to_string(&mut source)
.map_err(|e| e.to_string())?;
if source.len() > 60 * 1024 {
return Err(format!("{} exceeds 60 KiB", path.display()));
}
Ok(source)
}
pub(crate) fn prepare(
source: &str,
directory: &Path,
) -> Result<(Config, RuntimeConfig, Candidate), String> {
let document = Document::parse(source).map_err(|e| e.to_string())?;
prepare_document(&document, directory)
}
pub(crate) struct StartupConfig {
pub(crate) source: Option<String>,
pub(crate) runtime: RuntimeConfig,
pub(crate) theme: Candidate,
pub(crate) error: Option<String>,
}
pub(crate) fn startup(path: Option<&Path>) -> Result<StartupConfig, String> {
let directory = path.and_then(Path::parent).unwrap_or(Path::new("."));
let source = path.map_or(Ok(None), |path| match std::fs::symlink_metadata(path) {
Ok(_) => read_source(path).map(Some),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(format!("{}: {error}", path.display())),
});
let prepared = source.and_then(|source| {
let (_, runtime, theme) = prepare(source.as_deref().unwrap_or(""), directory)?;
Ok(StartupConfig {
source,
runtime,
theme,
error: None,
})
});
match prepared {
Ok(config) => Ok(config),
Err(error) => {
let (_, runtime, theme) = prepare("", directory)?;
Ok(StartupConfig {
source: Some(String::new()),
runtime,
theme,
error: Some(error),
})
}
}
}
pub(crate) fn prepare_document(
document: &Document,
directory: &Path,
) -> Result<(Config, RuntimeConfig, Candidate), String> {
let candidate = crate::theme::resolve_candidate(document, directory)?;
let config: Config =
serde_json::from_value(document.with_theme(&candidate.theme).value().clone())
.map_err(|e| e.to_string())?;
let mut runtime = config.runtime_config().map_err(|e| e.to_string())?;
runtime.theme_settings =
Config::resolved_theme_settings(&candidate.theme).map_err(|e| e.to_string())?;
Ok((config, runtime, candidate))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_startup_uses_defaults_and_keeps_the_authored_file() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.kdl");
let (_, defaults, default_theme) = prepare("", directory.path()).unwrap();
for source in [
"panel main { surface solid; group-surface none; }",
"panel main { geometry { height 1; }; }",
"animations { speed 0; }",
"theme { file \"missing-theme.kdl\"; }",
"panel main {",
] {
std::fs::write(&path, source).unwrap();
let config = startup(Some(&path)).unwrap();
assert!(config.error.is_some(), "{source}");
assert_eq!(config.source.as_deref(), Some(""));
assert_eq!(config.runtime.panel_style, defaults.panel_style);
assert_eq!(config.runtime.layout_mode, defaults.layout_mode);
assert_eq!(config.theme.theme, default_theme.theme);
assert_eq!(std::fs::read_to_string(&path).unwrap(), source);
assert!(
prepare(source, directory.path()).is_err(),
"strict validation must still reject {source}"
);
}
}
#[test]
fn startup_distinguishes_missing_valid_and_unreadable_files() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.kdl");
let missing = startup(Some(&path)).unwrap();
assert!(missing.error.is_none());
assert!(missing.source.is_none());
assert!(!path.exists());
let source = "// keep me\npanel main { style floating; geometry { height 42; }; }";
std::fs::write(&path, source).unwrap();
let valid = startup(Some(&path)).unwrap();
assert!(valid.error.is_none());
assert_eq!(valid.source.as_deref(), Some(source));
assert_eq!(
valid.runtime.panel_style,
ferese_panel::PanelStyle::Floating
);
assert_eq!(
Document::parse(valid.source.as_deref().unwrap())
.unwrap()
.get("panels.0.geometry.height"),
Some(&serde_json::json!(42))
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), source);
std::fs::write(&path, [0xff]).unwrap();
assert!(startup(Some(&path)).unwrap().error.is_some());
assert_eq!(std::fs::read(&path).unwrap(), [0xff]);
assert!(startup(Some(directory.path())).unwrap().error.is_some());
std::fs::remove_file(&path).unwrap();
std::os::unix::fs::symlink(directory.path().join("missing"), &path).unwrap();
assert!(startup(Some(&path)).unwrap().error.is_some());
assert!(path.is_symlink());
}
}
+105 -8
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Config, ConfigError, Deserialize, HashSet, default_true};
impl Config {
pub fn output_profiles(&self) -> Result<Vec<OutputProfile>, ConfigError> {
@@ -9,13 +9,19 @@ impl Config {
.map(|profile| {
let name = profile.name.trim();
if name.is_empty() {
return Err(ConfigError::OutputProfile("profile names cannot be empty".to_owned()));
return Err(ConfigError::OutputProfile(
"profile names cannot be empty".to_owned(),
));
}
if !names.insert(name.to_owned()) {
return Err(ConfigError::OutputProfile(format!("duplicate profile name {name:?}")));
return Err(ConfigError::OutputProfile(format!(
"duplicate profile name {name:?}"
)));
}
if profile.outputs.is_empty() {
return Err(ConfigError::OutputProfile(format!("profile {name:?} has no outputs")));
return Err(ConfigError::OutputProfile(format!(
"profile {name:?} has no outputs"
)));
}
let mut matchers = HashSet::new();
@@ -43,6 +49,7 @@ impl Config {
Ok(OutputSettings {
auto_refresh: output.auto_refresh,
required: output.required,
matcher: matcher.to_owned(),
enabled: output.enabled,
mode: output
@@ -58,9 +65,41 @@ impl Config {
})
.collect::<Result<Vec<_>, ConfigError>>()?;
if !outputs.iter().any(|output| output.enabled) {
return Err(ConfigError::OutputProfile(format!(
"profile {name:?} disables every output"
)));
}
if profile.layout != OutputLayout::Mirror && profile.mirror_source.is_some() {
return Err(ConfigError::OutputProfile(format!(
"profile {name:?}: mirror-source requires layout=\"mirror\""
)));
}
if let Some(source) = &profile.mirror_source
&& !outputs
.iter()
.any(|output| &output.matcher == source && output.enabled)
{
return Err(ConfigError::OutputProfile(format!(
"profile {name:?}: mirror source must name an enabled output"
)));
}
if profile.layout == OutputLayout::Mirror
&& outputs.iter().filter(|output| output.enabled).count() < 2
{
return Err(ConfigError::OutputProfile(format!(
"profile {name:?}: mirror needs at least two enabled outputs"
)));
}
Ok(OutputProfile {
name: name.to_owned(),
outputs,
layout: profile.layout,
confirm_timeout: profile.confirm_timeout,
lid_policy: profile.lid_policy,
lid_closed: profile.lid_closed,
mirror_source: profile.mirror_source.clone(),
})
})
.collect()
@@ -71,7 +110,9 @@ pub(super) fn parse_output_mode(value: &str) -> Result<OutputModeRequest, String
let (size, refresh) = value
.trim()
.split_once('@')
.map_or((value.trim(), None), |(size, refresh)| (size, Some(refresh)));
.map_or((value.trim(), None), |(size, refresh)| {
(size, Some(refresh))
});
let (width, height) = size
.split_once('x')
.ok_or_else(|| format!("mode {value:?} must use WIDTHxHEIGHT or WIDTHxHEIGHT@REFRESH"))?;
@@ -112,11 +153,17 @@ pub(super) const fn default_output_scale() -> f64 {
pub struct OutputProfile {
pub name: String,
pub outputs: Vec<OutputSettings>,
pub layout: OutputLayout,
pub confirm_timeout: u32,
pub lid_policy: LidPolicy,
pub lid_closed: Option<bool>,
pub mirror_source: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct OutputSettings {
pub auto_refresh: bool,
pub required: bool,
pub matcher: String,
pub enabled: bool,
pub mode: Option<OutputModeRequest>,
@@ -125,6 +172,18 @@ pub struct OutputSettings {
pub position: Option<[i32; 2]>,
}
impl OutputSettings {
/// Selectors and requiredness affect matching, not the applied output state.
pub(crate) fn same_configuration(&self, other: &Self) -> bool {
self.enabled == other.enabled
&& self.mode == other.mode
&& self.auto_refresh == other.auto_refresh
&& self.scale == other.scale
&& self.transform == other.transform
&& self.position == other.position
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OutputModeRequest {
pub width: u16,
@@ -132,7 +191,9 @@ pub struct OutputModeRequest {
pub refresh_millihertz: Option<u32>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
#[derive(
Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(rename_all = "snake_case")]
pub enum OutputTransform {
#[default]
@@ -152,17 +213,53 @@ pub enum OutputTransform {
Flipped270,
}
#[derive(Clone, Debug, Deserialize)]
#[derive(
Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum LidPolicy {
#[default]
DockOrSuspend,
Ignore,
}
const fn default_confirm_timeout() -> u32 {
15
}
#[derive(
Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum OutputLayout {
InternalOnly,
ExternalOnly,
#[default]
Extend,
Mirror,
}
#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct OutputProfileConfig {
pub(super) name: String,
#[serde(default)]
pub(super) layout: OutputLayout,
#[serde(default = "default_confirm_timeout")]
pub(super) confirm_timeout: u32,
#[serde(default)]
pub(super) lid_policy: LidPolicy,
pub(super) lid_closed: Option<bool>,
pub(super) mirror_source: Option<String>,
#[serde(default)]
pub(super) outputs: Vec<OutputConfig>,
}
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct OutputConfig {
#[serde(default)]
pub(super) auto_refresh: bool,
#[serde(default = "default_true")]
pub(super) required: bool,
#[serde(rename = "match")]
pub(super) matcher: String,
#[serde(default = "default_true")]
+169
View File
@@ -0,0 +1,169 @@
use super::{Config, ConfigError, Deserialize, parse_color};
#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)]
pub(super) struct TabIndicatorConfig {
width: f64,
segment_height: f64,
segment_gap: f64,
hit_width: f64,
gap_threshold: f64,
list_width: f64,
row_height: f64,
padding: f64,
font_size: f64,
outline_width: f64,
outline_color: Option<String>,
flash_duration_ms: u64,
active_color: Option<String>,
inactive_color: Option<String>,
background_color: Option<String>,
text_color: Option<String>,
}
impl Default for TabIndicatorConfig {
fn default() -> Self {
Self {
width: 4.0,
segment_height: 24.0,
segment_gap: 4.0,
hit_width: 12.0,
gap_threshold: 8.0,
list_width: 220.0,
row_height: 28.0,
padding: 4.0,
font_size: 12.0,
outline_width: 0.5,
outline_color: None,
flash_duration_ms: 1000,
active_color: None,
inactive_color: None,
background_color: None,
text_color: None,
}
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct TabIndicatorSettings {
pub width: f64,
pub segment_height: f64,
pub segment_gap: f64,
pub hit_width: f64,
pub gap_threshold: f64,
pub list_width: f64,
pub row_height: f64,
pub padding: f64,
pub font_size: f64,
pub outline_width: f64,
pub outline_color: Option<[f32; 4]>,
pub flash_duration: std::time::Duration,
pub active_color: Option<[f32; 4]>,
pub inactive_color: Option<[f32; 4]>,
pub background_color: Option<[f32; 4]>,
pub text_color: Option<[f32; 4]>,
}
impl Config {
pub(crate) fn tab_indicator_settings(&self) -> Result<TabIndicatorSettings, ConfigError> {
let c = &self.scrolling.tab_indicator;
let range = |value: f64, min: f64, max: f64, field: &'static str| {
if value.is_finite() && (min..=max).contains(&value) {
Ok(value)
} else {
Err(ConfigError::LayoutValue { field, value })
}
};
let color = |value: &Option<String>, field| {
value
.as_ref()
.map(|s| parse_color(s, field).map(|c| c.0))
.transpose()
};
let width = range(c.width, 1.0, 16.0, "tab_indicator.width")?;
let hit_width = range(c.hit_width, width, 64.0, "tab_indicator.hit_width")?;
let font_size = range(c.font_size, 8.0, 32.0, "tab_indicator.font_size")?;
Ok(TabIndicatorSettings {
width,
hit_width,
font_size,
outline_width: range(c.outline_width, 0.0, 8.0, "tab_indicator.outline_width")?,
outline_color: color(&c.outline_color, "tab_indicator.outline_color")?,
segment_height: range(
c.segment_height,
width,
128.0,
"tab_indicator.segment_height",
)?,
segment_gap: range(c.segment_gap, 0.0, 64.0, "tab_indicator.segment_gap")?,
gap_threshold: range(c.gap_threshold, width, 128.0, "tab_indicator.gap_threshold")?,
list_width: range(c.list_width, 80.0, 640.0, "tab_indicator.list_width")?,
row_height: range(
c.row_height,
font_size + 6.0,
80.0,
"tab_indicator.row_height",
)?,
padding: range(c.padding, 0.0, 24.0, "tab_indicator.padding")?,
flash_duration: std::time::Duration::from_millis(range(
c.flash_duration_ms as f64,
0.0,
60_000.0,
"tab_indicator.flash_duration_ms",
)? as u64),
active_color: color(&c.active_color, "tab_indicator.active_color")?,
inactive_color: color(&c.inactive_color, "tab_indicator.inactive_color")?,
background_color: color(&c.background_color, "tab_indicator.background_color")?,
text_color: color(&c.text_color, "tab_indicator.text_color")?,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn indicator_defaults_overrides_and_validation() {
let c = Config::parse_source("")
.unwrap()
.tab_indicator_settings()
.unwrap();
assert_eq!(
(
c.width,
c.segment_height,
c.segment_gap,
c.hit_width,
c.gap_threshold
),
(4., 24., 4., 12., 8.)
);
assert_eq!(c.flash_duration.as_millis(), 1000);
assert!(c.active_color.is_none());
let config = Config::parse_source(
"scrolling { tab-indicator { width 3; hit-width 14; flash-duration-ms 750; active-color \"#ff0000\"; }; }",
)
.unwrap();
let c = config.tab_indicator_settings().unwrap();
assert_eq!(
(c.width, c.hit_width, c.flash_duration.as_millis()),
(3., 14., 750)
);
assert_eq!(c.active_color, Some([1., 0., 0., 1.]));
for source in [
"width 0",
"segment-gap -1",
"hit-width 2",
"row-height 8",
"flash-duration-ms 60001",
"active-color \"invalid\"",
] {
let config =
Config::parse_source(&format!("scrolling {{ tab-indicator {{ {source}; }}; }}"))
.unwrap();
assert!(config.tab_indicator_settings().is_err(), "{source}");
}
assert!(Config::parse_source("scrolling { tab-indicator { typo 1; }; }").is_err());
}
}
+103
View File
@@ -0,0 +1,103 @@
use std::path::PathBuf;
use serde::Deserialize;
#[derive(
Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum XwaylandStartup {
#[default]
OnDemand,
Eager,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct XwaylandConfig {
pub enabled: bool,
pub startup: XwaylandStartup,
pub path: PathBuf,
}
impl Default for XwaylandConfig {
fn default() -> Self {
Self {
enabled: true,
startup: XwaylandStartup::OnDemand,
path: PathBuf::from("xwayland-satellite"),
}
}
}
impl XwaylandConfig {
pub fn validate(&self) -> Result<(), String> {
if self.enabled && self.path.as_os_str().is_empty() {
return Err("xwayland.path must name an executable".into());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_enable_on_demand_with_satellite_on_path() {
let config = XwaylandConfig::default();
assert!(config.enabled);
assert_eq!(config.startup, XwaylandStartup::OnDemand);
assert_eq!(config.path, PathBuf::from("xwayland-satellite"));
assert_eq!(config.validate(), Ok(()));
}
#[test]
fn disabled_config_does_not_require_a_path() {
let config = XwaylandConfig {
enabled: false,
path: PathBuf::new(),
..XwaylandConfig::default()
};
assert_eq!(config.validate(), Ok(()));
}
#[test]
fn enabled_config_rejects_an_empty_path() {
let config = XwaylandConfig {
enabled: true,
path: PathBuf::new(),
..XwaylandConfig::default()
};
assert_eq!(
config.validate(),
Err("xwayland.path must name an executable".to_owned())
);
}
#[test]
fn startup_mode_accepts_kebab_case() {
#[derive(Deserialize, schemars::JsonSchema, serde::Serialize)]
struct Wrapper {
startup: XwaylandStartup,
}
assert_eq!(
serde_json::from_str::<Wrapper>(r#"{"startup":"eager"}"#)
.unwrap()
.startup,
XwaylandStartup::Eager
);
assert_eq!(
serde_json::from_str::<Wrapper>(r#"{"startup":"on-demand"}"#)
.unwrap()
.startup,
XwaylandStartup::OnDemand
);
assert!(serde_json::from_str::<Wrapper>(r#"{"startup":"eagerly"}"#).is_err());
}
}
+506 -80
View File
@@ -1,48 +1,380 @@
use std::fs;
use std::sync::OnceLock;
use std::fs::File;
use std::io::Read;
use std::sync::{Arc, Condvar, Mutex, mpsc};
use std::time::{Duration, Instant};
use smithay::backend::allocator::Fourcc;
use smithay::backend::renderer::element::memory::MemoryRenderBuffer;
use smithay::input::pointer::CursorIcon;
use smithay::utils::{Buffer, Point, Size, Transform};
use smithay::input::pointer::{CursorIcon, CursorImageStatus};
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use smithay::reexports::calloop::{LoopSignal, RegistrationToken};
use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Size, Transform};
use xcursor::CursorTheme;
use xcursor::parser::Image;
const DEFAULT_CURSOR_SIZE: u32 = 24;
const MAX_FILE_BYTES: usize = 8 * 1024 * 1024;
const MAX_FRAMES: usize = 256;
const MAX_PIXELS: usize = 2 * 1024 * 1024;
const MAX_CACHE_BYTES: usize = 32 * 1024 * 1024;
#[derive(Debug, Clone)]
pub(crate) struct NamedCursor {
pub(crate) struct CursorFrame {
pub buffer: MemoryRenderBuffer,
pub hotspot: Point<i32, Buffer>,
pub size: Size<i32, Buffer>,
pub buffer_scale: i32,
delay: Duration,
}
pub(crate) fn cursor_theme() -> CursorTheme {
let name = std::env::var("XCURSOR_THEME").unwrap_or_else(|_| "default".into());
impl CursorFrame {
pub(crate) fn logical_rect(&self, pointer: Point<f64, Logical>) -> Rectangle<f64, Logical> {
Rectangle::new(
pointer
- Point::from((
f64::from(self.hotspot.x) / f64::from(self.buffer_scale),
f64::from(self.hotspot.y) / f64::from(self.buffer_scale),
)),
(
f64::from(self.size.w) / f64::from(self.buffer_scale),
f64::from(self.size.h) / f64::from(self.buffer_scale),
)
.into(),
)
}
CursorTheme::load(&name)
}
pub(crate) fn load_named_cursor(theme: &CursorTheme, icon: CursorIcon) -> NamedCursor {
let requested_size = cursor_size();
let image = cursor_names(icon)
.iter()
.find_map(|name| load_image(theme, name, requested_size));
match image {
Some(image) => cursor_from_image(image),
None => fallback_cursor(),
pub(crate) fn physical_location(
&self,
pointer: Point<f64, Logical>,
scale: f64,
) -> Point<f64, Physical> {
self.logical_rect(pointer).loc.to_physical(scale)
}
}
fn cursor_size() -> u32 {
static SIZE: OnceLock<u32> = OnceLock::new();
#[derive(Debug, Clone)]
pub(crate) struct NamedCursor {
frames: Arc<[CursorFrame]>,
bytes: usize,
requested_scale: i32,
}
*SIZE.get_or_init(|| {
std::env::var("XCURSOR_SIZE")
.ok()
.and_then(|value| value.parse().ok())
.filter(|size| *size > 0)
.unwrap_or(DEFAULT_CURSOR_SIZE)
impl NamedCursor {
fn sample(&self, elapsed: Duration) -> (usize, Option<Duration>) {
if self.frames.len() <= 1 {
return (0, None);
}
let period: Duration = self.frames.iter().map(|frame| frame.delay).sum();
let mut offset = elapsed.as_nanos() % period.as_nanos();
for (index, frame) in self.frames.iter().enumerate() {
if offset < frame.delay.as_nanos() {
return (
index,
Some(frame.delay - Duration::from_nanos(offset as u64)),
);
}
offset -= frame.delay.as_nanos();
}
unreachable!("cursor phase is within the bounded, nonzero period")
}
}
#[derive(Default)]
struct Requests {
pending: Option<(CursorIcon, i32)>,
in_flight: Option<(CursorIcon, i32)>,
closed: bool,
}
struct Loaded {
icon: CursorIcon,
scale: i32,
cursor: Option<NamedCursor>,
}
pub(crate) struct Loader {
shared: Arc<(Mutex<Requests>, Condvar)>,
results: mpsc::Receiver<Loaded>,
}
impl Loader {
pub(crate) fn new(signal: LoopSignal) -> std::io::Result<Self> {
Self::with_loader(signal, {
// Theme discovery and inherited-theme reads belong to the worker too.
let mut theme = None;
move |(icon, scale)| {
let theme = theme.get_or_insert_with(|| {
CursorTheme::load(
&std::env::var("XCURSOR_THEME").unwrap_or_else(|_| "default".into()),
)
});
load_named_cursor(theme, icon, scale)
}
})
}
fn with_loader(
signal: LoopSignal,
mut load: impl FnMut((CursorIcon, i32)) -> Option<NamedCursor> + Send + 'static,
) -> std::io::Result<Self> {
let shared = Arc::new((
Mutex::new(Requests {
pending: Some((CursorIcon::Default, 1)),
..Requests::default()
}),
Condvar::new(),
));
let worker = shared.clone();
let (sender, results) = mpsc::sync_channel(1);
std::thread::Builder::new()
.name("ferese-cursors".into())
.spawn(move || {
loop {
let (requests, ready) = &*worker;
let mut requests = ready
.wait_while(
requests.lock().expect(
"cursor decode queue must remain consistent across workers",
),
|requests| !requests.closed && requests.pending.is_none(),
)
.expect("cursor decode queue must remain consistent across workers");
if requests.closed {
break;
}
let request = requests
.pending
.take()
.expect("the decode wait completed with a pending cursor request");
requests.in_flight = Some(request);
drop(requests);
let cursor = load(request);
let (icon, scale) = request;
if sender
.send(Loaded {
icon,
scale,
cursor,
})
.is_err()
{
break;
}
signal.wakeup();
worker
.0
.lock()
.expect("cursor decode queue must remain consistent across workers")
.in_flight = None;
}
})?;
Ok(Self { shared, results })
}
pub(crate) fn request(&self, icon: CursorIcon, scale: i32) {
let request = (icon, scale);
let mut requests = self
.shared
.0
.lock()
.expect("cursor decode queue must remain consistent across workers");
if requests.in_flight == Some(request) || requests.pending == Some(request) {
return;
}
requests.pending = Some(request);
self.shared.1.notify_one();
}
}
impl Drop for Loader {
fn drop(&mut self) {
let mut requests = self
.shared
.0
.lock()
.expect("cursor decode queue must remain consistent across workers");
requests.closed = true;
requests.pending = None;
self.shared.1.notify_one();
}
}
pub(crate) struct Animation {
icon: Option<CursorIcon>,
epoch: Instant,
frame: usize,
timer: Option<RegistrationToken>,
pub(crate) reset: bool,
pub(crate) buffer_scale: i32,
}
impl Animation {
pub(crate) fn capture_revision(&self) -> (Option<CursorIcon>, Instant, usize) {
(self.icon, self.epoch, self.frame)
}
}
impl Default for Animation {
fn default() -> Self {
Self {
icon: None,
epoch: Instant::now(),
frame: 0,
timer: None,
reset: false,
buffer_scale: 1,
}
}
}
impl crate::Ferese {
pub(crate) fn named_cursor_frame(&self) -> Option<&CursorFrame> {
let CursorImageStatus::Named(icon) = self.cursor_status else {
return None;
};
let cursor = self
.named_cursors
.get(&icon)
.or_else(|| self.named_cursors.get(&CursorIcon::Default))?;
let frame = if self.cursor_animation.icon == Some(icon) {
self.cursor_animation.frame
} else {
0
};
cursor.frames.get(frame).or_else(|| cursor.frames.first())
}
pub(crate) fn update_named_cursor(&mut self, now: Instant) {
self.cursor_animation.buffer_scale = if let Some(backend) = self.direct_backend.as_ref() {
backend
.physical_outputs()
.map(|output| output.current_scale().fractional_scale().ceil() as i32)
.max()
} else {
self.space
.outputs()
.map(|output| output.current_scale().fractional_scale().ceil() as i32)
.max()
}
.unwrap_or(1)
.clamp(1, 8);
while let Ok(Loaded {
icon,
scale,
cursor,
}) = self.cursor_loader.results.try_recv()
{
let mut cursor = cursor.unwrap_or_else(fallback_cursor);
cursor.requested_scale = scale;
let active = match self.cursor_status {
CursorImageStatus::Named(icon) => Some(icon),
_ => None,
};
let affects_current = active == Some(icon)
|| (icon == CursorIcon::Default
&& active.is_some_and(|active| !self.named_cursors.contains_key(&active)));
let mut bytes: usize = self.named_cursors.values().map(|cursor| cursor.bytes).sum();
while bytes + cursor.bytes > MAX_CACHE_BYTES {
let Some(evict) = self.named_cursors.keys().copied().find(|key| {
*key != CursorIcon::Default && *key != icon && Some(*key) != active
}) else {
break;
};
bytes -= self
.named_cursors
.remove(&evict)
.expect("cursor selection retains the requested or default cursor")
.bytes;
}
self.named_cursors.insert(icon, cursor);
if affects_current {
self.cursor_animation.reset = true;
self.cursor_redraw_pending |=
crate::backends::direct::cursor_animation_visible(self);
}
}
let icon = match self.cursor_status {
CursorImageStatus::Named(icon) => Some(icon),
_ => None,
};
if let Some(icon) = icon
&& self
.named_cursors
.get(&icon)
.is_none_or(|cursor| cursor.requested_scale != self.cursor_animation.buffer_scale)
{
self.cursor_loader
.request(icon, self.cursor_animation.buffer_scale);
}
let animated = icon
.and_then(|icon| {
self.named_cursors
.get(&icon)
.or_else(|| self.named_cursors.get(&CursorIcon::Default))
})
.is_some_and(|cursor| cursor.frames.len() > 1);
let visible = animated && crate::backends::direct::cursor_animation_visible(self);
if !visible || self.cursor_animation.icon != icon || self.cursor_animation.reset {
if let Some(token) = self.cursor_animation.timer.take() {
self.loop_handle.remove(token);
}
self.cursor_animation.icon = icon.filter(|_| visible);
self.cursor_animation.epoch = now;
self.cursor_animation.frame = 0;
self.cursor_animation.reset = false;
}
if !visible {
return;
}
let icon = icon.expect("the hidden-cursor branch returned before image selection");
let cursor = self
.named_cursors
.get(&icon)
.or_else(|| self.named_cursors.get(&CursorIcon::Default))
.expect("cursor selection retains the requested or default cursor");
let (frame, remaining) =
cursor.sample(now.saturating_duration_since(self.cursor_animation.epoch));
if self.cursor_animation.frame != frame {
self.cursor_animation.frame = frame;
self.cursor_redraw_pending = true;
}
if let Some(remaining) = remaining
&& self.cursor_animation.timer.is_none()
{
match self.loop_handle.insert_source(
Timer::from_deadline(now + remaining),
|_, _, state| {
state.cursor_animation.timer = None;
state.cursor_redraw_pending = true;
TimeoutAction::Drop
},
) {
Ok(token) => self.cursor_animation.timer = Some(token),
Err(error) => tracing::warn!(%error, "could not schedule animated cursor"),
}
}
}
}
fn load_named_cursor(theme: &CursorTheme, icon: CursorIcon, scale: i32) -> Option<NamedCursor> {
let requested = std::env::var("XCURSOR_SIZE")
.ok()
.and_then(|size| size.parse::<u32>().ok())
.filter(|size| *size > 0 && *size <= 1024)
.unwrap_or(DEFAULT_CURSOR_SIZE);
cursor_names(icon).iter().find_map(|name| {
let path = theme.load_icon(name)?;
let file = File::open(path).ok()?;
let metadata = file.metadata().ok()?;
if !metadata.is_file() || metadata.len() > MAX_FILE_BYTES as u64 {
return None;
}
let mut bytes = Vec::new();
file.take((MAX_FILE_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.ok()?;
parse_cursor(&bytes, requested, scale)
})
}
@@ -55,36 +387,142 @@ fn cursor_names(icon: CursorIcon) -> [&'static str; 3] {
}
}
fn load_image(theme: &CursorTheme, name: &str, requested_size: u32) -> Option<Image> {
let path = theme.load_icon(name)?;
let bytes = fs::read(path).ok()?;
xcursor::parser::parse_xcursor(&bytes)?.into_iter().min_by_key(|image| {
(
image.size.abs_diff(requested_size),
image.width.abs_diff(requested_size),
)
// Inspect every image before allocating buffers, then decode only the selected
// nominal size. TOC aliases count toward the budget just like separate frames.
fn parse_cursor(bytes: &[u8], requested: u32, scale: i32) -> Option<NamedCursor> {
fn word(bytes: &[u8], offset: usize) -> Option<u32> {
Some(u32::from_le_bytes(
bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?,
))
}
if bytes.len() > MAX_FILE_BYTES || bytes.get(..4)? != b"Xcur" {
return None;
}
let header = word(bytes, 4)? as usize;
let count = word(bytes, 12)? as usize;
if header < 16
|| count > MAX_FRAMES
|| header.checked_add(count.checked_mul(12)?)? > bytes.len()
{
return None;
}
let mut images = Vec::new();
let mut total_pixels = 0usize;
for i in 0..count {
let toc = header + i * 12;
if word(bytes, toc)? != 0xfffd0002 {
continue;
}
let nominal = word(bytes, toc + 4)?;
let pos = word(bytes, toc + 8)? as usize;
if word(bytes, pos)? != 36
|| word(bytes, pos.checked_add(4)?)? != 0xfffd0002
|| word(bytes, pos.checked_add(8)?)? != nominal
|| word(bytes, pos.checked_add(12)?)? != 1
{
return None;
}
let width = word(bytes, pos + 16)?;
let height = word(bytes, pos + 20)?;
let xhot = word(bytes, pos + 24)?;
let yhot = word(bytes, pos + 28)?;
if nominal == 0
|| width == 0
|| height == 0
|| width > 1024
|| height > 1024
|| xhot > width
|| yhot > height
{
return None;
}
let pixels = (width as usize).checked_mul(height as usize)?;
total_pixels = total_pixels.checked_add(pixels)?;
if total_pixels > MAX_PIXELS {
return None;
}
let start = pos.checked_add(36)?;
let end = start.checked_add(pixels.checked_mul(4)?)?;
let rgba = bytes.get(start..end)?;
images.push((
nominal,
width,
height,
xhot,
yhot,
word(bytes, pos + 32)?,
rgba,
));
}
let base = images
.iter()
.map(|image| image.0)
.min_by_key(|size| (size.abs_diff(requested), *size))?;
let scale = scale.clamp(1, 8);
let desired = base.checked_mul(scale as u32)?;
let nominal = images
.iter()
.map(|image| image.0)
.min_by_key(|size| (size.abs_diff(desired), *size))?;
let mut frames = Vec::new();
let mut asset_bytes = 0usize;
for (_, width, height, xhot, yhot, delay, rgba) in
images.into_iter().filter(|image| image.0 == nominal)
{
let ratio = f64::from(desired) / f64::from(nominal);
let w = (f64::from(width) * ratio).round().max(1.0) as u32;
let h = (f64::from(height) * ratio).round().max(1.0) as u32;
if w > 1024 || h > 1024 {
return None;
}
let size: Size<i32, Buffer> = (w as i32, h as i32).into();
asset_bytes =
asset_bytes.checked_add((w as usize).checked_mul(h as usize)?.checked_mul(4)?)?;
if asset_bytes > MAX_PIXELS * 4 {
return None;
}
let resized;
let pixels = if w != width || h != height {
let original = image::RgbaImage::from_raw(width, height, rgba.to_vec())?;
resized =
image::imageops::resize(&original, w, h, image::imageops::FilterType::Triangle);
resized.as_raw().as_slice()
} else {
rgba
};
frames.push(CursorFrame {
buffer: MemoryRenderBuffer::from_slice(
pixels,
Fourcc::Abgr8888,
size,
scale,
Transform::Normal,
None,
),
hotspot: (
(f64::from(xhot) * ratio).round() as i32,
(f64::from(yhot) * ratio).round() as i32,
)
.into(),
size,
buffer_scale: scale,
// Zero-delay themes need a finite cadence rather than a busy loop.
delay: Duration::from_millis(if delay == 0 { 16 } else { u64::from(delay) }),
});
}
Some(NamedCursor {
frames: frames.into(),
bytes: asset_bytes,
requested_scale: scale,
})
}
fn cursor_from_image(image: Image) -> NamedCursor {
let size = Size::<i32, Buffer>::from((image.width as i32, image.height as i32));
let buffer = MemoryRenderBuffer::from_slice(&image.pixels_rgba, Fourcc::Abgr8888, size, 1, Transform::Normal, None);
NamedCursor {
buffer,
hotspot: Point::from((image.xhot as i32, image.yhot as i32)),
}
}
fn fallback_cursor() -> NamedCursor {
pub(crate) fn fallback_cursor() -> NamedCursor {
const WIDTH: usize = 12;
const HEIGHT: usize = 18;
let mut pixels = vec![0_u8; WIDTH * HEIGHT * 4];
for y in 0..HEIGHT {
let body_width = (y / 2 + 1).min(8);
for x in 0..body_width {
let edge = x == 0 || x + 1 == body_width || y == 0;
let offset = (y * WIDTH + x) * 4;
@@ -92,38 +530,26 @@ fn fallback_cursor() -> NamedCursor {
pixels[offset..offset + 4].copy_from_slice(&[color, color, color, 255]);
}
}
NamedCursor {
buffer: MemoryRenderBuffer::from_slice(
&pixels,
Fourcc::Abgr8888,
Size::<i32, Buffer>::from((WIDTH as i32, HEIGHT as i32)),
1,
Transform::Normal,
None,
),
hotspot: Point::default(),
frames: vec![CursorFrame {
buffer: MemoryRenderBuffer::from_slice(
&pixels,
Fourcc::Abgr8888,
(WIDTH as i32, HEIGHT as i32),
1,
Transform::Normal,
None,
),
hotspot: Point::default(),
size: (WIDTH as i32, HEIGHT as i32).into(),
buffer_scale: 1,
delay: Duration::from_millis(16),
}]
.into(),
bytes: pixels.len(),
requested_scale: 1,
}
}
#[cfg(test)]
mod tests {
use smithay::input::pointer::CursorIcon;
use super::cursor_names;
#[test]
fn supplies_legacy_fallback_names_for_common_cursors() {
assert_eq!(cursor_names(CursorIcon::Default), ["default", "left_ptr", "arrow"]);
assert_eq!(cursor_names(CursorIcon::Pointer), ["pointer", "hand2", "left_ptr"]);
assert_eq!(cursor_names(CursorIcon::Text), ["text", "xterm", "left_ptr"]);
}
#[test]
fn falls_back_to_default_for_other_named_cursors() {
assert_eq!(
cursor_names(CursorIcon::Crosshair),
["crosshair", "default", "left_ptr"]
);
}
}
mod tests;
+259
View File
@@ -0,0 +1,259 @@
use super::*;
use smithay::input::SeatHandler;
use smithay::input::pointer::MotionEvent;
use smithay::output::{Mode, Output, PhysicalProperties, Subpixel};
use smithay::reexports::calloop::EventLoop;
use smithay::utils::SERIAL_COUNTER;
fn file(frames: &[(u32, u32, u32, u32)]) -> Vec<u8> {
let mut bytes = b"Xcur".to_vec();
bytes.extend(
[16u32, 1, frames.len() as u32]
.into_iter()
.flat_map(u32::to_le_bytes),
);
let mut pos = 16 + frames.len() * 12;
for &(size, width, _, _) in frames {
bytes.extend(
[0xfffd0002, size, pos as u32]
.into_iter()
.flat_map(u32::to_le_bytes),
);
pos += 36 + width as usize * width as usize * 4;
}
for &(size, width, hotspot, delay) in frames {
bytes.extend(
[
36, 0xfffd0002, size, 1, width, width, hotspot, hotspot, delay,
]
.into_iter()
.flat_map(u32::to_le_bytes),
);
bytes.extend([255, 255, 255, 255].repeat(width as usize * width as usize));
}
bytes
}
#[test]
fn hotspot_and_footprint_use_the_images_scale() {
let bytes = file(&[(24, 24, 12, 50), (48, 48, 24, 50)]);
for scale in [1.0f64, 1.25, 2.0] {
let asset = parse_cursor(&bytes, 24, scale.ceil() as i32).unwrap();
let frame = &asset.frames[0];
let pointer: Point<f64, Logical> = (100.0, 120.0).into();
let location = frame.physical_location(pointer, scale);
let hotspot = f64::from(frame.hotspot.x) * scale / f64::from(frame.buffer_scale);
assert_eq!(location.x + hotspot, pointer.x * scale);
assert_eq!(location.y + hotspot, pointer.y * scale);
assert_eq!(frame.logical_rect(pointer).size, (24.0, 24.0).into());
if scale > 1.0 {
assert_eq!(frame.size.w, 48);
}
}
}
#[test]
fn unavailable_hidpi_size_preserves_logical_size_and_hotspot() {
let asset = parse_cursor(&file(&[(32, 32, 16, 50)]), 24, 2).unwrap();
let frame = &asset.frames[0];
assert_eq!(frame.buffer_scale, 2);
assert_eq!(frame.size.w, 64);
assert_eq!(frame.hotspot.x, 32);
assert_eq!(
frame.logical_rect((100.0, 100.0).into()).size,
(32.0, 32.0).into()
);
}
#[test]
fn selects_a_nominal_sequence_and_preserves_frame_delays() {
let asset = parse_cursor(
&file(&[(48, 48, 24, 70), (24, 24, 12, 30), (24, 24, 12, 80)]),
24,
1,
)
.unwrap();
assert_eq!(asset.frames.len(), 2);
for (time, index, remaining) in [
(0, 0, 30),
(29, 0, 1),
(30, 1, 80),
(109, 1, 1),
(110, 0, 30),
(400, 1, 40),
] {
assert_eq!(
asset.sample(Duration::from_millis(time)),
(index, Some(Duration::from_millis(remaining)))
);
}
let still = parse_cursor(&file(&[(24, 24, 12, 0)]), 24, 1).unwrap();
assert_eq!(still.sample(Duration::from_secs(300)), (0, None));
let zero = parse_cursor(&file(&[(24, 24, 12, 0), (24, 24, 12, 0)]), 24, 1).unwrap();
assert_eq!(zero.sample(Duration::from_millis(16)).0, 1);
}
#[test]
fn rejects_oversized_and_malformed_assets_before_buffer_allocation() {
assert!(parse_cursor(&vec![0; MAX_FILE_BYTES + 1], 24, 1).is_none());
let mut bytes = file(&[(24, 24, 12, 30)]);
bytes[12..16].copy_from_slice(&((MAX_FRAMES + 1) as u32).to_le_bytes());
assert!(parse_cursor(&bytes, 24, 1).is_none());
let mut bytes = file(&[(24, 24, 12, 30)]);
bytes[16 + 8..16 + 12].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(parse_cursor(&bytes, 24, 1).is_none());
let mut bytes = file(&[(24, 24, 12, 30)]);
bytes[28 + 16..28 + 20].copy_from_slice(&2048u32.to_le_bytes());
assert!(parse_cursor(&bytes, 24, 1).is_none());
// Repeated TOC references must not bypass the aggregate pixel limit.
let mut bytes = file(&[(1024, 1024, 0, 30)]);
let frame = bytes.split_off(28);
bytes.truncate(16);
bytes[12..16].copy_from_slice(&3u32.to_le_bytes());
for _ in 0..3 {
bytes.extend(
[0xfffd0002, 1024, 52]
.into_iter()
.flat_map(u32::to_le_bytes),
);
}
bytes.extend(frame);
assert!(parse_cursor(&bytes, 24, 1).is_none());
assert!(parse_cursor(&file(&[(24, 24, 12, 30)])[..40], 24, 1).is_none());
}
#[test]
fn delayed_loading_does_not_block_cursor_callbacks_or_pointer_dispatch() {
if !crate::startup_tests::private_runtime(
"cursor::tests::delayed_loading_does_not_block_cursor_callbacks_or_pointer_dispatch",
) {
return;
}
let mut events = EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let (started, calls) = mpsc::channel();
let (release, held) = mpsc::channel();
state.cursor_loader = Loader::with_loader(events.get_signal(), move |request| {
started.send(request).unwrap();
if request.0 == CursorIcon::Default {
held.recv_timeout(Duration::from_secs(2)).unwrap();
}
None
})
.unwrap();
assert_eq!(
calls.recv_timeout(Duration::from_secs(2)).unwrap().0,
CursorIcon::Default
);
let seat = state.seat.clone();
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Pointer));
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Text));
let pointer = state.seat.get_pointer().unwrap();
pointer.motion(
&mut state,
None,
&MotionEvent {
location: (10.0, 20.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(1),
},
);
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Text));
crate::after_dispatch_with_redraw(&mut state, |_, _| {});
assert_eq!(pointer.current_location(), (10.0, 20.0).into());
assert!(
state.named_cursor_frame().is_some(),
"cached fallback must remain visible"
);
assert!(
calls.try_recv().is_err(),
"decoder is still deliberately blocked"
);
release.send(()).unwrap();
assert_eq!(
calls.recv_timeout(Duration::from_secs(2)).unwrap().0,
CursorIcon::Text
);
}
#[test]
fn cursor_animation_advances_and_cancels_on_hide_and_shape_change() {
if !crate::startup_tests::private_runtime(
"cursor::tests::cursor_animation_advances_and_cancels_on_hide_and_shape_change",
) {
return;
}
let mut events = EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
// No background result can replace the synthetic fixture.
state.cursor_loader = Loader::with_loader(events.get_signal(), |_| None).unwrap();
let output = Output::new(
"cursor-test".into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: (800, 600).into(),
refresh: 60_000,
}),
None,
None,
None,
);
state.space.map_output(&output, (0, 0));
let asset = parse_cursor(&file(&[(24, 24, 12, 30), (24, 24, 12, 80)]), 24, 1).unwrap();
state.named_cursors.insert(CursorIcon::Wait, asset);
state.cursor_status = CursorImageStatus::Named(CursorIcon::Wait);
let now = Instant::now();
state.update_named_cursor(now);
assert_eq!(state.cursor_animation.frame, 0);
assert!(state.cursor_animation.timer.is_some());
// Invoke the same deadline callback by dispatching the real timer.
while Instant::now() < now + Duration::from_millis(35) {
events
.dispatch(Duration::from_millis(5), &mut state)
.unwrap();
}
state.update_named_cursor(now + Duration::from_millis(30));
assert_eq!(state.cursor_animation.frame, 1);
assert!(state.cursor_animation.timer.is_some());
let seat = state.seat.clone();
state.cursor_image(&seat, CursorImageStatus::Hidden);
state.update_named_cursor(now + Duration::from_millis(31));
assert!(state.cursor_animation.timer.is_none());
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Wait));
state.update_named_cursor(now + Duration::from_millis(32));
assert!(state.cursor_animation.timer.is_some());
state
.named_cursors
.insert(CursorIcon::Text, fallback_cursor());
state.cursor_image(&seat, CursorImageStatus::Named(CursorIcon::Text));
state.update_named_cursor(now + Duration::from_millis(33));
assert!(state.cursor_animation.timer.is_none());
}
#[test]
fn supplies_legacy_fallback_names_for_common_cursors() {
assert_eq!(
cursor_names(CursorIcon::Default),
["default", "left_ptr", "arrow"]
);
assert_eq!(
cursor_names(CursorIcon::Pointer),
["pointer", "hand2", "left_ptr"]
);
assert_eq!(
cursor_names(CursorIcon::Text),
["text", "xterm", "left_ptr"]
);
assert_eq!(
cursor_names(CursorIcon::Crosshair),
["crosshair", "default", "left_ptr"]
);
}
+218 -24
View File
@@ -1,10 +1,17 @@
//! Session-owned services. Never grant shell/effects privileges to daemons.
use std::cell::RefCell;
use std::collections::HashMap;
use std::process::Child;
use std::rc::Rc;
use std::time::{Duration, Instant};
use crate::Ferese;
use smithay::reexports::calloop::generic::Generic;
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use smithay::reexports::calloop::{Interest, Mode, PostAction, RegistrationToken};
use crate::config::DaemonConfig;
use crate::private_client::ClientCapabilities;
use crate::process::{pidfd, spawn_client, terminate_child};
use crate::{Ferese, SessionPolicy};
struct Service {
config: DaemonConfig,
@@ -13,13 +20,111 @@ struct Service {
finished: bool,
}
pub(crate) struct Runner(Vec<Service>);
pub(crate) struct Runner(
Vec<Service>,
HashMap<u32, RegistrationToken>,
Option<(RegistrationToken, Instant)>,
);
impl Runner {
pub fn reconcile(&mut self, configs: &[DaemonConfig], nested: bool) {
pub fn start(state: &mut Ferese) -> Rc<RefCell<Self>> {
let runner = Rc::new(RefCell::new(Self::new(
&state.autostart,
state.session_environment.policy,
)));
Self::refresh(&runner, state);
runner
}
pub fn refresh(owner: &Rc<RefCell<Self>>, state: &mut Ferese) {
let mut runner = owner.borrow_mut();
runner.tick(state);
let pids = runner
.0
.iter()
.filter_map(|service| service.child.as_ref().map(Child::id))
.collect::<Vec<_>>();
runner.1.retain(|pid, token| {
if pids.contains(pid) {
true
} else {
state.loop_handle.remove(*token);
false
}
});
let mut watch_retry = None;
for pid in pids {
if runner.1.contains_key(&pid) {
continue;
}
let weak = Rc::downgrade(owner);
let source = pidfd(pid).map(|fd| Generic::new(fd, Interest::READ, Mode::Level));
let result = source.and_then(|source| {
state
.loop_handle
.insert_source(source, move |_, _, state| {
if let Some(owner) = weak.upgrade() {
owner.borrow_mut().1.remove(&pid);
Self::refresh(&owner, state);
}
Ok(PostAction::Remove)
})
.map_err(std::io::Error::other)
});
match result {
Ok(token) => {
runner.1.insert(pid, token);
}
Err(error) => {
tracing::warn!(%error, pid, "could not watch daemon exit; retrying registration");
watch_retry = Some(Instant::now() + Duration::from_secs(5));
}
}
}
let deadline = runner
.0
.iter()
.filter(|service| service.child.is_none() && !service.finished)
.map(|service| service.next_start)
.chain(watch_retry)
.min();
if runner.2.as_ref().map(|(_, deadline)| *deadline) == deadline {
return;
}
if let Some((token, _)) = runner.2.take() {
state.loop_handle.remove(token);
}
if let Some(deadline) = deadline {
let weak = Rc::downgrade(owner);
match state.loop_handle.insert_source(
Timer::from_deadline(deadline),
move |_, _, state| {
if let Some(owner) = weak.upgrade() {
owner.borrow_mut().2 = None;
Self::refresh(&owner, state);
}
TimeoutAction::Drop
},
) {
Ok(token) => runner.2 = Some((token, deadline)),
Err(error) => tracing::warn!(%error, "could not arm daemon restart deadline"),
}
}
}
pub fn reconcile(&mut self, configs: &[DaemonConfig], policy: SessionPolicy) {
let mut previous = std::mem::take(&mut self.0);
for config in configs.iter().filter(|c| c.enabled && (!nested || c.nested)) {
if self.0.iter().any(|service| service.config.command == config.command) {
for config in configs
.iter()
.filter(|c| c.enabled && policy.allows_autostart(c.nested))
{
if self
.0
.iter()
.any(|service| service.config.command == config.command)
{
continue;
}
if let Some(index) = previous
@@ -45,16 +150,16 @@ impl Runner {
// TERM/grace/reaping must not pause the compositor's event loop.
for mut removed in previous {
if let Some(mut child) = removed.child.take() {
std::thread::spawn(move || crate::terminate_child(&mut child));
std::thread::spawn(move || terminate_child(&mut child));
}
}
}
pub fn new(configs: &[DaemonConfig], nested: bool) -> Self {
pub fn new(configs: &[DaemonConfig], policy: SessionPolicy) -> Self {
Self(
configs
.iter()
.filter(|config| config.enabled && (!nested || config.nested))
.filter(|config| config.enabled && policy.allows_autostart(config.nested))
.map(|config| Service {
config: config.clone(),
child: None,
@@ -62,11 +167,13 @@ impl Runner {
finished: false,
})
.collect(),
HashMap::new(),
None,
)
}
pub fn tick(&mut self, state: &mut Ferese) {
self.reconcile(&state.autostart, state.direct_backend.is_none());
self.reconcile(&state.autostart, state.session_environment.policy);
let now = Instant::now();
for service in &mut self.0 {
if let Some(child) = &mut service.child {
@@ -85,9 +192,9 @@ impl Runner {
}
}
if !service.finished && now >= service.next_start {
service.child = crate::spawn_client(
service.child = spawn_client(
state,
service.config.command.iter().map(Into::into).collect(),
service.config.command.iter(),
ClientCapabilities::default(),
);
service.next_start = now + Duration::from_secs(5);
@@ -101,7 +208,7 @@ impl Runner {
pub fn stop(&mut self) {
for service in &mut self.0 {
if let Some(mut child) = service.child.take() {
crate::terminate_child(&mut child);
terminate_child(&mut child);
}
service.finished = true;
}
@@ -117,6 +224,34 @@ impl Drop for Runner {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pidfd_wakes_the_loop_once_and_the_child_is_reaped() {
let mut child = std::process::Command::new("/bin/sh")
.args(["-c", "exit 7"])
.spawn()
.unwrap();
let fd = pidfd(child.id()).unwrap();
let mut events = smithay::reexports::calloop::EventLoop::<usize>::try_new().unwrap();
events
.handle()
.insert_source(
Generic::new(fd, Interest::READ, Mode::Level),
move |_, _, count| {
assert_eq!(child.wait().unwrap().code(), Some(7));
*count += 1;
Ok(PostAction::Remove)
},
)
.unwrap();
let mut count = 0;
events
.dispatch(Some(Duration::from_secs(2)), &mut count)
.unwrap();
assert_eq!(count, 1);
events.dispatch(Some(Duration::ZERO), &mut count).unwrap();
assert_eq!(count, 1);
}
#[test]
fn live_reconcile_keeps_existing_process_and_changes_policy_without_restart() {
let mut config = DaemonConfig {
@@ -125,21 +260,30 @@ mod tests {
restart: false,
nested: false,
};
let mut runner = Runner::new(&[config.clone()], false);
runner.0[0].child = Some(std::process::Command::new("/bin/sleep").arg("30").spawn().unwrap());
let mut runner = Runner::new(&[config.clone()], SessionPolicy::Desktop);
runner.0[0].child = Some(
std::process::Command::new("/bin/sleep")
.arg("30")
.spawn()
.unwrap(),
);
let pid = runner.0[0].child.as_ref().unwrap().id();
config.restart = true;
runner.reconcile(&[config.clone()], false);
runner.reconcile(&[config.clone()], SessionPolicy::Desktop);
assert_eq!(runner.0[0].child.as_ref().unwrap().id(), pid);
assert!(runner.0[0].config.restart);
runner.reconcile(&[config.clone(), config.clone()], false);
runner.reconcile(&[config.clone(), config.clone()], SessionPolicy::Desktop);
assert_eq!(runner.0.len(), 1);
config.enabled = false;
runner.reconcile(&[config], false);
runner.reconcile(&[config], SessionPolicy::Desktop);
assert!(runner.0.is_empty());
let deadline = Instant::now() + Duration::from_secs(2);
// SAFETY: kill with signal zero only probes an integer PID and has no pointer arguments.
while unsafe { libc::kill(pid as i32, 0) } == 0 {
assert!(Instant::now() < deadline, "removed service was not stopped/reaped");
assert!(
Instant::now() < deadline,
"removed service was not stopped/reaped"
);
std::thread::sleep(Duration::from_millis(10));
}
}
@@ -152,22 +296,72 @@ mod tests {
restart: true,
nested: false,
}];
assert!(Runner::new(&configs, true).0.is_empty());
let mut direct = Runner::new(&configs, false);
assert!(Runner::new(&configs, SessionPolicy::Embedded).0.is_empty());
let mut direct = Runner::new(&configs, SessionPolicy::Desktop);
assert_eq!(direct.0.len(), 1);
direct.stop();
assert!(direct.0[0].finished);
}
#[test]
fn disabled_login_items_never_start_on_either_backend() {
fn disabled_login_items_never_start_under_either_session_policy() {
let configs = vec![DaemonConfig {
command: vec!["not-executed".into()],
enabled: false,
restart: true,
nested: true,
}];
assert!(Runner::new(&configs, true).0.is_empty());
assert!(Runner::new(&configs, false).0.is_empty());
assert!(Runner::new(&configs, SessionPolicy::Embedded).0.is_empty());
assert!(Runner::new(&configs, SessionPolicy::Desktop).0.is_empty());
}
#[test]
fn embedded_autostart_requires_opt_in_on_start_and_reload() {
let regular = DaemonConfig {
command: vec!["regular".into()],
enabled: true,
restart: false,
nested: false,
};
let embedded = DaemonConfig {
command: vec!["embedded".into()],
nested: true,
..regular.clone()
};
let configs = [regular, embedded.clone()];
let mut runner = Runner::new(&configs, SessionPolicy::Embedded);
assert_eq!(runner.0.len(), 1);
assert_eq!(runner.0[0].config, embedded);
runner.reconcile(&configs, SessionPolicy::Desktop);
assert_eq!(runner.0.len(), 2);
runner.reconcile(&configs, SessionPolicy::Embedded);
assert_eq!(runner.0.len(), 1);
assert_eq!(runner.0[0].config, embedded);
}
#[test]
fn desktop_autostart_does_not_depend_on_a_live_drm_backend() {
if !crate::startup_tests::private_runtime(
"daemon::tests::desktop_autostart_does_not_depend_on_a_live_drm_backend",
) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
assert!(state.direct_backend.is_none());
assert_eq!(state.session_environment.policy, SessionPolicy::Desktop);
state.autostart = vec![DaemonConfig {
command: vec!["/bin/sleep".into(), "30".into()],
enabled: true,
restart: false,
nested: false,
}];
let runner = Runner::start(&mut state);
assert_eq!(runner.borrow().0.len(), 1);
assert!(runner.borrow().0[0].child.is_some());
runner.borrow_mut().stop();
state.session_environment.policy = SessionPolicy::Embedded;
Runner::refresh(&runner, &mut state);
assert!(runner.borrow().0.is_empty());
}
}
-170
View File
@@ -1,170 +0,0 @@
use std::time::Duration;
use ferese_layout::WindowId;
use crate::config::InactiveDimSettings;
pub(crate) fn target(
settings: InactiveDimSettings,
focused: Option<WindowId>,
window: WindowId,
overview: bool,
) -> f64 {
if settings.enabled && !overview && focused.is_some() && focused != Some(window) {
settings.amount
} else {
0.0
}
}
/// A bounded, interruption-safe opacity transition; it never overshoots.
#[derive(Clone, Debug)]
pub(crate) struct DimAnimation {
pub current: f64,
start: f64,
target: f64,
elapsed: f64,
}
impl DimAnimation {
pub(crate) fn is_animating(&self) -> bool {
self.current != self.target
}
pub(crate) fn predict(&mut self, delta: Duration, duration_ms: f64) {
self.advance(self.target, delta, duration_ms);
}
pub fn new(value: f64) -> Self {
Self {
current: value,
start: value,
target: value,
elapsed: 0.0,
}
}
pub fn advance(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool {
if self.target != target {
self.start = self.current;
self.target = target;
self.elapsed = 0.0;
}
if duration_ms == 0.0 {
self.current = target;
return false;
}
self.elapsed += delta.as_secs_f64() * 1000.0;
let progress = (self.elapsed / duration_ms).min(1.0);
if progress == 1.0 {
self.current = target;
return false;
}
let eased = progress * progress * (3.0 - 2.0 * progress);
self.current = self.start + (target - self.start) * eased;
self.current != target
}
/// The target changed at this tick, not at the beginning of a potentially
/// long idle interval. Do not spend that old elapsed time on a new fade.
pub fn advance_visual(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool {
self.advance(
target,
if self.target != target { Duration::ZERO } else { delta },
duration_ms,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn visual_focus_and_dimming_start_together_after_idle_and_reverse() {
let mut focus = DimAnimation::new(0.0);
let mut dim = DimAnimation::new(0.15);
assert!(focus.advance_visual(1.0, Duration::from_secs(30), 150.0));
assert!(dim.advance_visual(0.0, Duration::from_secs(30), 150.0));
assert_eq!(focus.current, 0.0);
assert_eq!(dim.current, 0.15);
focus.advance_visual(1.0, Duration::from_millis(75), 150.0);
dim.advance_visual(0.0, Duration::from_millis(75), 150.0);
assert!((focus.current + dim.current / 0.15 - 1.0).abs() < 1e-9);
let before = focus.current;
focus.advance_visual(0.0, Duration::from_millis(16), 150.0);
assert_eq!(focus.current, before);
assert!(!focus.advance_visual(0.0, Duration::from_millis(150), 150.0));
assert!(!focus.advance_visual(1.0, Duration::from_secs(30), 0.0));
assert_eq!(focus.current, 1.0);
}
#[test]
fn only_unfocused_windows_dim_and_overview_disables_it() {
let settings = InactiveDimSettings {
enabled: true,
amount: 0.15,
duration_ms: 150.0,
};
let left = WindowId(1);
let right = WindowId(2);
assert_eq!(target(settings, Some(right), left, false), 0.15);
assert_eq!(target(settings, Some(right), right, false), 0.0);
assert_eq!(target(settings, Some(right), left, true), 0.0);
assert_eq!(target(settings, None, left, false), 0.0);
assert_eq!(
target(
InactiveDimSettings {
enabled: false,
..settings
},
Some(right),
left,
false
),
0.0
);
assert_eq!(
target(
InactiveDimSettings {
amount: 0.0,
..settings
},
Some(right),
left,
false
),
0.0
);
}
#[test]
fn focus_transition_is_smooth_bounded_and_settles() {
let mut dim = DimAnimation::new(0.0);
assert!(dim.advance(0.15, Duration::from_millis(75), 150.0));
assert!((dim.current - 0.075).abs() < 1e-9);
assert!(!dim.advance(0.15, Duration::from_millis(75), 150.0));
assert_eq!(dim.current, 0.15);
assert!(!dim.advance(0.15, Duration::from_secs(10), 150.0));
}
#[test]
fn reversing_focus_starts_from_current_opacity() {
let mut dim = DimAnimation::new(0.0);
dim.advance(0.15, Duration::from_millis(75), 150.0);
let before = dim.current;
assert!(dim.advance(0.0, Duration::ZERO, 150.0));
assert_eq!(dim.current, before);
assert!(!dim.advance(0.0, Duration::from_millis(150), 150.0));
assert_eq!(dim.current, 0.0);
}
#[test]
fn zero_duration_snaps_for_reduced_motion() {
let mut dim = DimAnimation::new(0.15);
assert!(!dim.advance(0.0, Duration::ZERO, 0.0));
assert_eq!(dim.current, 0.0);
}
}
+152
View File
@@ -0,0 +1,152 @@
//! Visibility of client content on displayed outputs, independent of capture.
use std::collections::{HashMap, HashSet};
use smithay::backend::renderer::element::{
Id, RenderElementPresentationState, RenderElementState, RenderElementStates,
};
use smithay::output::Output;
#[derive(Default)]
struct OutputPresentation {
displayed: HashSet<Id>,
queued: Option<HashSet<Id>>,
}
#[derive(Default)]
pub(crate) struct DisplayPresentation {
outputs: HashMap<Output, OutputPresentation>,
}
pub(crate) fn is_visible(state: RenderElementState) -> bool {
state.visible_area > 0 && state.presentation_state != RenderElementPresentationState::Skipped
}
fn visible(states: &RenderElementStates) -> HashSet<Id> {
states
.states
.iter()
.filter(|&(_id, state)| is_visible(*state))
.map(|(id, _state)| id.clone())
.collect()
}
impl DisplayPresentation {
// Call only after a successful submission, never from an offscreen capture.
pub fn queued(&mut self, output: &Output, states: &RenderElementStates) {
self.outputs.entry(output.clone()).or_default().queued = Some(visible(states));
}
pub fn presented(&mut self, output: &Output) {
if let Some(record) = self.outputs.get_mut(output)
&& let Some(queued) = record.queued.take()
{
record.displayed = queued;
}
}
#[cfg(test)]
pub fn visible(&self, surface: &Id) -> bool {
self.outputs
.values()
.any(|record| record.displayed.contains(surface))
}
pub fn outputs_for<'a>(&'a self, surface: &'a Id) -> impl Iterator<Item = &'a Output> {
self.outputs
.iter()
.filter(|(_, record)| record.displayed.contains(surface))
.map(|(output, _)| output)
}
pub fn remove_output(&mut self, output: &Output) {
self.outputs.remove(output);
}
pub fn clear(&mut self) {
self.outputs.clear();
}
}
#[cfg(test)]
mod tests {
use smithay::backend::renderer::element::RenderElementState;
use smithay::output::{PhysicalProperties, Subpixel};
use super::*;
fn output(name: &str) -> Output {
Output::new(
name.into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
)
}
fn scene(id: &Id) -> RenderElementStates {
let mut states = RenderElementStates::default();
states.states.insert(
id.clone(),
RenderElementState {
needs_capture: false,
visible_area: 20,
presentation_state: RenderElementPresentationState::ZeroCopy,
},
);
states
}
#[test]
fn visibility_changes_only_when_a_submission_is_presented() {
let mut record = DisplayPresentation::default();
let output = output("a");
let id = Id::new();
record.queued(&output, &scene(&id));
assert!(!record.visible(&id));
record.presented(&output);
assert!(record.visible(&id));
record.presented(&output); // No new submission keeps the last displayed scene.
assert!(record.visible(&id));
record.queued(&output, &RenderElementStates::default());
assert!(record.visible(&id));
record.presented(&output);
assert!(!record.visible(&id));
}
#[test]
fn outputs_are_independent_and_queued_history_survives_until_presentation() {
let mut record = DisplayPresentation::default();
let a = output("a");
let b = output("b");
let id = Id::new();
for output in [&a, &b] {
record.queued(output, &scene(&id));
record.presented(output);
}
record.remove_output(&a);
assert!(record.visible(&id));
record.queued(&b, &scene(&id));
assert!(record.visible(&id));
record.presented(&b);
assert!(record.visible(&id));
record.clear();
assert!(!record.visible(&id));
}
#[test]
fn occluded_and_skipped_content_do_not_count_as_visible() {
let id = Id::new();
let mut states = scene(&id);
states.states.get_mut(&id).unwrap().visible_area = 0;
assert!(visible(&states).is_empty());
states.states.get_mut(&id).unwrap().visible_area = 20;
states.states.get_mut(&id).unwrap().presentation_state =
RenderElementPresentationState::Skipped;
assert!(visible(&states).is_empty());
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Allocation hints retain the import global's renderer as a composition fallback.
use std::collections::HashMap;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::wayland::compositor::with_states;
use smithay::wayland::dmabuf::{DmabufFeedback, DmabufGlobal, SurfaceDmabufFeedbackState};
struct Surface {
global: DmabufGlobal,
fallback: DmabufFeedback,
current: DmabufFeedback,
}
#[derive(Default)]
pub(crate) struct FeedbackManager {
globals: HashMap<DmabufGlobal, DmabufFeedback>,
surfaces: HashMap<WlSurface, Surface>,
}
impl FeedbackManager {
pub fn register(&mut self, global: DmabufGlobal, render: DmabufFeedback) {
self.globals.insert(global, render);
}
pub fn new_surface(
&mut self,
surface: &WlSurface,
global: DmabufGlobal,
) -> Option<DmabufFeedback> {
let fallback = self.globals.get(&global)?.clone();
self.surfaces.insert(
surface.clone(),
Surface {
global,
fallback: fallback.clone(),
current: fallback.clone(),
},
);
Some(fallback)
}
pub fn requested(&self) -> impl Iterator<Item = (&WlSurface, DmabufGlobal)> {
self.surfaces
.iter()
.map(|(surface, record)| (surface, record.global))
}
pub fn refresh(&mut self, preferred: &HashMap<WlSurface, DmabufFeedback>) {
for (surface, record) in &mut self.surfaces {
let feedback = if self.globals.contains_key(&record.global) {
preferred.get(surface).unwrap_or(&record.fallback)
} else {
&record.fallback
};
if &record.current == feedback {
continue;
}
// Avoid locking surface state on every dispatch when its allocation
// hints are unchanged. Smithay still owns subscriptions and messages.
with_states(surface, |states| {
if let Some(state) = SurfaceDmabufFeedbackState::from_states(states) {
state.set_feedback(feedback);
record.current = feedback.clone();
}
});
}
}
pub fn remove(&mut self, global: DmabufGlobal) {
self.globals.remove(&global);
for (surface, record) in self
.surfaces
.iter_mut()
.filter(|(_, record)| record.global == global)
{
// V4 requires a main-device tranche. Retain last-known render
// capabilities, withdraw scanout hints and retire the global. Bound
// resources cannot import through the retired global or another GPU.
with_states(surface, |states| {
if let Some(state) = SurfaceDmabufFeedbackState::from_states(states) {
state.set_feedback(&record.fallback);
record.current = record.fallback.clone();
}
});
}
}
pub fn destroyed(&mut self, surface: &WlSurface) {
self.surfaces.remove(surface);
}
}
#[cfg(test)]
mod tests;
+235
View File
@@ -0,0 +1,235 @@
//! Real v4 feedback subscriptions over a private Wayland socket; no DRM/EGL.
use std::io::Read;
use std::os::unix::net::UnixStream;
use smithay::backend::allocator::{Format, Fourcc, Modifier};
use smithay::output::{Mode, Output, PhysicalProperties, Subpixel};
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_feedback_v1::TrancheFlags;
use smithay::reexports::wayland_server::Display;
use smithay::wayland::dmabuf::DmabufFeedbackBuilder;
use super::*;
use crate::Ferese;
use crate::state::{dispatch_test_client as dispatch, test_client_request as request, test_window};
fn messages(wire: &mut UnixStream) -> Vec<(u32, u32, Vec<u32>)> {
wire.set_nonblocking(true).unwrap();
let mut bytes = Vec::new();
let _ = wire.read_to_end(&mut bytes);
wire.set_nonblocking(false).unwrap();
let mut offset = 0;
let mut result = Vec::new();
while offset < bytes.len() {
let object = u32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap());
let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap());
let size = (header >> 16) as usize;
result.push((
object,
header & 0xffff,
bytes[offset + 8..offset + size]
.chunks_exact(4)
.map(|word| u32::from_ne_bytes(word.try_into().unwrap()))
.collect(),
));
offset += size;
}
result
}
fn flags(events: &[(u32, u32, Vec<u32>)], object: u32) -> Vec<u32> {
events
.iter()
.filter(|(id, opcode, _)| *id == object && *opcode == 6)
.map(|(_, _, args)| args[0])
.collect()
}
fn done(events: &[(u32, u32, Vec<u32>)], object: u32) -> usize {
events
.iter()
.filter(|(id, opcode, _)| *id == object && *opcode == 0)
.count()
}
#[test]
fn subscriptions_update_deduplicate_and_retire_device_preferences() {
if !crate::startup_tests::private_runtime(
"dmabuf_feedback::tests::subscriptions_update_deduplicate_and_retire_device_preferences",
) {
return;
}
let mut events = EventLoop::try_new().unwrap();
let mut config = crate::config::Config::default().runtime_config().unwrap();
config.wallpaper.path = None;
let mut state = Ferese::new(&mut events, Display::new().unwrap(), config).unwrap();
let output = Output::new(
"feedback-test".into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: (100, 100).into(),
refresh: 60_000,
}),
None,
None,
None,
);
state.space.map_output(&output, (0, 0));
state.register_output(&output, "feedback-test".into());
let f = Format {
code: Fourcc::Argb8888,
modifier: Modifier::Linear,
};
let fallback = DmabufFeedbackBuilder::new(10, [f]).build().unwrap();
let preferred = DmabufFeedbackBuilder::new(10, [f])
.add_preference_tranche(20, TrancheFlags::Scanout, [f], 4..=6)
.build()
.unwrap();
let global = state
.dmabuf_state
.create_global_with_default_feedback::<Ferese>(&state.display_handle, &fallback);
state.dmabuf_feedback.register(global, fallback.clone());
let (window, mut wire) = test_window(&mut state, &mut events, 0xffabcdef);
let surface = window.toplevel().unwrap().wl_surface().clone();
request(&mut wire, 1, 1, &[7], None); // wl_display.get_registry
dispatch(&mut events, &mut state);
let registry = messages(&mut wire);
let dmabuf_name = registry
.iter()
.find_map(|(object, opcode, args)| {
if *object != 7 || *opcode != 0 {
return None;
}
let bytes = args[2..]
.iter()
.flat_map(|word| word.to_ne_bytes())
.collect::<Vec<_>>();
bytes
.starts_with(b"zwp_linux_dmabuf_v1\0")
.then_some(args[0])
})
.expect("DMA-BUF v4 global");
let name = b"zwp_linux_dmabuf_v1\0";
let mut padded = name.to_vec();
padded.resize(name.len().next_multiple_of(4), 0);
let bind = [dmabuf_name, name.len() as u32]
.into_iter()
.chain(
padded
.chunks_exact(4)
.map(|word| u32::from_ne_bytes(word.try_into().unwrap())),
)
.chain([4, 8])
.collect::<Vec<_>>();
request(&mut wire, 7, 0, &bind, None);
request(&mut wire, 8, 3, &[9, 2], None); // get_surface_feedback
dispatch(&mut events, &mut state);
let initial = messages(&mut wire);
assert_eq!(flags(&initial, 9), [0]);
assert_eq!(done(&initial, 9), 1);
assert_eq!(state.dmabuf_feedback.requested().count(), 1);
let main = initial
.iter()
.find(|(id, opcode, _)| *id == 9 && *opcode == 2)
.unwrap();
assert_eq!(main.2[0] as usize, std::mem::size_of::<libc::dev_t>());
assert_eq!(main.2[1], 10, "main device remains the renderer");
let selected = HashMap::from([(surface.clone(), preferred.clone())]);
state.dmabuf_feedback.refresh(&selected);
state.display_handle.flush_clients().unwrap();
let changed = messages(&mut wire);
assert_eq!(flags(&changed, 9), [TrancheFlags::Scanout.bits(), 0]);
assert_eq!(done(&changed, 9), 1);
let target = changed
.iter()
.find(|(id, opcode, _)| *id == 9 && *opcode == 4)
.unwrap();
assert_eq!(
target.2[1], 20,
"scanout tranche targets the primary DRM device"
);
state.dmabuf_feedback.refresh(&selected);
state.display_handle.flush_clients().unwrap();
assert!(
messages(&mut wire).is_empty(),
"unchanged feedback must not be published again"
);
request(&mut wire, 8, 3, &[10, 2], None);
dispatch(&mut events, &mut state);
let second = messages(&mut wire);
assert_eq!(flags(&second, 10), [TrancheFlags::Scanout.bits(), 0]);
state.dmabuf_feedback.refresh(&HashMap::new()); // migration/ineligibility fallback
state.display_handle.flush_clients().unwrap();
let fallback_events = messages(&mut wire);
assert_eq!(flags(&fallback_events, 9), [0]);
assert_eq!(flags(&fallback_events, 10), [0]);
assert_eq!(done(&fallback_events, 9), 1);
assert_eq!(done(&fallback_events, 10), 1);
request(&mut wire, 9, 0, &[], None);
request(&mut wire, 10, 0, &[], None);
dispatch(&mut events, &mut state);
messages(&mut wire);
request(&mut wire, 8, 3, &[11, 2], None);
request(&mut wire, 8, 2, &[12], None); // get_default_feedback
dispatch(&mut events, &mut state);
let renewed = messages(&mut wire);
assert_eq!(
flags(&renewed, 11),
[0],
"renewal starts with safe render feedback"
);
assert_eq!(flags(&renewed, 12), [0]);
state.dmabuf_feedback.refresh(&selected);
state.display_handle.flush_clients().unwrap();
let promoted = messages(&mut wire);
assert_eq!(flags(&promoted, 11), [TrancheFlags::Scanout.bits(), 0]);
state.dmabuf_feedback.remove(global);
state
.dmabuf_state
.disable_global::<Ferese>(&state.display_handle, &global);
state.display_handle.flush_clients().unwrap();
let retired = messages(&mut wire);
assert_eq!(done(&retired, 11), 1);
assert_eq!(
flags(&retired, 11),
[0],
"retirement keeps a valid main-device render tranche"
);
assert_eq!(
done(&retired, 12),
0,
"default renderer capabilities remain unchanged on existing bindings"
);
assert!(
retired
.iter()
.any(|(id, opcode, args)| *id == 7 && *opcode == 1 && args[0] == dmabuf_name)
);
state.dmabuf_feedback.refresh(&selected); // A stale choice cannot resurrect a retired preference.
state.display_handle.flush_clients().unwrap();
assert!(messages(&mut wire).is_empty());
assert!(
state
.dmabuf_feedback
.new_surface(&surface, global)
.is_none()
);
drop(wire);
dispatch(&mut events, &mut state);
assert_eq!(
state.dmabuf_feedback.requested().count(),
0,
"surface destruction must remove subscriptions without a refresh scan"
);
}
+329
View File
@@ -0,0 +1,329 @@
use std::collections::{HashSet, VecDeque};
use std::time::{Duration, Instant};
use smithay::backend::allocator::dmabuf::Dmabuf;
use smithay::backend::drm::DrmNode;
use smithay::backend::renderer::ImportDma;
use smithay::reexports::calloop::RegistrationToken;
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use smithay::reexports::wayland_server::backend::ClientId;
use smithay::wayland::dmabuf::{DmabufGlobal, ImportNotifier};
use crate::Ferese;
const MAX_PENDING: usize = 64;
const MAX_PER_CLIENT: usize = 16;
const MAX_WAIT: Duration = Duration::from_secs(5);
struct Pending<K, C, T> {
owner: K,
client: C,
deadline: Instant,
value: T,
}
struct Queue<K, C, T> {
pending: VecDeque<Pending<K, C, T>>,
}
impl<K, C, T> Default for Queue<K, C, T> {
fn default() -> Self {
Self {
pending: VecDeque::new(),
}
}
}
impl<K: Eq, C: Eq, T> Queue<K, C, T> {
fn enqueue(&mut self, owner: K, client: C, value: T, now: Instant) -> Result<(), T> {
if self.pending.len() >= MAX_PENDING
|| self
.pending
.iter()
.filter(|entry| entry.client == client)
.count()
>= MAX_PER_CLIENT
{
return Err(value);
}
self.pending.push_back(Pending {
owner,
client,
value,
deadline: now + MAX_WAIT,
});
Ok(())
}
fn process(&mut self, owner: &K, mut complete: impl FnMut(T)) {
self.remove_if(|entry| &entry.owner == owner, |entry| complete(entry.value));
}
fn remove_if(
&mut self,
mut remove: impl FnMut(&Pending<K, C, T>) -> bool,
mut complete: impl FnMut(Pending<K, C, T>),
) {
for _ in 0..self.pending.len() {
let entry = self
.pending
.pop_front()
.expect("the bounded drain removes at most the original queue length");
if remove(&entry) {
complete(entry);
} else {
self.pending.push_back(entry);
}
}
}
fn deadline(&self) -> Option<Instant> {
self.pending.front().map(|entry| entry.deadline)
}
}
struct Request {
buffer: Dmabuf,
notifier: ImportNotifier,
}
#[derive(Default)]
pub(crate) struct ImportManager {
globals: HashSet<DmabufGlobal>,
queue: Queue<DmabufGlobal, ClientId, Request>,
timer: Option<RegistrationToken>,
}
impl Drop for ImportManager {
fn drop(&mut self) {
self.queue
.remove_if(|_| true, |entry| entry.value.notifier.failed());
}
}
impl ImportManager {
pub(crate) fn register(&mut self, global: DmabufGlobal) {
self.globals.insert(global);
}
pub(crate) fn remove(&mut self, global: DmabufGlobal) {
self.globals.remove(&global);
self.queue
.process(&global, |request| request.notifier.failed());
}
pub(crate) fn enqueue(
&mut self,
global: DmabufGlobal,
buffer: Dmabuf,
notifier: ImportNotifier,
now: Instant,
) {
let Some(client) = notifier.client() else {
notifier.failed();
return;
};
if !self.globals.contains(&global) {
notifier.failed();
return;
}
if let Err(request) =
self.queue
.enqueue(global, client.id(), Request { buffer, notifier }, now)
{
request.notifier.failed();
}
}
pub(crate) fn process<R: ImportDma>(
&mut self,
global: DmabufGlobal,
renderer: &mut R,
node: Option<DrmNode>,
) {
self.process_with(global, node, |buffer| {
renderer.import_dmabuf(buffer, None).is_ok()
});
}
pub(crate) fn process_with(
&mut self,
global: DmabufGlobal,
node: Option<DrmNode>,
mut import: impl FnMut(&Dmabuf) -> bool,
) {
self.queue.process(&global, |request| {
if request.notifier.client().is_none() {
request.notifier.failed();
return;
}
if !import(&request.buffer) {
request.notifier.failed();
return;
}
if let Some(node) = node {
request.buffer.set_node(node);
}
if let Err(error) = request.notifier.successful::<Ferese>() {
tracing::debug!(?error, "dma-buf client disappeared before import completed");
}
});
}
pub(crate) fn expire(&mut self, now: Instant) {
self.queue.remove_if(
|entry| entry.deadline <= now || entry.value.notifier.client().is_none(),
|entry| entry.value.notifier.failed(),
);
}
}
impl Ferese {
pub(crate) fn process_pending_dmabuf_imports(&mut self) {
self.dmabuf_imports.expire(Instant::now());
if self.dmabuf_imports.queue.deadline().is_some() {
if let Some(backend) = self.direct_backend.as_mut() {
backend.process_dmabuf_imports(&mut self.dmabuf_imports);
} else if let Some(backend) = self.nested_backend.as_ref() {
let mut backend = backend.borrow_mut();
// Nested Ferese publishes exactly one DMA-BUF global.
if let Some(global) = self.dmabuf_imports.globals.iter().next().copied() {
self.dmabuf_imports
.process(global, backend.renderer(), None);
}
}
}
if let Some(deadline) = self.dmabuf_imports.queue.deadline() {
if self.dmabuf_imports.timer.is_none() {
match self.loop_handle.insert_source(
Timer::from_deadline(deadline),
|_, _, state| {
state.dmabuf_imports.expire(Instant::now());
if let Some(deadline) = state.dmabuf_imports.queue.deadline() {
TimeoutAction::ToInstant(deadline)
} else {
state.dmabuf_imports.timer = None;
TimeoutAction::Drop
}
},
) {
Ok(token) => self.dmabuf_imports.timer = Some(token),
Err(error) => {
tracing::warn!(%error, "could not schedule DMA-BUF import deadline");
self.dmabuf_imports
.queue
.remove_if(|_| true, |entry| entry.value.notifier.failed());
}
}
}
} else if let Some(token) = self.dmabuf_imports.timer.take() {
self.loop_handle.remove(token);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::fd::AsRawFd;
#[test]
fn renderer_order_cannot_consume_another_globals_requests() {
for order in [[1, 2], [2, 1]] {
let now = Instant::now();
let mut queue = Queue::default();
queue.enqueue(2, 1, "B-only", now).unwrap();
let mut results = Vec::new();
for owner in order {
queue.process(&owner, |buffer| {
assert_eq!(owner, 2);
assert_eq!(buffer, "B-only");
results.push(true);
});
}
assert_eq!(results, [true]);
assert!(queue.pending.is_empty());
}
}
#[test]
fn unavailable_imports_are_bounded_and_recover_before_the_deadline() {
let now = Instant::now();
let mut queue = Queue::default();
for index in 0..MAX_PENDING {
queue
.enqueue(1, index / MAX_PER_CLIENT, index, now)
.unwrap();
}
assert_eq!(queue.enqueue(1, 99, 100, now), Err(100));
let mut completed = Vec::new();
queue.remove_if(
|entry| entry.deadline <= now + MAX_WAIT / 2,
|_| panic!("expired early"),
);
queue.process(&1, |value| completed.push(value));
assert_eq!(completed, (0..MAX_PENDING).collect::<Vec<_>>());
assert!(queue.deadline().is_none());
}
#[test]
fn per_client_admission_leaves_room_for_other_clients() {
let now = Instant::now();
let mut queue = Queue::default();
for index in 0..MAX_PER_CLIENT {
queue.enqueue(1, 1, index, now).unwrap();
}
assert_eq!(queue.enqueue(2, 1, 100, now), Err(100));
queue.enqueue(2, 2, 101, now).unwrap();
}
#[test]
fn expiry_and_device_removal_release_resources_once_without_touching_other_owners() {
if !crate::startup_tests::private_runtime(
"dmabuf_imports::tests::expiry_and_device_removal_release_resources_once_without_touching_other_owners",
) {
return;
}
let now = Instant::now();
let mut queue = Queue::default();
let removed = tempfile::tempfile().unwrap();
let expired = tempfile::tempfile().unwrap();
let fd_removed = removed.as_raw_fd();
let fd_expired = expired.as_raw_fd();
queue.enqueue(1, 1, removed, now).unwrap();
queue
.enqueue(2, 1, expired, now + Duration::from_secs(1))
.unwrap();
let mut failures = 0;
queue.process(&1, |_| failures += 1);
// SAFETY: F_GETFD observes descriptor validity without modifying it.
assert_eq!(unsafe { libc::fcntl(fd_removed, libc::F_GETFD) }, -1);
// SAFETY: F_GETFD observes an integer descriptor without modifying it or dereferencing
// caller memory.
assert_ne!(unsafe { libc::fcntl(fd_expired, libc::F_GETFD) }, -1);
queue.remove_if(
|entry| entry.deadline <= now + MAX_WAIT,
|_| panic!("expired early"),
);
queue.remove_if(
|entry| entry.deadline <= now + MAX_WAIT + Duration::from_secs(1),
|_| failures += 1,
);
// SAFETY: F_GETFD observes an integer descriptor without modifying it or dereferencing
// caller memory.
assert_eq!(unsafe { libc::fcntl(fd_expired, libc::F_GETFD) }, -1);
assert_eq!(failures, 2);
assert!(queue.deadline().is_none());
}
}
+472 -42
View File
@@ -6,7 +6,9 @@ use ferese_protocols::effects::v1::server::ferese_surface_effects_v1::FereseSurf
use ferese_protocols::effects::v1::server::{ferese_effects_manager_v1, ferese_surface_effects_v1};
use ferese_protocols::material::v1::server::ferese_material_manager_v1::FereseMaterialManagerV1;
use ferese_protocols::material::v1::server::ferese_surface_material_v1::FereseSurfaceMaterialV1;
use ferese_protocols::material::v1::server::{ferese_material_manager_v1, ferese_surface_material_v1};
use ferese_protocols::material::v1::server::{
ferese_material_manager_v1, ferese_surface_material_v1,
};
use smithay::reexports::wayland_server::backend::ClientId;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::reexports::wayland_server::{
@@ -37,10 +39,16 @@ pub(crate) struct ResolvedMaterial {
pub shadow: [f64; 3],
}
pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle, opacity: f32) -> ResolvedMaterial {
pub(crate) fn resolve_material(
role: SemanticRole,
style: MaterialStyle,
opacity: f32,
) -> ResolvedMaterial {
let shadow = match role {
SemanticRole::Panel => [1.0, 5.0, 0.04],
SemanticRole::PanelElevated | SemanticRole::Popover | SemanticRole::Menu => [2.0, 8.0, 0.07],
SemanticRole::PanelElevated | SemanticRole::Popover | SemanticRole::Menu => {
[2.0, 8.0, 0.07]
}
SemanticRole::Hud => [1.0, 4.0, 0.04],
SemanticRole::Notification | SemanticRole::Modal => [3.0, 10.0, 0.09],
};
@@ -48,7 +56,26 @@ pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle, opacity
MaterialStyle::Solid => 1.0,
MaterialStyle::Translucent => opacity.clamp(0.0, 1.0),
};
ResolvedMaterial { style, opacity, shadow }
ResolvedMaterial {
style,
opacity,
shadow,
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct PanelPresentation {
pub shapes: std::sync::Arc<[ferese_panel::scene::Primitive]>,
pub fusion: f64,
}
#[derive(Debug)]
struct PendingPresentation {
panel: Option<PanelPresentation>,
role: SemanticRole,
regions: Vec<[f64; 5]>,
opacity: u32,
region_opacities: Vec<u32>,
}
#[derive(Debug)]
@@ -58,6 +85,9 @@ struct SurfaceEffectsState {
role: Mutex<Option<SemanticRole>>,
regions: Mutex<Option<Vec<[f64; 5]>>>,
opacity: Mutex<u32>,
pending: Mutex<Option<PendingPresentation>>,
panel: Mutex<Option<PanelPresentation>>,
region_opacities: Mutex<Vec<u32>>,
presentation_supported: AtomicBool,
dismissing: AtomicBool,
}
@@ -70,13 +100,61 @@ impl SurfaceEffectsState {
role: Mutex::new(None),
regions: Mutex::new(None),
opacity: Mutex::new(1000),
pending: Mutex::new(None),
panel: Mutex::new(None),
region_opacities: Mutex::new(Vec::new()),
presentation_supported: AtomicBool::new(false),
dismissing: AtomicBool::new(false),
}
}
fn commit_presentation(&self) {
let Some(pending) = self
.pending
.lock()
.expect("surface effect state must not be used after a mutator panics")
.take()
else {
return;
};
*self
.role
.lock()
.expect("surface effect state must not be used after a mutator panics") =
Some(pending.role);
*self
.panel
.lock()
.expect("surface effect state must not be used after a mutator panics") = pending.panel;
*self
.regions
.lock()
.expect("surface effect state must not be used after a mutator panics") =
Some(pending.regions);
*self
.region_opacities
.lock()
.expect("surface effect state must not be used after a mutator panics") =
pending.region_opacities;
if !self.dismissing.load(Ordering::Acquire) {
*self
.opacity
.lock()
.expect("surface effect state must not be used after a mutator panics") =
pending.opacity;
}
self.generation.fetch_add(1, Ordering::Release);
}
fn set_role(&self, role: Option<SemanticRole>) -> bool {
let mut current = self.role.lock().unwrap();
self.pending
.lock()
.expect("surface effect state must not be used after a mutator panics")
.take();
let mut current = self
.role
.lock()
.expect("surface effect state must not be used after a mutator panics");
if *current != role {
*current = role;
self.generation.fetch_add(1, Ordering::Release);
@@ -96,12 +174,16 @@ impl SurfaceEffectsUserData {
}
fn surface(&self) -> Option<WlSurface> {
self.0.lock().unwrap().upgrade().ok()
self.0
.lock()
.expect("surface effect state must not be used after a mutator panics")
.upgrade()
.ok()
}
}
pub(crate) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseEffectsManagerV1, _>(3, ());
display.create_global::<Ferese, FereseEffectsManagerV1, _>(6, ());
display.create_global::<Ferese, FereseMaterialManagerV1, _>(1, ());
}
@@ -131,8 +213,13 @@ impl Dispatch<FereseMaterialManagerV1, ()> for Ferese {
match request {
ferese_material_manager_v1::Request::GetSurfaceMaterial { id, surface } => {
let attached = with_states(&surface, |states| {
states.data_map.insert_if_missing_threadsafe(SurfaceEffectsState::new);
let effects = states.data_map.get::<SurfaceEffectsState>().unwrap();
states
.data_map
.insert_if_missing_threadsafe(SurfaceEffectsState::new);
let effects = states
.data_map
.get::<SurfaceEffectsState>()
.expect("effects state was inserted before this access");
effects.attached.swap(true, Ordering::AcqRel)
});
if attached {
@@ -145,7 +232,7 @@ impl Dispatch<FereseMaterialManagerV1, ()> for Ferese {
let material = data_init.init(id, SurfaceEffectsUserData::new(surface.clone()));
set_surface_role(&surface, Some(SemanticRole::Modal));
material.ready();
crate::backends::direct::render_all(state);
crate::backends::direct::render_surface(state, &surface);
}
ferese_material_manager_v1::Request::Destroy => {}
_ => unreachable!(),
@@ -167,7 +254,7 @@ impl Dispatch<FereseSurfaceMaterialV1, SurfaceEffectsUserData> for Ferese {
&& let Some(surface) = data.surface()
&& detach(&surface)
{
crate::backends::direct::render_all(state);
crate::backends::direct::render_surface(state, &surface);
}
}
@@ -183,6 +270,14 @@ impl Dispatch<FereseSurfaceMaterialV1, SurfaceEffectsUserData> for Ferese {
}
}
pub(crate) fn commit_presentation(surface: &WlSurface) {
with_states(surface, |states| {
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
effects.commit_presentation();
}
});
}
pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[f64; 5]>> {
with_states(surface, |states| {
states
@@ -190,13 +285,86 @@ pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[f64; 5]>> {
.get::<SurfaceEffectsState>()?
.regions
.lock()
.unwrap()
.expect("surface effect state must not be used after a mutator panics")
.clone()
})
}
pub(crate) fn surface_panel_presentation(surface: &WlSurface) -> Option<PanelPresentation> {
with_states(surface, |states| {
states
.data_map
.get::<SurfaceEffectsState>()?
.panel
.lock()
.expect("surface effect state must not be used after a mutator panics")
.clone()
})
}
fn decode_panel_shapes(bytes: &[u8], fusion: f64) -> Option<PanelPresentation> {
if !bytes.len().is_multiple_of(40)
|| bytes.len() > ferese_panel::scene::MAX_PRIMITIVES * 40
|| !fusion.is_finite()
|| !(0. ..=256.).contains(&fusion)
{
return None;
}
let shapes: Vec<_> = bytes
.as_chunks::<40>()
.0
.iter()
.map(|tuple| {
let values = std::array::from_fn(|i| f32::from_ne_bytes(tuple.as_chunks::<4>().0[i]));
ferese_panel::scene::Primitive::from_packet(values).ok()
})
.collect::<Option<_>>()?;
if shapes.iter().filter(|p| p.operation == 1).count() > 1 {
return None;
}
Some(PanelPresentation {
shapes: shapes.into(),
fusion,
})
}
pub(crate) fn surface_region_opacities(surface: &WlSurface) -> Vec<u32> {
with_states(surface, |states| {
states
.data_map
.get::<SurfaceEffectsState>()
.map(|effects| {
effects
.region_opacities
.lock()
.expect("surface effect state must not be used after a mutator panics")
.clone()
})
.unwrap_or_default()
})
}
fn decode_region_opacities(bytes: &[u8]) -> Option<Vec<u32>> {
if !bytes.len().is_multiple_of(4)
|| bytes.len() > ferese_protocols::effects::v1::MAX_REGIONS * 4
{
return None;
}
bytes
.as_chunks::<4>()
.0
.iter()
.map(|v| {
let value = u32::from_ne_bytes(*v);
(value <= 1000).then_some(value)
})
.collect()
}
fn decode_regions(bytes: &[u8]) -> Option<Vec<[f64; 5]>> {
if !bytes.len().is_multiple_of(20) || bytes.len() > 32 * 20 {
if !bytes.len().is_multiple_of(20)
|| bytes.len() > ferese_protocols::effects::v1::MAX_REGIONS * 20
{
return None;
}
bytes
@@ -204,7 +372,8 @@ fn decode_regions(bytes: &[u8]) -> Option<Vec<[f64; 5]>> {
.0
.iter()
.map(|tuple| {
let values: [f64; 5] = std::array::from_fn(|i| f64::from(f32::from_ne_bytes(tuple.as_chunks::<4>().0[i])));
let values: [f64; 5] =
std::array::from_fn(|i| f64::from(f32::from_ne_bytes(tuple.as_chunks::<4>().0[i])));
(values[2] > 0.0
&& values[3] > 0.0
&& values[4] >= 0.0
@@ -219,7 +388,10 @@ fn decode_regions(bytes: &[u8]) -> Option<Vec<[f64; 5]>> {
pub(crate) fn surface_role(surface: &WlSurface) -> Option<(SemanticRole, u64)> {
with_states(surface, |states| {
let effects = states.data_map.get::<SurfaceEffectsState>()?;
let role = *effects.role.lock().unwrap();
let role = *effects
.role
.lock()
.expect("surface effect state must not be used after a mutator panics");
role.map(|role| (role, effects.generation.load(Ordering::Acquire)))
})
}
@@ -256,11 +428,13 @@ impl Dispatch<FereseEffectsManagerV1, ()> for Ferese {
match request {
ferese_effects_manager_v1::Request::GetSurfaceEffects { id, surface } => {
let already_attached = with_states(&surface, |states| {
states.data_map.insert_if_missing_threadsafe(SurfaceEffectsState::new);
states
.data_map
.insert_if_missing_threadsafe(SurfaceEffectsState::new);
states
.data_map
.get::<SurfaceEffectsState>()
.unwrap()
.expect("effects state was inserted before this access")
.attached
.swap(true, Ordering::AcqRel)
});
@@ -276,7 +450,7 @@ impl Dispatch<FereseEffectsManagerV1, ()> for Ferese {
states
.data_map
.get::<SurfaceEffectsState>()
.unwrap()
.expect("effects state was inserted before this access")
.presentation_supported
.store(effects.version() >= 3, Ordering::Release)
});
@@ -303,6 +477,62 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
};
match request {
ferese_surface_effects_v1::Request::SetPanelPresentation {
regions,
shapes,
fusion,
opacity,
} => {
let (Some(regions), Some(panel)) = (
decode_regions(&regions),
decode_panel_shapes(&shapes, fusion),
) else {
return;
};
if panel.shapes.is_empty() != regions.is_empty() {
return;
}
with_states(&surface, |states| {
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
*effects.pending.lock().expect(
"surface effect state must not be used after a mutator panics",
) = Some(PendingPresentation {
panel: Some(panel),
role: SemanticRole::Panel,
region_opacities: vec![1000; regions.len()],
regions,
opacity: opacity.min(1000),
});
}
});
}
ferese_surface_effects_v1::Request::SetPresentation {
role,
regions,
opacity,
region_opacities,
} => {
let (Some(role), Some(regions), Some(region_opacities)) = (
decode_role(role),
decode_regions(&regions),
decode_region_opacities(&region_opacities),
) else {
return;
};
with_states(&surface, |states| {
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
*effects.pending.lock().expect(
"surface effect state must not be used after a mutator panics",
) = Some(PendingPresentation {
panel: None,
role,
regions,
opacity: opacity.min(1000),
region_opacities,
});
}
});
}
ferese_surface_effects_v1::Request::SetOpacity { opacity } => {
let changed = with_states(&surface, |states| {
let Some(effects) = states.data_map.get::<SurfaceEffectsState>() else {
@@ -311,7 +541,10 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
if effects.dismissing.load(Ordering::Acquire) {
return false;
}
let mut current = effects.opacity.lock().unwrap();
let mut current = effects
.opacity
.lock()
.expect("surface effect state must not be used after a mutator panics");
let opacity = opacity.min(1000);
if *current == opacity {
return false;
@@ -321,22 +554,38 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
true
});
if changed {
crate::backends::direct::render_all(state);
crate::backends::direct::render_surface(state, &surface);
}
}
ferese_surface_effects_v1::Request::SetRole { role } => {
let role = match role {
WEnum::Value(ferese_surface_effects_v1::Role::Panel) => SemanticRole::Panel,
WEnum::Value(ferese_surface_effects_v1::Role::PanelElevated) => SemanticRole::PanelElevated,
WEnum::Value(ferese_surface_effects_v1::Role::Popover) => SemanticRole::Popover,
WEnum::Value(ferese_surface_effects_v1::Role::Menu) => SemanticRole::Menu,
WEnum::Value(ferese_surface_effects_v1::Role::Notification) => SemanticRole::Notification,
WEnum::Value(ferese_surface_effects_v1::Role::Hud) => SemanticRole::Hud,
WEnum::Value(ferese_surface_effects_v1::Role::Modal) => SemanticRole::Modal,
WEnum::Unknown(_) | WEnum::Value(_) => return,
let Some(role) = decode_role(role) else {
return;
};
if set_surface_role(&surface, Some(role)) {
crate::backends::direct::render_all(state);
crate::backends::direct::render_surface(state, &surface);
}
}
ferese_surface_effects_v1::Request::SetRegionOpacities { opacities } => {
let Some(opacities) = decode_region_opacities(&opacities) else {
return;
};
let changed = with_states(&surface, |states| {
let Some(effects) = states.data_map.get::<SurfaceEffectsState>() else {
return false;
};
let mut old = effects
.region_opacities
.lock()
.expect("surface effect state must not be used after a mutator panics");
if *old == opacities {
return false;
}
*old = opacities;
effects.generation.fetch_add(1, Ordering::Release);
true
});
if changed {
crate::backends::direct::render_surface(state, &surface);
}
}
ferese_surface_effects_v1::Request::SetRegions { regions } => {
@@ -347,7 +596,10 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
let Some(effects) = states.data_map.get::<SurfaceEffectsState>() else {
return false;
};
let mut old = effects.regions.lock().unwrap();
let mut old = effects
.regions
.lock()
.expect("surface effect state must not be used after a mutator panics");
if old.as_ref() == Some(&regions) {
return false;
}
@@ -356,17 +608,17 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
true
});
if changed {
crate::backends::direct::render_all(state);
crate::backends::direct::render_surface(state, &surface);
}
}
ferese_surface_effects_v1::Request::ClearRole => {
if set_surface_role(&surface, None) {
crate::backends::direct::render_all(state);
crate::backends::direct::render_surface(state, &surface);
}
}
ferese_surface_effects_v1::Request::Destroy => {
if detach(&surface) {
crate::backends::direct::render_all(state);
crate::backends::direct::render_surface(state, &surface);
}
}
_ => unreachable!(),
@@ -385,6 +637,19 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
}
}
fn decode_role(role: WEnum<ferese_surface_effects_v1::Role>) -> Option<SemanticRole> {
Some(match role {
WEnum::Value(ferese_surface_effects_v1::Role::Panel) => SemanticRole::Panel,
WEnum::Value(ferese_surface_effects_v1::Role::PanelElevated) => SemanticRole::PanelElevated,
WEnum::Value(ferese_surface_effects_v1::Role::Popover) => SemanticRole::Popover,
WEnum::Value(ferese_surface_effects_v1::Role::Menu) => SemanticRole::Menu,
WEnum::Value(ferese_surface_effects_v1::Role::Notification) => SemanticRole::Notification,
WEnum::Value(ferese_surface_effects_v1::Role::Hud) => SemanticRole::Hud,
WEnum::Value(ferese_surface_effects_v1::Role::Modal) => SemanticRole::Modal,
WEnum::Unknown(_) | WEnum::Value(_) => return None,
})
}
fn set_surface_role(surface: &WlSurface, role: Option<SemanticRole>) -> bool {
with_states(surface, |states| {
states
@@ -400,8 +665,26 @@ fn detach(surface: &WlSurface) -> bool {
return false;
};
let role_changed = effects.set_role(None);
effects
.region_opacities
.lock()
.expect("surface effect state must not be used after a mutator panics")
.clear();
effects
.pending
.lock()
.expect("surface effect state must not be used after a mutator panics")
.take();
effects
.panel
.lock()
.expect("surface effect state must not be used after a mutator panics")
.take();
effects.dismissing.store(false, Ordering::Release);
let mut opacity = effects.opacity.lock().unwrap();
let mut opacity = effects
.opacity
.lock()
.expect("surface effect state must not be used after a mutator panics");
let opacity_changed = *opacity != 1000;
*opacity = 1000;
let was_attached = effects.attached.swap(false, Ordering::AcqRel);
@@ -414,24 +697,38 @@ pub(crate) fn surface_opacity(surface: &WlSurface) -> f32 {
states
.data_map
.get::<SurfaceEffectsState>()
.map_or(1.0, |effects| *effects.opacity.lock().unwrap() as f32 / 1000.0)
.map_or(1.0, |effects| {
*effects
.opacity
.lock()
.expect("surface effect state must not be used after a mutator panics")
as f32
/ 1000.0
})
})
}
pub(crate) fn begin_surface_dismiss(surface: &WlSurface) -> bool {
with_states(surface, |states| {
states.data_map.get::<SurfaceEffectsState>().is_some_and(|effects| {
effects.presentation_supported.load(Ordering::Acquire)
&& effects.attached.load(Ordering::Acquire)
&& !effects.dismissing.swap(true, Ordering::AcqRel)
})
states
.data_map
.get::<SurfaceEffectsState>()
.is_some_and(|effects| {
effects.presentation_supported.load(Ordering::Acquire)
&& effects.attached.load(Ordering::Acquire)
&& !effects.dismissing.swap(true, Ordering::AcqRel)
})
})
}
pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) {
with_states(surface, |states| {
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
*effects.opacity.lock().unwrap() = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
*effects
.opacity
.lock()
.expect("surface effect state must not be used after a mutator panics") =
(opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
effects.generation.fetch_add(1, Ordering::Release);
}
});
@@ -441,6 +738,123 @@ pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) {
mod tests {
use super::*;
fn shape_bytes(operation: f32) -> Vec<u8> {
[1.25f32, 2.5, 100., 36., 0., 0., 18., 18., operation, 0.]
.into_iter()
.flat_map(f32::to_ne_bytes)
.collect()
}
#[test]
fn panel_shapes_reject_malformed_packets_and_duplicate_cutouts() {
let bytes = shape_bytes(0.);
let decoded = decode_panel_shapes(&bytes, 12.).unwrap();
assert_eq!(decoded.shapes.len(), 1);
assert_eq!(decoded.shapes[0].bounds.x, 1.25);
assert_eq!(decoded.fusion, 12.);
assert!(decode_panel_shapes(&[], 0.).unwrap().shapes.is_empty());
assert!(decode_panel_shapes(&bytes[..39], 0.).is_none());
assert!(decode_panel_shapes(&bytes.repeat(33), 0.).is_none());
assert!(decode_panel_shapes(&shape_bytes(1.).repeat(2), 0.).is_none());
for invalid in [f64::NAN, f64::INFINITY, -1., 257.] {
assert!(decode_panel_shapes(&bytes, invalid).is_none());
}
for invalid in [f32::NAN, 0.5, 6.] {
assert!(decode_panel_shapes(&shape_bytes(invalid), 0.).is_none());
}
}
#[test]
fn panel_shape_and_capture_regions_commit_together_and_clear_for_popovers() {
let state = SurfaceEffectsState::new();
*state.pending.lock().unwrap() = Some(PendingPresentation {
panel: decode_panel_shapes(&shape_bytes(0.), 12.),
role: SemanticRole::Panel,
regions: vec![[1.25, 2.5, 100., 36., 0.]],
opacity: 1000,
region_opacities: vec![1000],
});
assert!(state.panel.lock().unwrap().is_none());
assert!(state.regions.lock().unwrap().is_none());
state.commit_presentation();
assert_eq!(state.panel.lock().unwrap().as_ref().unwrap().fusion, 12.);
assert_eq!(state.regions.lock().unwrap().as_ref().unwrap().len(), 1);
*state.pending.lock().unwrap() = Some(PendingPresentation {
panel: None,
role: SemanticRole::Popover,
regions: vec![[0., 0., 200., 100., 14.]],
opacity: 800,
region_opacities: vec![1000],
});
state.commit_presentation();
assert!(state.panel.lock().unwrap().is_none());
assert_eq!(*state.role.lock().unwrap(), Some(SemanticRole::Popover));
assert_eq!(*state.opacity.lock().unwrap(), 800);
}
#[test]
fn hiding_a_panel_cancels_its_queued_presentation() {
let state = SurfaceEffectsState::new();
state.set_role(Some(SemanticRole::Panel));
*state.pending.lock().unwrap() = Some(PendingPresentation {
panel: decode_panel_shapes(&shape_bytes(0.), 0.),
role: SemanticRole::Panel,
regions: vec![[0., 0., 100., 36., 0.]],
opacity: 1000,
region_opacities: vec![1000],
});
state.set_role(None);
state.commit_presentation();
assert_eq!(*state.role.lock().unwrap(), None);
assert!(state.panel.lock().unwrap().is_none());
}
#[test]
fn presentation_waits_for_buffer_commit_and_keeps_dismissal_ownership() {
let state = SurfaceEffectsState::new();
let queue = |opacity| PendingPresentation {
panel: None,
role: SemanticRole::Popover,
regions: vec![[1.25, 2.5, 100.0, 80.0, 12.0]],
opacity,
region_opacities: vec![500],
};
*state.pending.lock().unwrap() = Some(queue(900));
assert_eq!(*state.opacity.lock().unwrap(), 1000);
assert!(state.regions.lock().unwrap().is_none());
assert_eq!(state.generation.load(Ordering::Acquire), 0);
// Several requests before one buffer commit present only the last.
*state.pending.lock().unwrap() = Some(queue(800));
state.commit_presentation();
assert_eq!(*state.opacity.lock().unwrap(), 800);
assert_eq!(state.regions.lock().unwrap().as_ref().unwrap()[0][0], 1.25);
assert_eq!(*state.region_opacities.lock().unwrap(), [500]);
assert_eq!(state.generation.load(Ordering::Acquire), 1);
state.commit_presentation();
assert_eq!(state.generation.load(Ordering::Acquire), 1);
state.dismissing.store(true, Ordering::Release);
*state.pending.lock().unwrap() = Some(queue(1000));
state.commit_presentation();
assert_eq!(
*state.opacity.lock().unwrap(),
800,
"client cannot restart compositor fade"
);
}
#[test]
fn region_opacities_validate_independent_fades() {
let bytes: Vec<_> = [0u32, 500, 1000]
.into_iter()
.flat_map(u32::to_ne_bytes)
.collect();
assert_eq!(decode_region_opacities(&bytes), Some(vec![0, 500, 1000]));
assert_eq!(decode_region_opacities(&[]), Some(vec![]));
assert!(decode_region_opacities(&bytes[..11]).is_none());
assert!(decode_region_opacities(&1001u32.to_ne_bytes()).is_none());
assert!(decode_region_opacities(&[0; 33 * 4]).is_none());
}
#[test]
fn fractional_regions_validate_all_wire_values() {
let region = [0.25f32, -0.75, 100.5, 40.25, 13.5];
@@ -464,6 +878,22 @@ mod tests {
}
}
#[test]
fn maximum_panel_groups_and_overflow_fit_one_effects_publication() {
let count = ferese_panel::MAX_GROUPS + 1;
assert_eq!(count, ferese_protocols::effects::v1::MAX_REGIONS);
let region = [0.0f32, 0., 12., 36., 4.];
let bytes: Vec<_> = region.into_iter().flat_map(f32::to_ne_bytes).collect();
assert_eq!(decode_regions(&bytes.repeat(count)).unwrap().len(), count);
assert_eq!(
decode_region_opacities(&1000u32.to_ne_bytes().repeat(count))
.unwrap()
.len(),
count
);
assert!(decode_regions(&bytes.repeat(count + 1)).is_none());
}
#[test]
fn role_changes_advance_generation_only_when_material_changes() {
let effects = SurfaceEffectsState::new();
+82 -25
View File
@@ -19,8 +19,12 @@ pub fn intersection(a: Rect, b: Rect) -> Option<Rect> {
pub fn clamp_to_work(mut rect: Rect, work: Rect) -> Rect {
// Preserve client minimum sizes even when the work area is smaller.
rect.x = rect.x.clamp(work.x, work.x + (work.width - rect.width).max(0.0));
rect.y = rect.y.clamp(work.y, work.y + (work.height - rect.height).max(0.0));
rect.x = rect
.x
.clamp(work.x, work.x + (work.width - rect.width).max(0.0));
rect.y = rect
.y
.clamp(work.y, work.y + (work.height - rect.height).max(0.0));
rect
}
@@ -35,14 +39,27 @@ pub fn centered(size: (f64, f64), anchor: Rect) -> Rect {
pub fn anchored_size(rect: Rect, size: (f64, f64), left: bool, top: bool) -> Rect {
Rect::new(
if left { rect.x + rect.width - size.0 } else { rect.x },
if top { rect.y + rect.height - size.1 } else { rect.y },
if left {
rect.x + rect.width - size.0
} else {
rect.x
},
if top {
rect.y + rect.height - size.1
} else {
rect.y
},
size.0,
size.1,
)
}
pub fn min_overlap(size: (f64, f64), work: Rect, others: &[Rect], anchor: (f64, f64)) -> Option<Rect> {
pub fn min_overlap(
size: (f64, f64),
work: Rect,
others: &[Rect],
anchor: (f64, f64),
) -> Option<Rect> {
let (width, height) = size;
if !width.is_finite()
|| !height.is_finite()
@@ -77,12 +94,15 @@ pub fn min_overlap(size: (f64, f64), work: Rect, others: &[Rect], anchor: (f64,
.filter_map(|other| intersection(rect, *other))
.map(|r| r.width * r.height)
.sum::<f64>();
let distance = (x + width / 2.0 - anchor.0).powi(2) + (y + height / 2.0 - anchor.1).powi(2);
let distance =
(x + width / 2.0 - anchor.0).powi(2) + (y + height / 2.0 - anchor.1).powi(2);
let cost = (overlap, distance);
if best
.as_ref()
.is_none_or(|(old, _)| cost.0.total_cmp(&old.0).then(cost.1.total_cmp(&old.1)).is_lt())
{
if best.as_ref().is_none_or(|(old, _)| {
cost.0
.total_cmp(&old.0)
.then(cost.1.total_cmp(&old.1))
.is_lt()
}) {
best = Some((cost, rect));
}
}
@@ -97,7 +117,10 @@ pub struct Cascade {
impl Cascade {
pub fn place(&mut self, output: &str, work: Rect, size: (f64, f64)) -> Rect {
let position = self.positions.entry(output.to_owned()).or_insert((work.x, work.y));
let position = self
.positions
.entry(output.to_owned())
.or_insert((work.x, work.y));
if position.0 < work.x
|| position.1 < work.y
|| position.0 + size.0 > work.x + work.width
@@ -132,7 +155,10 @@ impl Remembered {
pub fn restore(&self, outputs: &[(String, Rect)]) -> Option<Rect> {
let work = outputs.iter().find(|(name, _)| *name == self.output)?.1;
if !self.fractions.iter().all(|v| v.is_finite()) || self.fractions[2] <= 0.0 || self.fractions[3] <= 0.0 {
if !self.fractions.iter().all(|v| v.is_finite())
|| self.fractions[2] <= 0.0
|| self.fractions[3] <= 0.0
{
return None;
}
let [x, y, w, h] = self.fractions;
@@ -142,11 +168,15 @@ impl Remembered {
w * work.width,
h * work.height,
);
if ![rect.x, rect.y, rect.width, rect.height].iter().all(|v| v.is_finite()) {
if ![rect.x, rect.y, rect.width, rect.height]
.iter()
.all(|v| v.is_finite())
{
return None;
}
let visible = outputs.iter().any(|(_, area)| {
intersection(rect, *area).is_some_and(|r| r.width * r.height >= rect.width * rect.height * 0.25)
intersection(rect, *area)
.is_some_and(|r| r.width * r.height >= rect.width * rect.height * 0.25)
});
visible.then_some(rect)
}
@@ -169,8 +199,13 @@ struct SaveThread {
}
impl SaveThread {
fn start(mut persist: impl FnMut(&Path, &Memory) -> std::io::Result<()> + Send + 'static) -> std::io::Result<Self> {
let shared = std::sync::Arc::new((std::sync::Mutex::new(SaveState::default()), std::sync::Condvar::new()));
fn start(
mut persist: impl FnMut(&Path, &Memory) -> std::io::Result<()> + Send + 'static,
) -> std::io::Result<Self> {
let shared = std::sync::Arc::new((
std::sync::Mutex::new(SaveState::default()),
std::sync::Condvar::new(),
));
let worker = shared.clone();
let thread = std::thread::Builder::new()
.name("floating-geometry-save".into())
@@ -178,9 +213,13 @@ impl SaveThread {
loop {
let job = {
let (mutex, ready) = &*worker;
let mut state = mutex.lock().unwrap();
let mut state = mutex.lock().expect(
"screenshot writer queue must remain consistent across workers",
);
while state.pending.is_none() && !state.closed {
state = ready.wait(state).unwrap();
state = ready.wait(state).expect(
"screenshot writer queue must remain consistent across workers",
);
}
match state.pending.take() {
Some(job) => job,
@@ -202,7 +241,10 @@ impl SaveThread {
fn queue(&self, path: PathBuf, memory: Memory) {
let (mutex, ready) = &*self.shared;
mutex.lock().unwrap().pending = Some((path, memory));
mutex
.lock()
.expect("screenshot writer queue must remain consistent across workers")
.pending = Some((path, memory));
ready.notify_one();
}
}
@@ -210,7 +252,10 @@ impl SaveThread {
impl Drop for SaveThread {
fn drop(&mut self) {
let (mutex, ready) = &*self.shared;
mutex.lock().unwrap().closed = true;
mutex
.lock()
.expect("screenshot writer queue must remain consistent across workers")
.closed = true;
ready.notify_one();
// Flush the latest snapshot only during compositor shutdown, after
// the event loop has stopped. Drag completion never joins this thread.
@@ -230,7 +275,10 @@ impl SaveWorker {
if self.worker.is_none() {
self.worker = Some(SaveThread::start(|path, memory| memory.persist(path))?);
}
self.worker.as_ref().unwrap().queue(path, memory.clone());
self.worker
.as_ref()
.expect("screenshot submission initialized the writer before queueing work")
.queue(path, memory.clone());
Ok(())
}
}
@@ -355,7 +403,9 @@ pub fn snap_lines(raw: Rect, work: Rect, others: &[(u64, Rect)]) -> (Vec<SnapLin
},
];
for &(target, rect) in others {
if raw.y - SNAP_DISTANCE <= rect.y + rect.height && raw.y + raw.height + SNAP_DISTANCE >= rect.y {
if raw.y - SNAP_DISTANCE <= rect.y + rect.height
&& raw.y + raw.height + SNAP_DISTANCE >= rect.y
{
xs.extend([
SnapLine {
target,
@@ -367,7 +417,9 @@ pub fn snap_lines(raw: Rect, work: Rect, others: &[(u64, Rect)]) -> (Vec<SnapLin
},
]);
}
if raw.x - SNAP_DISTANCE <= rect.x + rect.width && raw.x + raw.width + SNAP_DISTANCE >= rect.x {
if raw.x - SNAP_DISTANCE <= rect.x + rect.width
&& raw.x + raw.width + SNAP_DISTANCE >= rect.x
{
ys.extend([
SnapLine {
target,
@@ -420,7 +472,9 @@ mod tests {
memory
};
worker.queue(PathBuf::from("unused"), snapshot("first"));
started_rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
started_rx
.recv_timeout(std::time::Duration::from_secs(2))
.unwrap();
worker.queue(PathBuf::from("unused"), snapshot("superseded"));
worker.queue(PathBuf::from("unused"), snapshot("last"));
assert_eq!(*written.lock().unwrap(), vec!["first"]);
@@ -511,7 +565,10 @@ mod tests {
};
let placed = min_overlap(size, Rect::new(0., 0., 16., 16.), &others, (8., 8.)).unwrap();
let brute = (0..=16 - size.0 as i32)
.flat_map(|x| (0..=16 - size.1 as i32).map(move |y| Rect::new(x as f64, y as f64, size.0, size.1)))
.flat_map(|x| {
(0..=16 - size.1 as i32)
.map(move |y| Rect::new(x as f64, y as f64, size.0, size.1))
})
.map(overlap)
.min_by(f64::total_cmp)
.unwrap();
+170
View File
@@ -0,0 +1,170 @@
use std::time::Duration;
use crate::config::FocusEffectSettings;
use ferese_layout::WindowId;
pub(crate) fn target(
settings: FocusEffectSettings,
active: Option<WindowId>,
window: WindowId,
) -> f64 {
if !settings.has_effects() || active == Some(window) {
1.0
} else {
0.0
}
}
impl FocusEffectSettings {
pub(crate) fn has_effects(self) -> bool {
self.enabled && (self.active_opacity != self.inactive_opacity || self.inactive_dim != 0.0)
}
pub(crate) fn resolve(
self,
progress: f64,
has_active: bool,
overview_opacity: f32,
) -> (f32, f64) {
if !self.enabled {
return (1.0, 0.0);
}
let progress = progress.clamp(0.0, 1.0);
let alpha =
self.inactive_opacity + (self.active_opacity - self.inactive_opacity) * progress;
let dim = if has_active {
self.inactive_dim * (1.0 - progress) * (1.0 - f64::from(overview_opacity))
} else {
0.0
};
(alpha as f32, dim)
}
}
/// A bounded, interruption-safe opacity transition; it never overshoots.
#[derive(Clone, Debug)]
pub(crate) struct BoundedFade {
pub current: f64,
start: f64,
target: f64,
elapsed: f64,
}
impl BoundedFade {
pub(crate) fn needs_update(&self, target: f64) -> bool {
self.target != target || self.current != target
}
pub(crate) fn is_animating(&self) -> bool {
self.current != self.target
}
pub fn new(value: f64) -> Self {
Self {
current: value,
start: value,
target: value,
elapsed: 0.0,
}
}
pub fn advance(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool {
if self.target != target {
self.start = self.current;
self.target = target;
self.elapsed = 0.0;
}
if duration_ms == 0.0 {
self.current = target;
return false;
}
self.elapsed += delta.as_secs_f64() * 1000.0;
let progress = (self.elapsed / duration_ms).min(1.0);
if progress == 1.0 {
self.current = target;
return false;
}
let eased = progress * progress * (3.0 - 2.0 * progress);
self.current = self.start + (target - self.start) * eased;
self.current != target
}
/// The target changed at this tick, not at the beginning of a potentially
/// long idle interval. Do not spend that old elapsed time on a new fade.
pub fn advance_visual(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool {
self.advance(
target,
if self.target != target {
Duration::ZERO
} else {
delta
},
duration_ms,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn settings() -> FocusEffectSettings {
FocusEffectSettings {
enabled: true,
blur: true,
active_opacity: 1.0,
inactive_opacity: 0.8,
inactive_dim: 0.2,
duration_ms: 150.0,
}
}
#[test]
fn disabled_effect_bypasses_opacity_and_dimming_without_changing_settings() {
let mut effect = settings();
effect.enabled = false;
assert!(!effect.has_effects());
for progress in [0.0, 0.5, 1.0] {
assert_eq!(effect.resolve(progress, true, 0.0), (1.0, 0.0));
}
assert_eq!(target(effect, Some(WindowId(1)), WindowId(2)), 1.0);
effect.enabled = true;
assert_eq!(effect.resolve(0.0, true, 0.0), (0.8, 0.2));
}
#[test]
fn opacity_and_dimming_share_one_interruption_safe_transition() {
let mut focus = BoundedFade::new(0.0);
assert!(focus.advance_visual(1.0, Duration::from_secs(30), 150.0));
assert_eq!(focus.current, 0.0);
focus.advance_visual(1.0, Duration::from_millis(75), 150.0);
let (alpha, dim) = settings().resolve(focus.current, true, 0.0);
assert!((alpha - 0.9).abs() < 1e-6);
assert!((dim - 0.1).abs() < 1e-9);
let before = focus.current;
focus.advance_visual(0.0, Duration::from_secs(30), 150.0);
assert_eq!(focus.current, before);
assert!(!focus.advance_visual(0.0, Duration::from_millis(150), 150.0));
assert_eq!(settings().resolve(focus.current, true, 0.0), (0.8, 0.2));
}
#[test]
fn activation_is_independent_of_overview_and_no_active_window() {
assert_eq!(target(settings(), Some(WindowId(1)), WindowId(1)), 1.0);
assert_eq!(target(settings(), Some(WindowId(1)), WindowId(2)), 0.0);
assert_eq!(target(settings(), None, WindowId(2)), 0.0);
assert_eq!(settings().resolve(0.0, true, 1.0), (0.8, 0.0));
assert_eq!(settings().resolve(0.0, false, 0.0), (0.8, 0.0));
assert_eq!(settings().resolve(0.0, true, 0.5), (0.8, 0.1));
}
#[test]
fn reduced_motion_and_zero_duration_snap() {
let mut focus = BoundedFade::new(0.0);
assert!(!focus.advance_visual(1.0, Duration::ZERO, 0.0));
assert_eq!(settings().resolve(focus.current, true, 0.0), (1.0, 0.0));
}
}
+142 -20
View File
@@ -3,6 +3,7 @@ use std::time::Duration;
const MIN_RENDER: Duration = Duration::from_millis(2);
const TIMER_MARGIN: Duration = Duration::from_millis(1);
const WAKEUP_DECAY: Duration = Duration::from_secs(1);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct FramePlan {
@@ -25,6 +26,8 @@ pub(crate) struct FrameScheduler {
render_variance: Duration,
last_sample: Option<Duration>,
extra_margin: Duration,
wakeup_delay: Duration,
last_wakeup: Option<Duration>,
pub missed_deadlines: u64,
}
@@ -42,6 +45,8 @@ impl FrameScheduler {
render_variance: Duration::ZERO,
last_sample: None,
extra_margin: Duration::ZERO,
wakeup_delay: Duration::ZERO,
last_wakeup: None,
missed_deadlines: 0,
}
}
@@ -56,6 +61,10 @@ impl FrameScheduler {
self.requested_at.get_or_insert(now);
}
pub fn has_pending_request(&self) -> bool {
self.requested_at.is_some() || self.scheduled.is_some()
}
fn budget(&self, now: Duration) -> Duration {
let idle = self
.last_presented
@@ -64,10 +73,27 @@ impl FrameScheduler {
// A cold GPU is less predictable. Start immediately on idle wakeup.
self.interval
} else {
(self.render_estimate + self.render_variance * 2 + TIMER_MARGIN + self.extra_margin).min(self.interval)
(self.render_estimate
+ self.render_variance * 2
+ TIMER_MARGIN
+ self.extra_margin
+ self.wakeup_margin(now))
.min(self.interval)
}
}
fn wakeup_margin(&self, now: Duration) -> Duration {
if self.wakeup_delay.is_zero() {
return Duration::ZERO;
}
let elapsed = self
.last_wakeup
.map_or(Duration::ZERO, |last| now.saturating_sub(last));
// Forget transient contention by elapsed time, independent of refresh rate.
self.wakeup_delay
.mul_f64((-elapsed.as_secs_f64() / WAKEUP_DECAY.as_secs_f64()).exp())
}
fn phase_at_or_after(&self, time: Duration) -> Duration {
let Some(last) = self.last_presented else {
return time;
@@ -91,7 +117,10 @@ impl FrameScheduler {
}
let requested_at = self.requested_at?;
if self.last_presented.is_some_and(|last| last > now + self.interval) {
if self
.last_presented
.is_some_and(|last| last > now + self.interval)
{
self.last_presented = None;
self.last_target = None;
}
@@ -126,8 +155,15 @@ impl FrameScheduler {
Some(plan)
}
pub fn begin(&mut self) -> Option<FramePlan> {
pub fn begin(&mut self, now: Duration) -> Option<FramePlan> {
let plan = self.scheduled.take()?;
if plan.phase_known {
// Rendering cost starts after dispatch. Account separately for time
// spent waiting for a CPU or for earlier event-loop callbacks.
let late = now.saturating_sub(plan.render_at).min(self.interval);
self.wakeup_delay = self.wakeup_margin(now).max(late);
self.last_wakeup = Some(now);
}
self.requested_at = None;
self.last_target = Some(plan.present_at);
Some(plan)
@@ -144,8 +180,12 @@ impl FrameScheduler {
let ratio = (elapsed.as_secs_f64() / 0.5).clamp(0.01, 1.0);
let deviation = sample.saturating_sub(self.render_estimate);
let variance_ratio = (elapsed.as_secs_f64() / 6.0).clamp(0.001, 0.1);
self.render_variance = self.render_variance.mul_f64(1.0 - variance_ratio).max(deviation);
self.render_estimate = self.render_estimate.mul_f64(1.0 - ratio) + sample.mul_f64(ratio);
self.render_variance = self
.render_variance
.mul_f64(1.0 - variance_ratio)
.max(deviation);
self.render_estimate =
self.render_estimate.mul_f64(1.0 - ratio) + sample.mul_f64(ratio);
}
}
@@ -164,7 +204,8 @@ impl FrameScheduler {
self.last_target = Some(timestamp);
let late = timestamp.saturating_sub(plan.present_at);
if plan.phase_known && late > self.interval / 2 {
let missed = (late.as_nanos() + self.interval.as_nanos() / 2) / self.interval.as_nanos();
let missed =
(late.as_nanos() + self.interval.as_nanos() / 2) / self.interval.as_nanos();
self.missed_deadlines = self.missed_deadlines.saturating_add(missed as u64);
// CPU submission timings may omit asynchronous GPU work. Increase
// the margin promptly after a real miss; relax it slowly afterward.
@@ -222,7 +263,8 @@ impl FrameScheduler {
// the next refresh look too early and halve a client's frame rate.
self.last_callback = Some(self.last_presented.map_or(now, |last| {
let tolerance = Duration::from_micros(500).min(self.interval / 8);
let slots = (now.saturating_sub(last) + tolerance).as_nanos() / self.interval.as_nanos();
let slots =
(now.saturating_sub(last) + tolerance).as_nanos() / self.interval.as_nanos();
last + self.interval.mul_f64(slots as f64)
}));
}
@@ -245,7 +287,7 @@ mod tests {
assert_eq!((plan.render_at, plan.present_at), (ms(107), ms(110)));
scheduler.request(ms(102));
assert_eq!(scheduler.plan(ms(102)), Some(plan));
assert_eq!(scheduler.begin(), Some(plan));
assert_eq!(scheduler.begin(plan.render_at), Some(plan));
scheduler.rendered(plan, ms(108), ms(1), true);
scheduler.request(ms(109));
assert_eq!(scheduler.plan(ms(109)), None);
@@ -260,7 +302,7 @@ mod tests {
scheduler.request(ms(10_100));
let plan = scheduler.plan(ms(10_100)).unwrap();
assert_eq!(plan.render_at, ms(10_100));
scheduler.begin();
scheduler.begin(plan.render_at);
scheduler.rendered(plan, ms(10_101), ms(1), true);
scheduler.presented(ms(10_110));
assert_eq!(scheduler.missed_deadlines, 0);
@@ -272,7 +314,7 @@ mod tests {
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
scheduler.begin();
scheduler.begin(plan.render_at);
scheduler.rendered(plan, ms(108), ms(1), true);
assert_eq!(scheduler.presented(ms(130)), Some(plan));
assert_eq!(scheduler.missed_deadlines, 2);
@@ -280,12 +322,81 @@ mod tests {
assert_eq!(scheduler.presented(ms(129)), None);
}
#[test]
fn late_timer_dispatch_moves_the_next_render_earlier_without_skipping_a_refresh() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let first = scheduler.plan(ms(101)).unwrap();
assert_eq!(first.render_at, ms(107));
let dispatch = first.render_at + ms(3);
assert_eq!(scheduler.begin(dispatch), Some(first));
scheduler.rendered(first, dispatch + ms(2), ms(2), true);
scheduler.presented(ms(120));
assert_eq!(scheduler.missed_deadlines, 1);
scheduler.request(ms(120));
let next = scheduler.plan(ms(120)).unwrap();
assert_eq!(next.present_at, ms(130), "retain the next physical refresh");
assert!(
next.render_at + ms(3) + ms(2) <= next.present_at - TIMER_MARGIN,
"the next frame must budget both the observed wakeup delay and rendering"
);
assert_eq!(
scheduler.plan(ms(121)),
Some(next),
"new input must not postpone the plan"
);
}
#[test]
fn wakeup_history_decays_by_time_is_bounded_and_resets_with_the_output() {
let mut fast = FrameScheduler::new(ms(5));
let mut slow = FrameScheduler::new(ms(20));
let mut margins = Vec::new();
for scheduler in [&mut fast, &mut slow] {
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
let now = plan.render_at + ms(3);
scheduler.begin(now);
assert_eq!(scheduler.wakeup_margin(now), ms(3));
margins.push(scheduler.wakeup_margin(now + ms(1000)));
assert!(scheduler.wakeup_margin(now + ms(5000)) < Duration::from_micros(21));
scheduler.reset(ms(10));
assert_eq!(scheduler.wakeup_margin(now), Duration::ZERO);
}
assert_eq!(
margins[0], margins[1],
"decay must not depend on display refresh rate"
);
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
let now = plan.render_at + ms(200);
scheduler.begin(now);
assert_eq!(scheduler.wakeup_margin(now), ms(10));
assert!(scheduler.budget(now) <= ms(10));
}
#[test]
fn unknown_phase_does_not_train_wakeup_history_or_move_a_pending_plan() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.request(ms(100));
let plan = scheduler.plan(ms(100)).unwrap();
assert!(!plan.phase_known);
assert_eq!(scheduler.plan(ms(120)), Some(plan));
scheduler.begin(ms(130));
assert_eq!(scheduler.wakeup_margin(ms(130)), Duration::ZERO);
}
#[test]
fn first_retirement_aligns_the_next_frame_to_the_observed_refresh() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.request(ms(100));
let plan = scheduler.plan(ms(100)).unwrap();
scheduler.begin();
scheduler.begin(plan.render_at);
scheduler.rendered(plan, ms(102), ms(2), true);
scheduler.presented(ms(103));
@@ -299,12 +410,15 @@ mod tests {
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
scheduler.begin();
scheduler.begin(plan.render_at);
scheduler.rendered(plan, ms(108), ms(1), false);
scheduler.callback_sent(ms(110));
scheduler.request(ms(108));
assert_eq!(scheduler.plan(ms(108)).unwrap().present_at, ms(120));
assert_eq!(scheduler.callback_deadline(ms(111), ms(110)).unwrap(), ms(120));
assert_eq!(
scheduler.callback_deadline(ms(111), ms(110)).unwrap(),
ms(120)
);
}
#[test]
@@ -313,26 +427,34 @@ mod tests {
scheduler.presented(ms(100));
scheduler.request(ms(101));
let unchanged = scheduler.plan(ms(101)).unwrap();
scheduler.begin();
scheduler.begin(unchanged.render_at);
scheduler.rendered(unchanged, ms(108), ms(1), false);
let callback_at = scheduler.callback_deadline(ms(108), unchanged.present_at).unwrap();
let callback_at = scheduler
.callback_deadline(ms(108), unchanged.present_at)
.unwrap();
assert_eq!(callback_at, ms(110));
scheduler.request(ms(109));
let changed = scheduler.plan(ms(109)).unwrap();
scheduler.begin();
scheduler.begin(changed.render_at);
scheduler.rendered(changed, ms(118), ms(1), true);
// The old timer is dispatched late, after a new frame was queued.
// Neither that timer nor the predicted refresh can release callbacks.
assert_eq!(scheduler.callback_deadline(ms(118), callback_at), None);
assert_eq!(scheduler.callback_deadline(ms(120), changed.present_at), None);
assert_eq!(
scheduler.callback_deadline(ms(120), changed.present_at),
None
);
assert_eq!(scheduler.callback_deadline(ms(129), callback_at), None);
scheduler.presented(ms(130));
assert_eq!(scheduler.callback_deadline(ms(130), ms(130)), Some(ms(130)));
scheduler.callback_sent(ms(130));
assert_eq!(scheduler.callback_deadline(ms(130), callback_at), Some(ms(140)));
assert_eq!(
scheduler.callback_deadline(ms(130), callback_at),
Some(ms(140))
);
}
#[test]
@@ -368,7 +490,7 @@ mod tests {
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
scheduler.begin();
scheduler.begin(plan.render_at);
scheduler.rendered(plan, ms(108), ms(1), true);
scheduler.request(ms(109));
assert_eq!(scheduler.presented(ms(90)), Some(plan));
@@ -391,7 +513,7 @@ mod tests {
unknown.request(ms(100));
let plan = unknown.plan(ms(100)).unwrap();
assert_eq!(unknown.forecast_time(ms(101), plan), None);
unknown.begin();
unknown.begin(plan.render_at);
unknown.rendered(plan, ms(102), ms(2), true);
unknown.presented(ms(130));
assert_eq!(
+109
View File
@@ -0,0 +1,109 @@
//! Gamma ramps shared by display backends and the gamma-control protocol.
use std::fs::File;
use std::io;
use std::os::unix::fs::FileExt;
// Keep client-controlled allocation bounded even if a driver reports an invalid size.
const MAX_GAMMA_SIZE: usize = 65_536;
#[derive(Clone, PartialEq)]
pub(crate) struct Ramp(Vec<u16>);
impl Ramp {
pub(crate) fn zeroed(size: usize) -> io::Result<Self> {
if size == 0 || size > MAX_GAMMA_SIZE {
return Err(io::ErrorKind::Unsupported.into());
}
Ok(Self(vec![0; size * 3]))
}
pub(crate) fn size(&self) -> usize {
self.0.len() / 3
}
pub(crate) fn channels_mut(&mut self) -> (&mut [u16], &mut [u16], &mut [u16]) {
let size = self.size();
let (red, rest) = self.0.split_at_mut(size);
let (green, blue) = rest.split_at_mut(size);
(red, green, blue)
}
pub(crate) fn at_temperature(&self, temperature: u16) -> Self {
let mut ramp = self.clone();
for (channel, gain) in ramp
.0
.chunks_exact_mut(self.size())
.zip(temperature_gains(temperature))
{
for value in channel {
*value = (f64::from(*value) * gain).round() as u16;
}
}
ramp
}
pub(crate) fn interpolate(&self, target: &Self, progress: f64) -> Self {
assert_eq!(self.size(), target.size());
let progress = progress.clamp(0., 1.);
Self(
self.0
.iter()
.zip(&target.0)
.map(|(&from, &to)| {
(f64::from(from) + (f64::from(to) - f64::from(from)) * progress).round() as u16
})
.collect(),
)
}
pub(crate) fn read(file: File, size: usize) -> io::Result<Self> {
let metadata = file.metadata()?;
let length = size
.checked_mul(6)
.filter(|_| size > 0 && size <= MAX_GAMMA_SIZE);
let Some(length) = length else {
return Err(io::ErrorKind::InvalidData.into());
};
// pread preserves the caller's offset and avoids mmap's truncation SIGBUS.
// Only fixed-size files are accepted, not pipes or device streams.
if !metadata.is_file() || metadata.len() != length as u64 {
return Err(io::ErrorKind::InvalidData.into());
}
let mut bytes = vec![0; length];
file.read_exact_at(&mut bytes, 0)?;
Ok(Self(
bytes
.chunks_exact(2)
.map(|v| u16::from_ne_bytes([v[0], v[1]]))
.collect(),
))
}
pub(crate) fn channels(&self) -> (&[u16], &[u16], &[u16]) {
let size = self.0.len() / 3;
(
&self.0[..size],
&self.0[size..2 * size],
&self.0[2 * size..],
)
}
}
fn temperature_gains(kelvin: u16) -> [f64; 3] {
// Tanner Helland's temperature approximation, normalized to neutral at 6500 K.
// https://tannerhelland.com/2012/09/18/convert-temperature-rgb-algorithm-code.html
let green = |t: f64| (99.4708025861 * t.ln() - 161.1195681661).clamp(0., 255.);
let blue = |t: f64| {
if t <= 19. {
0.
} else {
(138.5177312231 * (t - 10.).ln() - 305.0447927307).clamp(0., 255.)
}
};
let temperature = f64::from(kelvin.clamp(1000, 6500)) / 100.;
[
1.,
green(temperature) / green(65.),
blue(temperature) / blue(65.),
]
}
+119 -4
View File
@@ -1,5 +1,3 @@
//! Navigation previews follow finger motion; actions commit once on release.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[repr(u32)]
pub(crate) enum SwipeDirection {
@@ -12,10 +10,16 @@ pub(crate) enum SwipeDirection {
#[derive(Default)]
pub(crate) struct Swipe {
delta: Option<(f64, f64)>,
tracker_x: ferese_animation::gesture::VelocityTracker,
tracker_y: ferese_animation::gesture::VelocityTracker,
pub release_velocity: f64,
pub unbounded_release_velocity: f64,
threshold: f64,
fingers: u32,
direction: Option<SwipeDirection>,
preview_started: bool,
momentum_navigation: bool,
}
impl Swipe {
@@ -25,6 +29,66 @@ impl Swipe {
self.fingers = fingers;
self.direction = None;
self.preview_started = false;
self.momentum_navigation = false;
self.tracker_x = Default::default();
self.tracker_y = Default::default();
self.release_velocity = 0.0;
self.unbounded_release_velocity = 0.0;
}
pub fn start_time(&mut self, time: std::time::Duration) {
self.tracker_x.push(time, 0.0);
self.tracker_y.push(time, 0.0);
}
pub fn update_at(&mut self, dx: f64, dy: f64, time: std::time::Duration) {
self.update(dx, dy);
if let Some((x, y)) = self.delta {
self.tracker_x.push(time, x);
self.tracker_y.push(time, y);
}
}
pub fn finish_at(
&mut self,
cancelled: bool,
time: std::time::Duration,
) -> Option<SwipeDirection> {
let Some(direction) = self.direction else {
return self.finish(cancelled);
};
let v = match direction {
SwipeDirection::Left => -self.tracker_x.velocity(time),
SwipeDirection::Right => self.tracker_x.velocity(time),
SwipeDirection::Up => -self.tracker_y.velocity(time),
SwipeDirection::Down => self.tracker_y.velocity(time),
};
self.release_velocity = if cancelled {
0.0
} else {
v / (2.0 * self.threshold.max(1.0))
};
self.unbounded_release_velocity = self.release_velocity;
if !self.preview_started && !self.momentum_navigation {
return self.finish(cancelled);
}
let Some((_, progress)) = self.preview() else {
self.release_velocity = 0.0;
self.unbounded_release_velocity = 0.0;
self.delta = None;
return None;
};
// The preview is bounded. Its visible velocity is zero when pushing
// farther beyond either endpoint, even if the fingers keep moving.
if (progress == 0.0 && self.release_velocity < 0.0)
|| (progress == 1.0 && self.release_velocity > 0.0)
{
self.release_velocity = 0.0;
}
self.delta = None;
(!cancelled
&& ferese_animation::gesture::project(progress, self.release_velocity, 0.997) >= 0.5)
.then_some(direction)
}
pub fn fingers(&self) -> u32 {
@@ -49,11 +113,20 @@ impl Swipe {
self.preview_started = true;
}
pub fn mark_momentum_navigation(&mut self) {
self.momentum_navigation = true;
}
pub fn preview_started(&self) -> bool {
self.preview_started
}
pub fn preview(&self) -> Option<(SwipeDirection, f64)> {
self.unbounded_preview()
.map(|(direction, progress)| (direction, progress.clamp(0.0, 1.0)))
}
pub fn unbounded_preview(&self) -> Option<(SwipeDirection, f64)> {
let (x, y) = self.delta?;
if !x.is_finite() || !y.is_finite() {
return None;
@@ -65,7 +138,7 @@ impl Swipe {
SwipeDirection::Up => -y,
SwipeDirection::Down => y,
};
Some((direction, (distance / (self.threshold.max(1.0) * 2.0)).clamp(0.0, 1.0)))
Some((direction, distance / (self.threshold.max(1.0) * 2.0)))
}
pub fn finish(&mut self, cancelled: bool) -> Option<SwipeDirection> {
@@ -73,7 +146,8 @@ impl Swipe {
if cancelled {
return None;
}
dominant_direction(x, y, self.threshold).filter(|direction| Some(*direction) == self.direction)
dominant_direction(x, y, self.threshold)
.filter(|direction| Some(*direction) == self.direction)
}
}
@@ -103,6 +177,47 @@ fn dominant_direction(x: f64, y: f64, threshold: f64) -> Option<SwipeDirection>
mod tests {
use super::*;
#[test]
fn release_uses_momentum_and_a_pause_removes_it() {
use std::time::Duration;
let swipe = || {
let mut swipe = Swipe::default();
swipe.begin(3, false, 80);
swipe.start_time(Duration::ZERO);
swipe.update_at(0.0, -20.0, Duration::from_millis(10));
swipe.update_at(0.0, -20.0, Duration::from_millis(20));
swipe.mark_preview_started();
swipe
};
let mut flick = swipe();
assert_eq!(flick.preview(), Some((SwipeDirection::Up, 0.25)));
assert_eq!(
flick.finish_at(false, Duration::from_millis(20)),
Some(SwipeDirection::Up)
);
assert!((flick.release_velocity - 12.5).abs() < 1e-9);
let mut paused = swipe();
assert_eq!(paused.finish_at(false, Duration::from_millis(80)), None);
assert_eq!(paused.release_velocity, 0.0);
let mut cancelled = swipe();
assert_eq!(cancelled.finish_at(true, Duration::from_millis(20)), None);
assert_eq!(cancelled.release_velocity, 0.0);
}
#[test]
fn reversing_momentum_can_cancel_past_halfway() {
use std::time::Duration;
let mut swipe = Swipe::default();
swipe.begin(3, false, 80);
swipe.start_time(Duration::ZERO);
swipe.update_at(0.0, -120.0, Duration::from_millis(200));
swipe.update_at(0.0, 20.0, Duration::from_millis(210));
swipe.mark_preview_started();
assert_eq!(swipe.preview(), Some((SwipeDirection::Up, 0.625)));
assert_eq!(swipe.finish_at(false, Duration::from_millis(210)), None);
assert!(swipe.release_velocity < 0.0);
}
#[test]
fn cancellation_and_new_gestures_reset_preview_state() {
let mut swipe = Swipe::default();
+5 -3
View File
@@ -1,5 +1,7 @@
mod move_grab;
mod resize_grab;
pub use move_grab::MoveSurfaceGrab;
pub use resize_grab::{ResizeEdge, ResizeSurfaceGrab, handle_resize_commit};
pub use resize_grab::ResizeEdge;
mod window_grab;
pub(crate) use resize_grab::{constrained_size, resized_rect};
pub(crate) use window_grab::WindowPointerGrab;
+86 -320
View File
@@ -1,18 +1,5 @@
use std::cell::RefCell;
use smithay::desktop::Window;
use smithay::input::pointer::{
AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent,
GesturePinchUpdateEvent, GestureSwipeBeginEvent, GestureSwipeEndEvent, GestureSwipeUpdateEvent, GrabStartData,
MotionEvent, PointerGrab, PointerInnerHandle, RelativeMotionEvent,
};
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Point, Rectangle, Size};
use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::SurfaceCachedState;
use crate::Ferese;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ResizeEdge(xdg_toplevel::ResizeEdge);
@@ -24,6 +11,15 @@ impl From<xdg_toplevel::ResizeEdge> for ResizeEdge {
}
impl ResizeEdge {
pub(crate) fn cursor(self) -> smithay::input::pointer::CursorIcon {
use smithay::input::pointer::CursorIcon;
match (self.left(), self.right(), self.top(), self.bottom()) {
(true, _, true, _) | (_, true, _, true) => CursorIcon::NwseResize,
(true, _, _, true) | (_, true, true, _) => CursorIcon::NeswResize,
(true, _, _, _) | (_, true, _, _) => CursorIcon::EwResize,
_ => CursorIcon::NsResize,
}
}
pub fn at(point: Point<f64, Logical>, rect: Rectangle<i32, Logical>) -> Self {
use xdg_toplevel::ResizeEdge::*;
let x = (point.x - rect.loc.x as f64) / rect.size.w.max(1) as f64;
@@ -47,14 +43,16 @@ impl ResizeEdge {
_ => Bottom,
})
}
fn left(self) -> bool {
pub(crate) fn left(self) -> bool {
matches!(
self.0,
xdg_toplevel::ResizeEdge::Left | xdg_toplevel::ResizeEdge::TopLeft | xdg_toplevel::ResizeEdge::BottomLeft
xdg_toplevel::ResizeEdge::Left
| xdg_toplevel::ResizeEdge::TopLeft
| xdg_toplevel::ResizeEdge::BottomLeft
)
}
fn right(self) -> bool {
pub(crate) fn right(self) -> bool {
matches!(
self.0,
xdg_toplevel::ResizeEdge::Right
@@ -63,14 +61,16 @@ impl ResizeEdge {
)
}
fn top(self) -> bool {
pub(crate) fn top(self) -> bool {
matches!(
self.0,
xdg_toplevel::ResizeEdge::Top | xdg_toplevel::ResizeEdge::TopLeft | xdg_toplevel::ResizeEdge::TopRight
xdg_toplevel::ResizeEdge::Top
| xdg_toplevel::ResizeEdge::TopLeft
| xdg_toplevel::ResizeEdge::TopRight
)
}
fn bottom(self) -> bool {
pub(crate) fn bottom(self) -> bool {
matches!(
self.0,
xdg_toplevel::ResizeEdge::Bottom
@@ -80,314 +80,49 @@ impl ResizeEdge {
}
}
pub struct ResizeSurfaceGrab {
start_data: GrabStartData<Ferese>,
window: Window,
edges: ResizeEdge,
initial_rect: Rectangle<i32, Logical>,
last_size: Size<i32, Logical>,
finished: bool,
snap_x: crate::floating::AxisSnap,
snap_y: crate::floating::AxisSnap,
}
impl ResizeSurfaceGrab {
pub fn new(
start_data: GrabStartData<Ferese>,
window: Window,
edges: ResizeEdge,
initial_rect: Rectangle<i32, Logical>,
) -> Self {
ResizeState::with(
window.toplevel().expect("managed window has a toplevel").wl_surface(),
|state| *state = ResizeState::Resizing { edges, initial_rect },
);
Self {
start_data,
window,
edges,
initial_rect,
last_size: initial_rect.size,
finished: false,
snap_x: Default::default(),
snap_y: Default::default(),
}
}
}
impl PointerGrab<Ferese> for ResizeSurfaceGrab {
fn motion(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
_focus: Option<(WlSurface, Point<f64, Logical>)>,
event: &MotionEvent,
) {
handle.motion(data, None, event);
let mut delta = event.location - self.start_data.location;
let initial = self.initial_rect;
let raw_left = initial.loc.x as f64 + if self.edges.left() { delta.x } else { 0. };
let raw_top = initial.loc.y as f64 + if self.edges.top() { delta.y } else { 0. };
let raw_right = (initial.loc.x + initial.size.w) as f64 + if self.edges.right() { delta.x } else { 0. };
let raw_bottom = (initial.loc.y + initial.size.h) as f64 + if self.edges.bottom() { delta.y } else { 0. };
let raw = ferese_layout::Rect::new(
raw_left,
raw_top,
(raw_right - raw_left).max(1.),
(raw_bottom - raw_top).max(1.),
);
let (xs, ys) = data.floating_snap_lines(&self.window, raw);
if data.floating_snap_bypassed() {
self.snap_x.clear();
self.snap_y.clear();
} else {
if self.edges.left() || self.edges.right() {
let moving = if self.edges.left() { raw_left } else { raw_right };
delta.x += self.snap_x.apply(moving, 0., &xs) - moving;
}
if self.edges.top() || self.edges.bottom() {
let moving = if self.edges.top() { raw_top } else { raw_bottom };
delta.y += self.snap_y.apply(moving, 0., &ys) - moving;
}
}
let surface = self.window.toplevel().expect("managed window has a toplevel");
let (minimum, maximum) = with_states(surface.wl_surface(), |states| {
let mut cached = states.cached_state.get::<SurfaceCachedState>();
let state = cached.current();
(state.min_size, state.max_size)
});
self.last_size = constrained_size(self.initial_rect.size, delta, self.edges, minimum, maximum);
let rect = resized_rect(self.initial_rect, self.last_size, self.edges);
data.set_floating_window_geometry(&self.window, rect.loc, rect.size);
if let Some(id) = data.windows.ids().get(&self.window).copied() {
data.windows.update(id, |w| {
w.resize_anchor = Some((
self.edges.left(),
self.edges.top(),
ferese_layout::Rect::new(
initial.loc.x as f64,
initial.loc.y as f64,
initial.size.w as f64,
initial.size.h as f64,
),
))
});
}
surface.with_pending_state(|state| {
state.states.set(xdg_toplevel::State::Resizing);
state.size = Some(self.last_size);
});
surface.send_pending_configure();
}
fn relative_motion(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
focus: Option<(WlSurface, Point<f64, Logical>)>,
event: &RelativeMotionEvent,
) {
handle.relative_motion(data, focus, event);
}
fn button(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>, event: &ButtonEvent) {
handle.button(data, event);
if handle.current_pressed().is_empty() {
self.finished = true;
data.remember_floating(&self.window);
handle.unset_grab(self, data, event.serial, event.time, true);
let surface = self.window.toplevel().expect("managed window has a toplevel");
surface.with_pending_state(|state| {
state.states.unset(xdg_toplevel::State::Resizing);
state.size = Some(self.last_size);
});
surface.send_pending_configure();
ResizeState::with(surface.wl_surface(), |state| {
*state = ResizeState::WaitingForFinalCommit {
edges: self.edges,
initial_rect: self.initial_rect,
};
});
}
}
fn axis(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>, frame: AxisFrame) {
handle.axis(data, frame);
}
fn frame(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>) {
handle.frame(data);
}
fn gesture_swipe_begin(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GestureSwipeBeginEvent,
) {
handle.gesture_swipe_begin(data, event);
}
fn gesture_swipe_update(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GestureSwipeUpdateEvent,
) {
handle.gesture_swipe_update(data, event);
}
fn gesture_swipe_end(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GestureSwipeEndEvent,
) {
handle.gesture_swipe_end(data, event);
}
fn gesture_pinch_begin(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GesturePinchBeginEvent,
) {
handle.gesture_pinch_begin(data, event);
}
fn gesture_pinch_update(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GesturePinchUpdateEvent,
) {
handle.gesture_pinch_update(data, event);
}
fn gesture_pinch_end(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GesturePinchEndEvent,
) {
handle.gesture_pinch_end(data, event);
}
fn gesture_hold_begin(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GestureHoldBeginEvent,
) {
handle.gesture_hold_begin(data, event);
}
fn gesture_hold_end(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GestureHoldEndEvent,
) {
handle.gesture_hold_end(data, event);
}
fn start_data(&self) -> &GrabStartData<Ferese> {
&self.start_data
}
fn unset(&mut self, data: &mut Ferese) {
if self.finished {
return;
}
let surface = self.window.toplevel().expect("managed window has a toplevel");
surface.with_pending_state(|state| {
state.states.unset(xdg_toplevel::State::Resizing);
state.size = Some(self.initial_rect.size);
});
surface.send_pending_configure();
ResizeState::with(surface.wl_surface(), |state| *state = ResizeState::Idle);
data.set_floating_window_geometry(&self.window, self.initial_rect.loc, self.initial_rect.size);
}
}
#[derive(Clone, Copy, Debug, Default)]
enum ResizeState {
#[default]
Idle,
Resizing {
edges: ResizeEdge,
initial_rect: Rectangle<i32, Logical>,
},
WaitingForFinalCommit {
edges: ResizeEdge,
initial_rect: Rectangle<i32, Logical>,
},
}
impl ResizeState {
fn with<T>(surface: &WlSurface, callback: impl FnOnce(&mut Self) -> T) -> T {
with_states(surface, |states| {
states.data_map.insert_if_missing(RefCell::<Self>::default);
callback(
&mut states
.data_map
.get::<RefCell<Self>>()
.expect("resize state was inserted")
.borrow_mut(),
)
})
}
fn commit(&mut self) -> Option<(ResizeEdge, Rectangle<i32, Logical>)> {
match *self {
Self::Idle => None,
Self::Resizing { edges, initial_rect } => Some((edges, initial_rect)),
Self::WaitingForFinalCommit { edges, initial_rect } => {
*self = Self::Idle;
Some((edges, initial_rect))
}
}
}
}
pub fn handle_resize_commit(surface: &WlSurface) {
// The compositor's visual rect owns the anchored edge. A delayed client
// commit must not move the window or change its stacking order.
ResizeState::with(surface, ResizeState::commit);
}
fn constrained_size(
pub(crate) fn constrained_size(
initial: Size<i32, Logical>,
delta: Point<f64, Logical>,
edges: ResizeEdge,
minimum: Size<i32, Logical>,
maximum: Size<i32, Logical>,
minimum: (i32, i32),
maximum: (i32, i32),
) -> Size<i32, Logical> {
let mut width = initial.w;
let mut height = initial.h;
if edges.left() {
width -= delta.x as i32;
width = width.saturating_sub(delta.x as i32);
} else if edges.right() {
width += delta.x as i32;
}
if edges.top() {
height -= delta.y as i32;
} else if edges.bottom() {
height += delta.y as i32;
width = width.saturating_add(delta.x as i32);
}
let maximum_width = if maximum.w == 0 { i32::MAX } else { maximum.w };
let maximum_height = if maximum.h == 0 { i32::MAX } else { maximum.h };
if edges.top() {
height = height.saturating_sub(delta.y as i32);
} else if edges.bottom() {
height = height.saturating_add(delta.y as i32);
}
// Protocol validation rejects these ranges, but a grab may still be alive
// when its client is disconnected. Keep this path safe for any cached state.
let minimum_width = minimum.0.max(1);
let minimum_height = minimum.1.max(1);
let maximum_width = if maximum.0 <= 0 {
i32::MAX
} else {
maximum.0.max(minimum_width)
};
let maximum_height = if maximum.1 <= 0 {
i32::MAX
} else {
maximum.1.max(minimum_height)
};
(
width.clamp(minimum.w.max(1), maximum_width),
height.clamp(minimum.h.max(1), maximum_height),
width.clamp(minimum_width, maximum_width),
height.clamp(minimum_height, maximum_height),
)
.into()
}
fn resized_rect(
pub(crate) fn resized_rect(
initial: Rectangle<i32, Logical>,
size: Size<i32, Logical>,
edges: ResizeEdge,
@@ -395,10 +130,15 @@ fn resized_rect(
let mut location = initial.loc;
if edges.left() {
location.x += initial.size.w - size.w;
location.x = location
.x
.saturating_add(initial.size.w.saturating_sub(size.w));
}
if edges.top() {
location.y += initial.size.h - size.h;
location.y = location
.y
.saturating_add(initial.size.h.saturating_sub(size.h));
}
Rectangle::new(location, size)
@@ -439,8 +179,8 @@ mod tests {
(800, 600).into(),
(100.0, 50.0).into(),
ResizeEdge(xdg_toplevel::ResizeEdge::TopLeft),
(1, 1).into(),
(0, 0).into(),
(1, 1),
(0, 0),
);
assert_eq!(size, (700, 550).into());
}
@@ -451,8 +191,8 @@ mod tests {
(800, 600).into(),
(100.0, 50.0).into(),
ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight),
(1, 1).into(),
(0, 0).into(),
(1, 1),
(0, 0),
);
assert_eq!(size, (900, 650).into());
}
@@ -463,12 +203,38 @@ mod tests {
(800, 600).into(),
(1_000.0, 1_000.0).into(),
ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight),
(640, 480).into(),
(1_024, 768).into(),
(640, 480),
(1_024, 768),
);
assert_eq!(size, (1_024, 768).into());
}
#[test]
fn invalid_and_extreme_client_limits_cannot_panic_or_overflow() {
for (minimum, maximum) in [
((800, 700), (400, 300)),
((-1, -2), (-3, -4)),
((i32::MAX, i32::MAX), (1, 1)),
((0, 0), (0, 0)),
] {
for delta in [(f64::MAX, f64::MAX), (-f64::MAX, -f64::MAX), (0.0, 0.0)] {
let size = constrained_size(
(800, 600).into(),
delta.into(),
ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight),
minimum,
maximum,
);
assert!(size.w >= 1 && size.h >= 1);
let _ = resized_rect(
Rectangle::new((100, 80).into(), (800, 600).into()),
size,
ResizeEdge(xdg_toplevel::ResizeEdge::TopLeft),
);
}
}
}
#[test]
fn resized_rect_keeps_the_opposite_edge_fixed() {
let initial = Rectangle::new((100, 80).into(), (800, 600).into());
@@ -1,26 +1,20 @@
use smithay::desktop::Window;
use smithay::input::pointer::{
AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent,
GesturePinchUpdateEvent, GestureSwipeBeginEvent, GestureSwipeEndEvent, GestureSwipeUpdateEvent, GrabStartData,
MotionEvent, PointerGrab, PointerInnerHandle, RelativeMotionEvent,
AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent,
GesturePinchEndEvent, GesturePinchUpdateEvent, GestureSwipeBeginEvent, GestureSwipeEndEvent,
GestureSwipeUpdateEvent, GrabStartData, MotionEvent, PointerGrab, PointerInnerHandle,
RelativeMotionEvent,
};
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Point, Size};
use smithay::utils::{Logical, Point};
use crate::Ferese;
use crate::floating::AxisSnap;
pub struct MoveSurfaceGrab {
pub(crate) struct WindowPointerGrab {
pub start_data: GrabStartData<Ferese>,
pub window: Window,
pub initial_location: Point<i32, Logical>,
pub initial_size: Size<i32, Logical>,
pub finished: bool,
pub snap_x: AxisSnap,
pub snap_y: AxisSnap,
}
impl PointerGrab<Ferese> for MoveSurfaceGrab {
impl PointerGrab<Ferese> for WindowPointerGrab {
fn motion(
&mut self,
data: &mut Ferese,
@@ -29,49 +23,69 @@ impl PointerGrab<Ferese> for MoveSurfaceGrab {
event: &MotionEvent,
) {
handle.motion(data, None, event);
let location = self.initial_location.to_f64() + (event.location - self.start_data.location);
let raw = ferese_layout::Rect::new(
location.x,
location.y,
self.initial_size.w as f64,
self.initial_size.h as f64,
);
let (xs, ys) = data.floating_snap_lines(&self.window, raw);
let location = if data.floating_snap_bypassed() {
self.snap_x.clear();
self.snap_y.clear();
location
} else {
Point::from((
self.snap_x.apply(raw.x, raw.width, &xs),
self.snap_y.apply(raw.y, raw.height, &ys),
))
if !self.finished
&& let Some(operation) = data.pointer_operation.as_mut()
{
operation.location = event.location;
}
.to_i32_round();
data.set_floating_window_geometry(&self.window, location, self.initial_size);
}
fn relative_motion(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
focus: Option<(WlSurface, Point<f64, Logical>)>,
_focus: Option<(WlSurface, Point<f64, Logical>)>,
event: &RelativeMotionEvent,
) {
handle.relative_motion(data, focus, event);
handle.relative_motion(data, None, event);
}
fn button(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>, event: &ButtonEvent) {
fn button(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &ButtonEvent,
) {
if let Some(operation) = data.pointer_operation.as_mut() {
if event.state == smithay::backend::input::ButtonState::Pressed {
operation.pressed.insert(event.button);
if event.button == 0x111 && event.button != self.start_data.button {
operation.toggle_drop_mode();
}
} else {
operation.pressed.remove(&event.button);
}
}
handle.button(data, event);
if handle.current_pressed().is_empty() {
if event.button == self.start_data.button
&& event.state == smithay::backend::input::ButtonState::Released
{
self.finished = true;
data.remember_floating(&self.window);
handle.unset_grab(self, data, event.serial, event.time, true);
if let Some(operation) = data.pointer_operation.as_mut() {
operation.finished = true;
}
}
// Extra buttons are drained with focus cleared, so their releases cannot
// reach a client that never received the corresponding presses.
if handle.current_pressed().is_empty() {
handle.unset_grab(self, data, event.serial, event.time, false);
}
}
fn axis(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>, frame: AxisFrame) {
handle.axis(data, frame);
fn axis(
&mut self,
data: &mut Ferese,
_handle: &mut PointerInnerHandle<'_, Ferese>,
frame: AxisFrame,
) {
if let Some(operation) = data.pointer_operation.as_mut() {
let amount = if frame.axis.0 != 0.0 {
frame.axis.0
} else {
frame.axis.1
};
operation.scroll(amount);
}
}
fn frame(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>) {
@@ -155,10 +169,12 @@ impl PointerGrab<Ferese> for MoveSurfaceGrab {
}
fn unset(&mut self, data: &mut Ferese) {
if self.finished {
return;
// Resolve focus after the layout settles and the pointer mutex is released.
data.window_grab_focus_pending = true;
if !self.finished
&& let Some(operation) = data.pointer_operation.as_mut()
{
operation.cancelled = true;
}
data.set_floating_window_geometry(&self.window, self.initial_location, self.initial_size);
}
}
+291 -28
View File
@@ -1,6 +1,13 @@
use std::time::Duration;
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use smithay::input::Seat;
use smithay::reexports::wayland_server::Resource;
use smithay::reexports::wayland_server::backend::ClientId;
use smithay::utils::Serial;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
#[cfg(test)]
use smithay::utils::SERIAL_COUNTER;
use smithay::wayland::xdg_activation::{
XdgActivationHandler, XdgActivationState, XdgActivationToken, XdgActivationTokenData,
@@ -9,6 +16,53 @@ use smithay::wayland::xdg_activation::{
use crate::Ferese;
const TOKEN_MAX_AGE: Duration = Duration::from_secs(10);
const MAX_INTERACTIONS: usize = 64;
pub(crate) struct InputHistory<C = ClientId> {
events: VecDeque<(Serial, C, Instant)>,
}
impl<C> Default for InputHistory<C> {
fn default() -> Self {
Self {
events: VecDeque::new(),
}
}
}
impl<C: PartialEq> InputHistory<C> {
fn record(&mut self, serial: Serial, client: C, time: Instant) {
while self.events.len() >= MAX_INTERACTIONS {
self.events.pop_front();
}
self.events.push_back((serial, client, time));
}
fn validate(&self, serial: Serial, client: &C, now: Instant) -> Option<Instant> {
self.events.iter().rev().find_map(|(event, owner, time)| {
(*event == serial
&& owner == client
&& now.saturating_duration_since(*time) <= TOKEN_MAX_AGE)
.then_some(*time)
})
}
}
struct ApprovedInteraction(Instant);
impl Ferese {
pub(crate) fn record_activation_input(&mut self, serial: Serial, surface: Option<WlSurface>) {
if self.session_lock.active() || self.input_capture.active() {
return;
}
if let Some(client) = surface.and_then(|surface| surface.client()) {
self.activation_inputs
.record(serial, client.id(), Instant::now());
}
}
}
impl XdgActivationHandler for Ferese {
fn activation_state(&mut self) -> &mut XdgActivationState {
@@ -16,47 +70,256 @@ impl XdgActivationHandler for Ferese {
}
fn token_created(&mut self, _token: XdgActivationToken, data: XdgActivationTokenData) -> bool {
data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE
if self.session_lock.active() || self.input_capture.active() {
return false;
}
let Some((serial, seat)) = &data.serial else {
return false;
};
let Some(client) = &data.client_id else {
return false;
};
if Seat::<Self>::from_resource(seat).as_ref() != Some(&self.seat)
|| seat.client().is_none_or(|owner| owner.id() != *client)
|| data
.surface
.as_ref()
.is_some_and(|surface| surface.client().is_none_or(|owner| owner.id() != *client))
{
return false;
}
let Some(time) = self
.activation_inputs
.validate(*serial, client, Instant::now())
else {
return false;
};
data.user_data
.insert_if_missing(|| ApprovedInteraction(time));
true
}
fn request_activation(&mut self, token: XdgActivationToken, data: XdgActivationTokenData, surface: WlSurface) {
if self.session_lock.active || self.input_capture.captures(1) {
fn request_activation(
&mut self,
token: XdgActivationToken,
data: XdgActivationTokenData,
surface: WlSurface,
) {
self.xdg_activation_state.remove_token(&token);
if self.session_lock.active() || self.input_capture.captures(1) {
return;
}
let valid = data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE;
self.xdg_activation_state.remove_token(&token);
// The recipient may be a different client: launcher-to-app token handoff.
// Retain the original interaction time rather than renewing it at commit.
let valid = data.timestamp.elapsed() <= TOKEN_MAX_AGE
&& data
.user_data
.get::<ApprovedInteraction>()
.is_some_and(|proof| proof.0.elapsed() <= TOKEN_MAX_AGE);
if !valid {
tracing::debug!("ignored stale or non-interactive activation request");
return;
}
let Some(window) = self
.space
.elements()
.find(|window| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == &surface)
})
.cloned()
else {
let Some(id) = self.windows.id_for_surface(&surface) else {
return;
};
self.activate_managed_window(id);
}
}
self.focused_window = self.windows.ids().get(&window).copied();
if let Some(focused) = self.focused_window
&& let Err(error) = self.workspaces.focus_window(focused)
{
tracing::error!(%error, ?focused, "failed to update workspace focus");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_creation_validates_real_seat_owner_and_input_age() {
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_server::protocol::wl_seat::WlSeat;
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::sync::Arc;
if !crate::startup_tests::private_runtime(
"handlers::activation::tests::token_creation_validates_real_seat_owner_and_input_age",
) {
return;
}
self.raise_window(&window, true);
let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
keyboard.set_focus(self, Some(surface), SERIAL_COUNTER.next_serial());
self.space.elements().for_each(|window| {
if let Some(toplevel) = window.toplevel() {
toplevel.send_pending_configure();
let mut events = EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let (server, mut wire) = UnixStream::pair().unwrap();
let launcher = state
.display_handle
.insert_client(server, Arc::new(crate::state::ClientState::default()))
.unwrap();
let send = |wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32]| {
let bytes: Vec<_> = [object, ((args.len() as u32 + 2) * 4) << 16 | opcode]
.into_iter()
.chain(args.iter().copied())
.flat_map(u32::to_ne_bytes)
.collect();
wire.write_all(&bytes).unwrap();
};
send(&mut wire, 1, 1, &[2]); // wl_display.get_registry
events
.dispatch(Duration::from_millis(1), &mut state)
.unwrap();
state.display_handle.flush_clients().unwrap();
wire.set_nonblocking(true).unwrap();
let mut globals = Vec::new();
let _ = wire.read_to_end(&mut globals);
wire.set_nonblocking(false).unwrap();
let mut offset = 0;
let mut seat_name = None;
while offset < globals.len() {
let header = u32::from_ne_bytes(globals[offset + 4..offset + 8].try_into().unwrap());
if u32::from_ne_bytes(globals[offset..offset + 4].try_into().unwrap()) == 2
&& header & 0xffff == 0
{
let length =
u32::from_ne_bytes(globals[offset + 12..offset + 16].try_into().unwrap())
as usize;
if &globals[offset + 16..offset + 16 + length] == b"wl_seat\0" {
seat_name = Some(u32::from_ne_bytes(
globals[offset + 8..offset + 12].try_into().unwrap(),
));
}
}
});
offset += (header >> 16) as usize;
}
let mut args = vec![seat_name.expect("seat global"), 8];
args.extend(
b"wl_seat\0"
.chunks_exact(4)
.map(|word| u32::from_ne_bytes(word.try_into().unwrap())),
);
args.extend([7, 3]);
send(&mut wire, 2, 0, &args);
events
.dispatch(Duration::from_millis(1), &mut state)
.unwrap();
let seat: WlSeat = state
.seat
.client_seats(&launcher)
.into_iter()
.next()
.expect("bound seat");
let (token, _) = state.xdg_activation_state.create_external_token(None);
let token = token.clone();
let serial = SERIAL_COUNTER.next_serial();
let data = || XdgActivationTokenData {
client_id: Some(launcher.id()),
serial: Some((serial, seat.clone())),
..Default::default()
};
assert!(
!state.token_created(token.clone(), data()),
"fabricated serial accepted"
);
state.activation_inputs.record(
serial,
launcher.id(),
Instant::now() - TOKEN_MAX_AGE - Duration::from_millis(1),
);
assert!(
!state.token_created(token.clone(), data()),
"fresh token renewed stale input"
);
state
.activation_inputs
.record(serial, launcher.id(), Instant::now());
let approved = data();
assert!(state.token_created(token.clone(), approved.clone()));
assert!(approved.user_data.get::<ApprovedInteraction>().is_some());
let (server, mut recipient_wire) = UnixStream::pair().unwrap();
let recipient = state
.display_handle
.insert_client(server, Arc::new(crate::state::ClientState::default()))
.unwrap();
let mut stolen = data();
stolen.client_id = Some(recipient.id());
assert!(
!state.token_created(token.clone(), stolen),
"other client claimed launcher's seat/input"
);
// The proof is attached to the token, not the recipient identity. This
// is what lets a validated launcher hand it to a different application.
assert_ne!(approved.client_id, Some(recipient.id()));
assert!(
approved
.user_data
.get::<ApprovedInteraction>()
.unwrap()
.0
.elapsed()
< TOKEN_MAX_AGE
);
// A valid handoff must not focus a recipient before its first buffer.
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_wm_base::XdgWmBase;
use smithay::reexports::wayland_server::protocol::wl_compositor::WlCompositor;
use smithay::wayland::shell::xdg::XdgWmBaseUserData;
let compositor = recipient
.create_resource::<WlCompositor, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
6,
smithay::wayland::GlobalData,
)
.unwrap();
let shell = recipient
.create_resource::<XdgWmBase, XdgWmBaseUserData, Ferese>(
&state.display_handle,
6,
Default::default(),
)
.unwrap();
send(&mut recipient_wire, compositor.id().protocol_id(), 0, &[2]);
send(&mut recipient_wire, shell.id().protocol_id(), 2, &[3, 2]);
send(&mut recipient_wire, 3, 1, &[4]);
events
.dispatch(Duration::from_millis(1), &mut state)
.unwrap();
let target = state
.space
.elements()
.find_map(|window| {
let surface = window.toplevel()?.wl_surface();
surface
.client()
.is_some_and(|client| client.id() == recipient.id())
.then(|| surface.clone())
})
.expect("recipient's toplevel");
state.request_activation(token, approved, target.clone());
assert_eq!(state.seat.get_keyboard().unwrap().current_focus(), None);
}
#[test]
fn validates_owner_serial_and_original_interaction_age() {
let now = Instant::now();
let mut history = InputHistory::default();
let serial = Serial::from(12);
history.record(serial, "launcher", now);
assert_eq!(history.validate(serial, &"launcher", now), Some(now));
assert_eq!(history.validate(Serial::from(13), &"launcher", now), None);
assert_eq!(history.validate(serial, &"other-client", now), None);
assert_eq!(
history.validate(
serial,
&"launcher",
now + TOKEN_MAX_AGE + Duration::from_millis(1)
),
None
);
// An approved token retains the interaction time across client handoff.
let approved = ApprovedInteraction(history.validate(serial, &"launcher", now).unwrap());
assert_eq!(approved.0, now);
}
}
+73 -58
View File
@@ -1,42 +1,23 @@
use smithay::backend::allocator::dmabuf::Dmabuf;
use smithay::backend::renderer::utils::on_commit_buffer_handler;
use smithay::reexports::wayland_server::Client;
use smithay::reexports::calloop::Interest;
use smithay::reexports::wayland_server::protocol::wl_buffer;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::reexports::wayland_server::{Client, Resource};
use smithay::wayland::buffer::BufferHandler;
use smithay::wayland::compositor::{
CompositorClientState, CompositorHandler, CompositorState, get_parent, is_sync_subsurface,
BufferAssignment, CompositorClientState, CompositorHandler, CompositorState, SurfaceAttributes,
add_blocker, add_pre_commit_hook, get_parent, get_role, is_sync_subsurface, with_states,
};
use smithay::wayland::dmabuf::{
DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier, get_dmabuf,
};
use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier};
use smithay::wayland::shm::{ShmHandler, ShmState};
use super::{layer_shell, xdg_shell};
use crate::Ferese;
use crate::state::ClientState;
fn layer_affects_backdrop(layer: smithay::wayland::shell::wlr_layer::Layer) -> bool {
matches!(
layer,
smithay::wayland::shell::wlr_layer::Layer::Background | smithay::wayland::shell::wlr_layer::Layer::Bottom
)
}
impl Ferese {
fn surface_affects_backdrop(&self, surface: &WlSurface) -> bool {
let mut root = surface.clone();
while let Some(parent) = get_parent(&root) {
root = parent;
}
if matches!(&self.cursor_status, smithay::input::pointer::CursorImageStatus::Surface(cursor) if cursor == &root)
{
return false;
}
self.space
.layer_for_surface(&root, smithay::desktop::WindowSurfaceType::ALL)
.is_none_or(|layer| layer_affects_backdrop(layer.cached_state().layer))
}
}
impl CompositorHandler for Ferese {
fn compositor_state(&mut self) -> &mut CompositorState {
&mut self.compositor_state
@@ -51,6 +32,39 @@ impl CompositorHandler for Ferese {
fn new_surface(&mut self, surface: &WlSurface) {
self.update_surface_preferences(surface);
// A commit is not proof that the GPU has finished writing its dma-buf.
// Keep the previous surface state renderable until the acquire fence
// signals, including during resize snapshots and subsurface updates.
add_pre_commit_hook::<Self, _>(surface, |state, _dh, surface| {
let dmabuf = with_states(surface, |states| {
states
.cached_state
.get::<SurfaceAttributes>()
.pending()
.buffer
.as_ref()
.and_then(|assignment| match assignment {
BufferAssignment::NewBuffer(buffer) => get_dmabuf(buffer).ok().cloned(),
_ => None,
})
});
let Some((dmabuf, client)) = dmabuf.zip(surface.client()) else {
return;
};
if let Ok((blocker, source)) = dmabuf.generate_blocker(Interest::READ) {
let inserted = state.loop_handle.insert_source(source, move |_, _, state| {
let dh = state.display_handle.clone();
state
.client_compositor_state(&client)
.blocker_cleared(state, &dh);
Ok(())
});
match inserted {
Ok(_) => add_blocker(surface, blocker),
Err(error) => tracing::warn!(%error, "could not monitor surface GPU readiness"),
}
}
});
}
fn new_subsurface(&mut self, surface: &WlSurface, _parent: &WlSurface) {
@@ -58,23 +72,25 @@ impl CompositorHandler for Ferese {
}
fn commit(&mut self, surface: &WlSurface) {
if get_role(surface) == Some(smithay::wayland::shell::xdg::XDG_TOPLEVEL_ROLE)
&& let Some(window) = self.window_for_surface(surface)
&& let Some(toplevel) = window.toplevel()
&& !xdg_shell::validate_size_constraints(toplevel)
{
return;
}
self.update_surface_preferences(surface);
self.capture_resize_before_commit(surface);
if self.surface_affects_backdrop(surface) {
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
}
self.capture_close_before_commit(surface);
on_commit_buffer_handler::<Self>(surface);
crate::effects::commit_presentation(surface);
if !is_sync_subsurface(surface) {
let mut root = surface.clone();
while let Some(parent) = get_parent(&root) {
root = parent;
}
let window = {
self.space
.elements()
.find(|window| window.toplevel().is_some_and(|toplevel| toplevel.wl_surface() == &root))
.cloned()
};
let window = self.window_for_surface(&root);
if let Some(window) = window {
window.on_commit();
@@ -84,6 +100,7 @@ impl CompositorHandler for Ferese {
} else if self.windows.ids().contains_key(&window) {
self.remove_tiled_window(&window);
self.space.map_element(window.clone(), (0, 0), false);
self.invalidate_window_stacking();
self.restore_keyboard_focus();
}
if window
@@ -96,27 +113,31 @@ impl CompositorHandler for Ferese {
self.record_client_commit(&window);
}
}
self.refresh_idle_inhibition();
layer_shell::handle_commit(self, surface);
xdg_shell::handle_commit(&mut self.popups, &mut self.space, surface);
crate::backends::direct::render_all(self);
xdg_shell::handle_commit(self, surface);
self.position_committed_input_popup(surface);
crate::backends::direct::render_surface(self, surface);
}
fn destroyed(&mut self, surface: &WlSurface) {
if self.session_lock.active {
self.dmabuf_feedback.destroyed(surface);
if self.session_lock.active() {
self.session_lock
.surfaces
.retain(|_, lock| lock.wl_surface() != surface);
self.focus_lock_surface();
}
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
crate::backends::direct::render_all(self);
crate::backends::direct::render_surface(self, surface);
if self.idle_inhibitors.remove(surface).is_some() {
self.refresh_idle_inhibition();
}
if matches!(&self.cursor_status, smithay::input::pointer::CursorImageStatus::Surface(cursor) if cursor == surface)
{
self.cursor_status = smithay::input::pointer::CursorImageStatus::default_named();
crate::backends::direct::render_all(self);
crate::backends::direct::render_cursor(self);
}
}
}
@@ -130,9 +151,17 @@ impl DmabufHandler for Ferese {
&mut self.dmabuf_state
}
fn dmabuf_imported(&mut self, _global: &DmabufGlobal, dmabuf: Dmabuf, notifier: ImportNotifier) {
self.queue_dmabuf_import(dmabuf, notifier);
crate::backends::direct::render_all(self);
fn new_surface_feedback(
&mut self,
surface: &WlSurface,
global: &DmabufGlobal,
) -> Option<smithay::wayland::dmabuf::DmabufFeedback> {
self.dmabuf_feedback.new_surface(surface, *global)
}
fn dmabuf_imported(&mut self, global: &DmabufGlobal, dmabuf: Dmabuf, notifier: ImportNotifier) {
self.dmabuf_imports
.enqueue(*global, dmabuf, notifier, std::time::Instant::now());
}
}
@@ -141,17 +170,3 @@ impl ShmHandler for Ferese {
&self.shm_state
}
}
#[cfg(test)]
mod tests {
use smithay::wayland::shell::wlr_layer::Layer;
use super::*;
#[test]
fn panel_commits_do_not_invalidate_their_own_backdrop() {
assert!(!layer_affects_backdrop(Layer::Top));
assert!(!layer_affects_backdrop(Layer::Overlay));
assert!(layer_affects_backdrop(Layer::Bottom));
assert!(layer_affects_backdrop(Layer::Background));
}
}
+145
View File
@@ -0,0 +1,145 @@
use crate::Ferese;
use smithay::reexports::wayland_server::backend::ClientId;
use smithay::reexports::wayland_server::{
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, protocol as wl,
};
use smithay::reexports::{
wayland_protocols as protocols, wayland_protocols_misc as misc, wayland_protocols_wlr as wlr,
};
use smithay::wayland::{Dispatch2, GlobalDispatch2};
// Forward stock protocol interfaces through Smithay's current dispatch API.
// Keep xdg_toplevel and ext_session_lock_v1 explicit: Ferese validates their
// requests before delegation, including ownership of the active session lock.
macro_rules! delegate_protocols {
($($interface:path),* $(,)?) => {
$(
impl<U: Dispatch2<$interface, Ferese>> Dispatch<$interface, U> for Ferese {
fn request(
state: &mut Self,
client: &Client,
resource: &$interface,
request: <$interface as Resource>::Request,
data: &U,
display: &DisplayHandle,
init: &mut DataInit<'_, Self>,
) {
data.request(state, client, resource, request, display, init);
}
fn destroyed(state: &mut Self, client: ClientId, resource: &$interface, data: &U) {
data.destroyed(state, client, resource);
}
}
impl<U: GlobalDispatch2<$interface, Ferese>> GlobalDispatch<$interface, U> for Ferese {
fn bind(
state: &mut Self,
display: &DisplayHandle,
client: &Client,
resource: New<$interface>,
data: &U,
init: &mut DataInit<'_, Self>,
) {
data.bind(state, display, client, resource, init);
}
fn can_view(client: Client, data: &U) -> bool {
data.can_view(&client)
}
}
)*
};
}
delegate_protocols!(
wlr::virtual_pointer::v1::server::zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
wlr::virtual_pointer::v1::server::zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
protocols::ext::image_capture_source::v1::server::ext_image_capture_source_v1::ExtImageCaptureSourceV1,
protocols::ext::image_capture_source::v1::server::ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1,
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_manager_v1::ExtImageCopyCaptureManagerV1,
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_session_v1::ExtImageCopyCaptureSessionV1,
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_cursor_session_v1::ExtImageCopyCaptureCursorSessionV1,
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_frame_v1::ExtImageCopyCaptureFrameV1,
protocols::wp::idle_inhibit::zv1::server::zwp_idle_inhibit_manager_v1::ZwpIdleInhibitManagerV1,
protocols::wp::idle_inhibit::zv1::server::zwp_idle_inhibitor_v1::ZwpIdleInhibitorV1,
protocols::ext::data_control::v1::server::ext_data_control_device_v1::ExtDataControlDeviceV1,
protocols::ext::data_control::v1::server::ext_data_control_manager_v1::ExtDataControlManagerV1,
protocols::ext::data_control::v1::server::ext_data_control_source_v1::ExtDataControlSourceV1,
protocols::ext::idle_notify::v1::server::ext_idle_notification_v1::ExtIdleNotificationV1,
protocols::ext::idle_notify::v1::server::ext_idle_notifier_v1::ExtIdleNotifierV1,
protocols::ext::session_lock::v1::server::ext_session_lock_manager_v1::ExtSessionLockManagerV1,
protocols::ext::session_lock::v1::server::ext_session_lock_surface_v1::ExtSessionLockSurfaceV1,
protocols::wp::alpha_modifier::v1::server::wp_alpha_modifier_surface_v1::WpAlphaModifierSurfaceV1,
protocols::wp::alpha_modifier::v1::server::wp_alpha_modifier_v1::WpAlphaModifierV1,
protocols::wp::cursor_shape::v1::server::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1,
protocols::wp::cursor_shape::v1::server::wp_cursor_shape_manager_v1::WpCursorShapeManagerV1,
protocols::wp::fractional_scale::v1::server::wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1,
protocols::wp::fractional_scale::v1::server::wp_fractional_scale_v1::WpFractionalScaleV1,
protocols::wp::keyboard_shortcuts_inhibit::zv1::server::zwp_keyboard_shortcuts_inhibit_manager_v1::ZwpKeyboardShortcutsInhibitManagerV1,
protocols::wp::keyboard_shortcuts_inhibit::zv1::server::zwp_keyboard_shortcuts_inhibitor_v1::ZwpKeyboardShortcutsInhibitorV1,
protocols::wp::linux_dmabuf::zv1::server::zwp_linux_buffer_params_v1::ZwpLinuxBufferParamsV1,
protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_feedback_v1::ZwpLinuxDmabufFeedbackV1,
protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1,
protocols::wp::pointer_constraints::zv1::server::zwp_confined_pointer_v1::ZwpConfinedPointerV1,
protocols::wp::pointer_constraints::zv1::server::zwp_locked_pointer_v1::ZwpLockedPointerV1,
protocols::wp::pointer_constraints::zv1::server::zwp_pointer_constraints_v1::ZwpPointerConstraintsV1,
protocols::wp::presentation_time::server::wp_presentation::WpPresentation,
protocols::wp::presentation_time::server::wp_presentation_feedback::WpPresentationFeedback,
protocols::wp::primary_selection::zv1::server::zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1,
protocols::wp::primary_selection::zv1::server::zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1,
protocols::wp::primary_selection::zv1::server::zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,
protocols::wp::relative_pointer::zv1::server::zwp_relative_pointer_manager_v1::ZwpRelativePointerManagerV1,
protocols::wp::relative_pointer::zv1::server::zwp_relative_pointer_v1::ZwpRelativePointerV1,
protocols::wp::security_context::v1::server::wp_security_context_manager_v1::WpSecurityContextManagerV1,
protocols::wp::security_context::v1::server::wp_security_context_v1::WpSecurityContextV1,
protocols::wp::single_pixel_buffer::v1::server::wp_single_pixel_buffer_manager_v1::WpSinglePixelBufferManagerV1,
protocols::wp::text_input::zv3::server::zwp_text_input_manager_v3::ZwpTextInputManagerV3,
protocols::wp::text_input::zv3::server::zwp_text_input_v3::ZwpTextInputV3,
protocols::wp::viewporter::server::wp_viewport::WpViewport,
protocols::wp::viewporter::server::wp_viewporter::WpViewporter,
protocols::xdg::activation::v1::server::xdg_activation_token_v1::XdgActivationTokenV1,
protocols::xdg::activation::v1::server::xdg_activation_v1::XdgActivationV1,
protocols::xdg::decoration::zv1::server::zxdg_decoration_manager_v1::ZxdgDecorationManagerV1,
protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1,
protocols::xdg::foreign::zv2::server::zxdg_exported_v2::ZxdgExportedV2,
protocols::xdg::foreign::zv2::server::zxdg_exporter_v2::ZxdgExporterV2,
protocols::xdg::foreign::zv2::server::zxdg_imported_v2::ZxdgImportedV2,
protocols::xdg::foreign::zv2::server::zxdg_importer_v2::ZxdgImporterV2,
protocols::xdg::shell::server::xdg_popup::XdgPopup,
protocols::xdg::shell::server::xdg_positioner::XdgPositioner,
protocols::xdg::shell::server::xdg_surface::XdgSurface,
protocols::xdg::shell::server::xdg_wm_base::XdgWmBase,
protocols::xdg::toplevel_icon::v1::server::xdg_toplevel_icon_manager_v1::XdgToplevelIconManagerV1,
protocols::xdg::toplevel_icon::v1::server::xdg_toplevel_icon_v1::XdgToplevelIconV1,
protocols::xdg::xdg_output::zv1::server::zxdg_output_manager_v1::ZxdgOutputManagerV1,
protocols::xdg::xdg_output::zv1::server::zxdg_output_v1::ZxdgOutputV1,
misc::zwp_input_method_v2::server::zwp_input_method_keyboard_grab_v2::ZwpInputMethodKeyboardGrabV2,
misc::zwp_input_method_v2::server::zwp_input_method_manager_v2::ZwpInputMethodManagerV2,
misc::zwp_input_method_v2::server::zwp_input_method_v2::ZwpInputMethodV2,
misc::zwp_input_method_v2::server::zwp_input_popup_surface_v2::ZwpInputPopupSurfaceV2,
misc::zwp_virtual_keyboard_v1::server::zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1,
misc::zwp_virtual_keyboard_v1::server::zwp_virtual_keyboard_v1::ZwpVirtualKeyboardV1,
wlr::data_control::v1::server::zwlr_data_control_device_v1::ZwlrDataControlDeviceV1,
wlr::data_control::v1::server::zwlr_data_control_manager_v1::ZwlrDataControlManagerV1,
wlr::data_control::v1::server::zwlr_data_control_source_v1::ZwlrDataControlSourceV1,
wlr::layer_shell::v1::server::zwlr_layer_shell_v1::ZwlrLayerShellV1,
wlr::layer_shell::v1::server::zwlr_layer_surface_v1::ZwlrLayerSurfaceV1,
wl::wl_buffer::WlBuffer,
wl::wl_callback::WlCallback,
wl::wl_compositor::WlCompositor,
wl::wl_data_device::WlDataDevice,
wl::wl_data_device_manager::WlDataDeviceManager,
wl::wl_data_source::WlDataSource,
wl::wl_keyboard::WlKeyboard,
wl::wl_output::WlOutput,
wl::wl_pointer::WlPointer,
wl::wl_region::WlRegion,
wl::wl_seat::WlSeat,
wl::wl_shm::WlShm,
wl::wl_shm_pool::WlShmPool,
wl::wl_subcompositor::WlSubcompositor,
wl::wl_subsurface::WlSubsurface,
wl::wl_surface::WlSurface,
wl::wl_touch::WlTouch,
);
+101
View File
@@ -0,0 +1,101 @@
use std::fs::File;
use smithay::output::Output;
use smithay::reexports::wayland_protocols_wlr::gamma_control::v1::server::{
zwlr_gamma_control_manager_v1::{self, ZwlrGammaControlManagerV1},
zwlr_gamma_control_v1::{self, ZwlrGammaControlV1},
};
use smithay::reexports::wayland_server::{
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
};
use crate::Ferese;
use crate::backends::direct::gamma;
use crate::gamma::Ramp;
pub(crate) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, ZwlrGammaControlManagerV1, ()>(1, ());
}
impl GlobalDispatch<ZwlrGammaControlManagerV1, ()> for Ferese {
fn bind(
_state: &mut Self,
_display: &DisplayHandle,
_client: &Client,
resource: New<ZwlrGammaControlManagerV1>,
_data: &(),
data_init: &mut DataInit<'_, Self>,
) {
data_init.init(resource, ());
}
}
impl Dispatch<ZwlrGammaControlManagerV1, ()> for Ferese {
fn request(
state: &mut Self,
_client: &Client,
_resource: &ZwlrGammaControlManagerV1,
request: zwlr_gamma_control_manager_v1::Request,
_data: &(),
_display: &DisplayHandle,
data_init: &mut DataInit<'_, Self>,
) {
match request {
zwlr_gamma_control_manager_v1::Request::GetGammaControl { id, output } => {
let output = Output::from_resource(&output);
let control = data_init.init(id, output.clone());
if let Some(size) = output
.as_ref()
.and_then(|output| gamma::acquire(state, output, &control))
{
control.gamma_size(size as u32);
} else {
control.failed();
}
}
zwlr_gamma_control_manager_v1::Request::Destroy => {}
_ => unreachable!(),
}
}
}
impl Dispatch<ZwlrGammaControlV1, Option<Output>> for Ferese {
fn request(
state: &mut Self,
_client: &Client,
control: &ZwlrGammaControlV1,
request: zwlr_gamma_control_v1::Request,
output: &Option<Output>,
_display: &DisplayHandle,
_data_init: &mut DataInit<'_, Self>,
) {
match request {
zwlr_gamma_control_v1::Request::SetGamma { fd } => {
let Some(output) = output else { return };
let Some(size) = gamma::size(state, output, control) else {
return;
};
match Ramp::read(File::from(fd), size) {
Ok(ramp) => gamma::set(state, output, control, ramp),
Err(error) => control.post_error(
zwlr_gamma_control_v1::Error::InvalidGamma,
format!("Invalid gamma table: {error}"),
),
}
}
zwlr_gamma_control_v1::Request::Destroy => {}
_ => unreachable!(),
}
}
fn destroyed(
state: &mut Self,
_client: smithay::reexports::wayland_server::backend::ClientId,
control: &ZwlrGammaControlV1,
output: &Option<Output>,
) {
if let Some(output) = output {
gamma::release(state, output, control);
}
}
}
+952
View File
@@ -0,0 +1,952 @@
//! Persistent, damage-driven capture for the native portal. One frame may be
//! outstanding per session; client buffers remain owned until GPU completion.
use smithay::reexports::calloop::RegistrationToken;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use ferese_layout::WindowId;
use smithay::backend::allocator::{Fourcc, Modifier};
use smithay::backend::egl::EGLDevice;
use smithay::backend::renderer::gles::{Capability, GlesRenderer, GlesTexture};
use smithay::backend::renderer::Texture;
use smithay::backend::renderer::utils::{CommitCounter, RendererSurfaceStateUserData, SurfaceView};
use smithay::output::{Output, WeakOutput};
use smithay::reexports::wayland_protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_frame_v1::FailureReason;
use smithay::reexports::wayland_server::backend::ObjectId;
use smithay::reexports::wayland_server::{Client, DisplayHandle, Resource};
use smithay::reexports::wayland_server::protocol::wl_shm;
use smithay::utils::{Buffer, Logical, Point, Rectangle, Size, Transform};
use smithay::wayland::compositor::{TraversalAction, with_surface_tree_downward};
use smithay::wayland::image_capture_source::{ImageCaptureSource, ImageCaptureSourceHandler, OutputCaptureSourceHandler, OutputCaptureSourceState};
use smithay::wayland::image_copy_capture::{BufferConstraints, DmabufConstraints, Frame, FrameRef, ImageCopyCaptureHandler, ImageCopyCaptureState, Session, SessionRef};
use crate::Ferese;
const MAX_SESSIONS: usize = 16;
// Reserve for the maximum eight portal pool buffers, a retained target and a
// fallback transfer texture. Imports of the same allocation are not extra pixels.
const MAX_STREAM_BYTES: u64 = 512 * 1024 * 1024;
fn stream_bytes(size: Size<i32, Buffer>) -> u64 {
// The portal rejects DMA strides above this allowance. Driver bookkeeping
// and encoder memory are separate from this pixel-storage reservation.
let stride = (size.w.max(0) as u64 * 4 + 4095) & !4095;
stride
.saturating_mul(size.h.max(0) as u64)
.saturating_mul(10)
}
pub(super) enum Source {
Output(WeakOutput),
Window(WindowId),
Workspace(super::workspace_capture::WorkspaceSource),
}
#[derive(Default)]
pub(super) struct SourcePaused(pub std::sync::atomic::AtomicBool);
fn source_active(source: &ImageCaptureSource) -> bool {
source
.user_data()
.get::<SourcePaused>()
.is_none_or(|paused| !paused.0.load(std::sync::atomic::Ordering::Relaxed))
}
#[derive(PartialEq)]
struct SurfaceStamp {
id: ObjectId,
commit: CommitCounter,
view: SurfaceView,
scale: i32,
transform: Transform,
alpha: Option<f32>,
}
#[derive(PartialEq)]
struct WindowStamp {
surfaces: Vec<SurfaceStamp>,
geometry: Rectangle<i32, Logical>,
scale: f64,
epoch: u64,
cursor: Option<CursorStamp>,
}
#[derive(PartialEq)]
pub(super) struct CursorStamp {
location: Point<f64, Logical>,
geometry: Rectangle<i32, Logical>,
image: CursorImageStamp,
}
#[derive(PartialEq)]
enum CursorImageStamp {
Named(Option<smithay::input::pointer::CursorIcon>, Instant, usize),
Surface(Vec<SurfaceStamp>, Point<i32, Logical>),
Hidden,
}
fn surface_stamp(
root: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
crop: Option<Rectangle<i32, Logical>>,
) -> Vec<SurfaceStamp> {
let mut stamp = Vec::new();
with_surface_tree_downward(
root,
Point::<f64, Logical>::default(),
|surface, states, location| {
let Some(data) = states.data_map.get::<RendererSurfaceStateUserData>() else {
return TraversalAction::SkipChildren;
};
use smithay::wayland::alpha_modifier::AlphaModifierSurfaceCachedState;
let alpha = states
.cached_state
.get::<AlphaModifierSurfaceCachedState>()
.current()
.multiplier_f32();
let data = data.lock().expect("surface metadata mutex poisoned");
let Some(view) = data.view() else {
return TraversalAction::SkipChildren;
};
let location = *location + view.offset.to_f64();
if crop.is_none_or(|crop| {
Rectangle::new(location, view.dst.to_f64()).overlaps(crop.to_f64())
}) {
stamp.push(SurfaceStamp {
id: surface.id(),
commit: data.current_commit(),
view,
scale: data.buffer_scale(),
transform: data.buffer_transform(),
alpha,
});
}
// An off-crop parent can have a child offset back into the captured area.
TraversalAction::DoChildren(location)
},
|_, _, _| {},
|_, _, _| true,
);
stamp
}
fn pending_frame_callbacks(
root: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
) -> bool {
let mut pending = false;
with_surface_tree_downward(
root,
(),
|_, states, _| {
if states
.data_map
.get::<RendererSurfaceStateUserData>()
.is_some_and(|data| {
data.lock()
.expect("surface metadata mutex poisoned")
.view()
.is_none()
})
{
return TraversalAction::SkipChildren;
}
pending |= !states
.cached_state
.get::<smithay::wayland::compositor::SurfaceAttributes>()
.current()
.frame_callbacks
.is_empty();
TraversalAction::DoChildren(())
},
|_, _, _| {},
|_, _, _| true,
);
pending
}
pub(super) struct Active {
pub(super) session: Session,
pub(super) pending: Option<Frame>,
pub(super) revision: Option<u64>,
window_stamp: Option<WindowStamp>,
size: Size<i32, Buffer>,
dma_node: Option<smithay::backend::drm::DrmNode>,
}
struct InFlight {
frame: Frame,
session: SessionRef,
epoch: u64,
fence: RegistrationToken,
timeout: RegistrationToken,
}
pub(crate) struct CaptureSessions {
copy: ImageCopyCaptureState,
outputs: OutputCaptureSourceState,
active: Vec<Active>,
in_flight: HashMap<u64, InFlight>,
next_fence: u64,
callback_timer: Option<RegistrationToken>,
callback_deadline: Option<Instant>,
workspace_callbacks: HashMap<ferese_core::WorkspaceId, Instant>,
workspaces:
HashMap<(ferese_core::WorkspaceId, bool), super::workspace_capture::WorkspaceCapture>,
}
fn authorized(client: &Client) -> bool {
super::screencopy::capture_allowed()
&& client
.get_data::<crate::state::ClientState>()
.is_some_and(|state| {
state
.capabilities
.contains(crate::private_client::ClientCapabilities::WINDOW_CAPTURE)
})
}
impl CaptureSessions {
pub(super) fn workspace_callback_deadline(
&self,
id: ferese_core::WorkspaceId,
now: Instant,
) -> Instant {
self.workspace_callbacks.get(&id).copied().unwrap_or(now)
}
pub(super) fn set_workspace_callback_deadline(
&mut self,
id: ferese_core::WorkspaceId,
deadline: Instant,
) {
self.workspace_callbacks.insert(id, deadline);
}
pub(crate) fn invalidate_workspaces(&mut self) {
for view in self.workspaces.values_mut() {
view.invalidate();
}
}
pub(crate) fn workspace_changed(&mut self, id: ferese_core::WorkspaceId) {
for ((workspace, _), view) in &mut self.workspaces {
if *workspace == id {
view.content_changed();
}
}
}
pub(crate) fn workspace_geometry_changed(&mut self, id: ferese_core::WorkspaceId) {
for ((workspace, _), view) in &mut self.workspaces {
if *workspace == id {
view.invalidate();
}
}
}
pub(crate) fn captured_workspaces(&self) -> Vec<ferese_core::WorkspaceId> {
self.workspaces.keys().map(|(id, _)| *id).collect()
}
pub(crate) fn new(display: &DisplayHandle) -> Self {
Self {
copy: ImageCopyCaptureState::new_with_filter::<Ferese, _>(display, authorized),
outputs: OutputCaptureSourceState::new_with_filter::<Ferese, _>(display, authorized),
active: Vec::new(),
in_flight: HashMap::new(),
next_fence: 0,
callback_timer: None,
callback_deadline: None,
workspace_callbacks: HashMap::new(),
workspaces: HashMap::new(),
}
}
pub(crate) fn has_sessions(&self) -> bool {
self.active.iter().any(|active| {
active.pending.is_some()
&& matches!(
active.session.source().user_data().get::<Source>(),
Some(Source::Output(_))
)
})
}
pub(super) fn pending_output(&self, output: &Output, cursor: bool) -> bool {
self.active.iter().any(|active| {
active.pending.is_some()
&& active.session.draw_cursor() == cursor
&& matches!(active.session.source().user_data().get::<Source>(),
Some(Source::Output(weak)) if weak.upgrade().as_ref() == Some(output))
})
}
}
pub(super) fn dma_constraints(renderer: &GlesRenderer) -> Option<Arc<DmabufConstraints>> {
struct Cached(Option<Arc<DmabufConstraints>>);
renderer
.egl_context()
.user_data()
.get_or_insert_threadsafe(|| Cached(query_dma_constraints(renderer).map(Arc::new)))
.0
.clone()
}
fn query_dma_constraints(renderer: &GlesRenderer) -> Option<DmabufConstraints> {
if !renderer.capabilities().contains(&Capability::ExportFence) {
return None;
}
let display = renderer.egl_context().display();
if !display
.extensions()
.iter()
.any(|extension| extension == "EGL_ANDROID_native_fence_sync")
{
return None;
}
let node = EGLDevice::device_for_display(display)
.ok()?
.try_get_render_node()
.ok()??;
let format = smithay::backend::allocator::Format {
code: Fourcc::Xrgb8888,
modifier: Modifier::Linear,
};
display
.dmabuf_render_formats()
.contains(&format)
.then_some(DmabufConstraints {
node,
formats: vec![(Fourcc::Xrgb8888, vec![Modifier::Linear])],
})
}
impl ImageCaptureSourceHandler for Ferese {}
impl OutputCaptureSourceHandler for Ferese {
fn output_capture_source_state(&mut self) -> &mut OutputCaptureSourceState {
&mut self.image_capture.outputs
}
fn output_source_created(&mut self, source: ImageCaptureSource, output: &Output) {
source
.user_data()
.insert_if_missing(|| Source::Output(output.downgrade()));
}
}
impl ImageCopyCaptureHandler for Ferese {
fn image_copy_capture_state(&mut self) -> &mut ImageCopyCaptureState {
&mut self.image_capture.copy
}
fn capture_constraints(&mut self, source: &ImageCaptureSource) -> Option<BufferConstraints> {
let (size, output) = self.capture_source_geometry(source)?;
Some(BufferConstraints {
size,
shm: vec![wl_shm::Format::Xrgb8888],
dma: self.capture_dma_constraints(&output).as_deref().cloned(),
})
}
fn new_session(&mut self, session: Session) {
if self.image_capture.active.len() >= MAX_SESSIONS {
return;
}
let Some(constraints) = session.current_constraints() else {
return;
};
let reserved: u64 = self
.image_capture
.active
.iter()
.map(|active| stream_bytes(active.size))
.sum();
if stream_bytes(constraints.size) > MAX_STREAM_BYTES.saturating_sub(reserved) {
return;
}
self.image_capture.active.push(Active {
session,
pending: None,
revision: None,
window_stamp: None,
size: constraints.size,
dma_node: constraints.dma.as_ref().map(|dma| dma.node),
});
}
fn frame(&mut self, session: &SessionRef, frame: Frame) {
let Some(active) = self
.image_capture
.active
.iter_mut()
.find(|active| active.session.as_ref() == *session)
else {
return;
};
if active.pending.is_some() {
frame.fail(FailureReason::Unknown);
return;
}
active.pending = Some(frame);
if let Some(Source::Output(weak)) = session.source().user_data().get::<Source>()
&& let Some(output) = weak.upgrade()
{
crate::backends::direct::schedule_capture(self, &[output]);
}
}
fn session_destroyed(&mut self, session: SessionRef) {
self.image_capture
.active
.retain(|active| active.session.as_ref() != session);
let fences: Vec<_> = self
.image_capture
.in_flight
.iter()
.filter_map(|(id, pending)| (pending.session == session).then_some(*id))
.collect();
for id in fences {
self.cancel_capture_fence(id);
}
}
fn frame_aborted(&mut self, frame: FrameRef) {
let fences: Vec<_> = self
.image_capture
.in_flight
.iter()
.filter_map(|(id, pending)| (pending.frame == frame).then_some(*id))
.collect();
for id in fences {
self.cancel_capture_fence(id);
}
for active in &mut self.image_capture.active {
if active
.pending
.as_ref()
.is_some_and(|pending| *pending == frame)
{
active.pending.take();
}
}
}
}
impl Ferese {
pub(super) fn capture_cursor_stamp(
&self,
geometry: Rectangle<i32, Logical>,
) -> Option<CursorStamp> {
use smithay::input::pointer::CursorImageStatus;
let pointer = self.seat.get_pointer()?;
let image = match &self.cursor_status {
CursorImageStatus::Named(_) => {
let (icon, epoch, frame) = self.cursor_animation.capture_revision();
CursorImageStamp::Named(icon, epoch, frame)
}
CursorImageStatus::Surface(surface) => {
let hotspot = smithay::wayland::compositor::with_states(surface, |states| {
states
.data_map
.get::<smithay::input::pointer::CursorImageSurfaceData>()
.map(|data| data.lock().expect("cursor metadata mutex poisoned").hotspot)
.unwrap_or_default()
});
CursorImageStamp::Surface(surface_stamp(surface, None), hotspot)
}
CursorImageStatus::Hidden => CursorImageStamp::Hidden,
};
Some(CursorStamp {
location: pointer.current_location(),
geometry,
image,
})
}
fn capture_source_geometry(
&self,
source: &ImageCaptureSource,
) -> Option<(Size<i32, Buffer>, Output)> {
if self.session_lock.active() || !source.alive() || !super::screencopy::capture_allowed() {
return None;
}
let (size, output) = match source.user_data().get::<Source>()? {
Source::Workspace(view) => {
if !view.valid(self) {
return None;
}
(view.size, view.output.clone())
}
Source::Window(id) => {
let info = self.window_capture_info(*id)?;
let size = info.geometry.size.to_physical_precise_round(info.scale);
(Size::from((size.w, size.h)), info.output)
}
Source::Output(weak) => {
let output = weak.upgrade()?;
if !self.output_ids.contains_key(&output) {
return None;
}
let size = output
.current_transform()
.transform_size(output.current_mode()?.size);
(Size::from((size.w, size.h)), output)
}
};
let bytes = u64::try_from(size.w)
.ok()?
.checked_mul(u64::try_from(size.h).ok()?)?
.checked_mul(4)?;
if bytes == 0 || bytes > 128 * 1024 * 1024 {
return None;
}
Some((size, output))
}
fn capture_dma_constraints(&mut self, output: &Output) -> Option<Arc<DmabufConstraints>> {
if let Some(backend) = self.nested_backend.clone() {
backend
.try_borrow_mut()
.ok()
.and_then(|mut backend| dma_constraints(backend.renderer()))
} else {
self.direct_backend
.as_mut()
.and_then(|backend| backend.capture_renderer(output))
.and_then(|renderer| dma_constraints(renderer))
}
}
pub(super) fn refresh_capture_source(&mut self, source: &ImageCaptureSource) {
let mut outputs = Vec::new();
for active in &mut self.image_capture.active {
if active.session.source() != *source {
continue;
}
active.revision = None;
active.window_stamp = None;
if active.pending.is_some()
&& let Some(Source::Output(weak)) = source.user_data().get::<Source>()
&& let Some(output) = weak.upgrade()
&& !outputs.contains(&output)
{
outputs.push(output);
}
}
if !outputs.is_empty() {
crate::backends::direct::schedule_capture(self, &outputs);
}
}
pub(crate) fn refresh_capture_sessions(&mut self) {
self.image_capture.copy.cleanup();
let mut callback_delay: Option<Duration> = None;
// Yield between distinct workspace compositions so input and display
// deadlines can run even when several consumers request changed views.
let mut workspace_compositions = 1usize;
let mut sessions = std::mem::take(&mut self.image_capture.active);
let mut workspaces = std::mem::take(&mut self.image_capture.workspaces);
let mut reserved: u64 = sessions
.iter()
.map(|active| stream_bytes(active.size))
.sum();
sessions.retain_mut(|active| {
let Some((size, output)) = self.capture_source_geometry(&active.session.source())
else {
self.cancel_session_fences(active.session.as_ref());
reserved = reserved.saturating_sub(stream_bytes(active.size));
return false;
};
let dma = self.capture_dma_constraints(&output);
let dma_node = dma.as_ref().map(|dma| dma.node);
if size != active.size || dma_node != active.dma_node {
let next_reserved = reserved
.saturating_sub(stream_bytes(active.size))
.saturating_add(stream_bytes(size));
if next_reserved > MAX_STREAM_BYTES {
self.cancel_session_fences(active.session.as_ref());
reserved = reserved.saturating_sub(stream_bytes(active.size));
return false;
}
reserved = next_reserved;
if let Some(Source::Workspace(source)) =
active.session.source().user_data().get::<Source>()
{
// GPU resources belong to the renderer that created them.
workspaces.remove(&(source.id, active.session.draw_cursor()));
}
active.size = size;
active.dma_node = dma_node;
active.revision = None;
active.window_stamp = None;
active.session.update_constraints(BufferConstraints {
size,
shm: vec![wl_shm::Format::Xrgb8888],
dma: dma.as_deref().cloned(),
});
if let Some(frame) = active.pending.take() {
frame.fail(FailureReason::BufferConstraints);
}
}
if let Some(Source::Workspace(source)) =
active.session.source().user_data().get::<Source>()
&& source_active(&active.session.source())
{
let source = source.clone();
let key = (source.id, active.session.draw_cursor());
if let Some(frame) = active.pending.take() {
let view = workspaces.entry(key).or_insert_with(|| {
super::workspace_capture::WorkspaceCapture::new(
source.clone(),
self.capture_epoch,
)
});
if !view.source.valid(self) {
*view = super::workspace_capture::WorkspaceCapture::new(
source.clone(),
self.capture_epoch,
);
}
if let Some(delay) = super::workspace_capture::process(
view,
self,
active,
frame,
&mut workspace_compositions,
) {
callback_delay =
Some(callback_delay.map_or(delay, |existing| existing.min(delay)));
}
}
}
if let Some(Source::Window(id)) = active.session.source().user_data().get::<Source>() {
let id = *id;
if let Some(frame) = active.pending.take() {
let Some(info) = self.window_capture_info(id) else {
return false;
};
let callback_interval = Duration::from_nanos(
1_000_000_000_000
/ info
.output
.current_mode()
.map(|mode| mode.refresh.max(1000) as u64)
.unwrap_or(60_000),
);
if let Some(toplevel) = info.window.toplevel()
&& pending_frame_callbacks(toplevel.wl_surface())
{
smithay::desktop::utils::send_frames_surface_tree(
toplevel.wl_surface(),
&info.output,
self.start_time.elapsed(),
Some(callback_interval),
|_, _| None,
);
}
let mut stamp = WindowStamp {
surfaces: Vec::new(),
geometry: info.geometry,
scale: info.scale,
epoch: self.capture_epoch,
cursor: None,
};
if let Some(toplevel) = info.window.toplevel() {
stamp.surfaces = surface_stamp(toplevel.wl_surface(), Some(info.geometry));
}
let cursor = active
.session
.draw_cursor()
.then(|| self.window_capture_cursor(id, &info.window, info.geometry))
.flatten();
if let Some(geometry) = cursor
&& let Some(pointer) = self.seat.get_pointer()
{
use smithay::input::pointer::CursorImageStatus;
let image = match &self.cursor_status {
CursorImageStatus::Named(_) => {
let (icon, epoch, frame) = self.cursor_animation.capture_revision();
CursorImageStamp::Named(icon, epoch, frame)
}
CursorImageStatus::Surface(surface) => {
let hotspot =
smithay::wayland::compositor::with_states(surface, |states| {
states
.data_map
.get::<smithay::input::pointer::CursorImageSurfaceData>(
)
.map(|data| {
data.lock()
.expect("cursor metadata mutex poisoned")
.hotspot
})
.unwrap_or_default()
});
CursorImageStamp::Surface(surface_stamp(surface, None), hotspot)
}
CursorImageStatus::Hidden => CursorImageStamp::Hidden,
};
stamp.cursor = Some(CursorStamp {
location: pointer.current_location(),
geometry,
image,
});
}
if active.window_stamp.as_ref() == Some(&stamp) {
active.pending = Some(frame);
if info
.window
.toplevel()
.is_some_and(|toplevel| pending_frame_callbacks(toplevel.wl_surface()))
{
callback_delay =
Some(callback_delay.map_or(callback_interval, |delay| {
delay.min(callback_interval)
}));
}
} else {
crate::backends::direct::capture::touch_cache(self);
let result = if let Some(backend) = self.nested_backend.clone() {
backend
.try_borrow_mut()
.map_err(|error| error.to_string())
.and_then(|mut backend| {
crate::render::capture_stream_window(
backend.renderer(),
self,
&info,
cursor,
&frame.buffer(),
)
})
} else if let Some(mut backend) = self.direct_backend.take() {
let result = backend
.capture_renderer(&info.output)
.ok_or_else(|| "Capture output is unavailable".to_string())
.and_then(|renderer| {
crate::render::capture_stream_window(
renderer,
self,
&info,
cursor,
&frame.buffer(),
)
});
self.direct_backend = Some(backend);
result
} else {
Err("Capture renderer is unavailable".into())
};
active.window_stamp = Some(stamp);
self.finish_stream_frame(frame, active.session.as_ref(), result);
}
}
}
true
});
workspaces.retain(|(id, cursor), view| {
let matches = |active: &Active| {
active.session.draw_cursor() == *cursor
&& matches!(active.session.source().user_data().get::<Source>(), Some(Source::Workspace(source)) if source.id == *id)
};
if !sessions.iter().any(matches) { return false; }
let pending = sessions.iter().any(|active| matches(active)
&& active.pending.is_some() && source_active(&active.session.source()));
if !pending && view.target.is_some() {
let idle = view.last_used.elapsed();
if idle >= Duration::from_secs(2) {
view.release_idle_target();
} else {
let delay = Duration::from_secs(2) - idle;
callback_delay = Some(callback_delay.map_or(delay, |existing| existing.min(delay)));
}
}
true
});
self.image_capture
.workspace_callbacks
.retain(|id, _| workspaces.keys().any(|(workspace, _)| id == workspace));
self.image_capture.workspaces = workspaces;
self.image_capture.active = sessions;
if let Some(delay) = callback_delay {
let deadline = Instant::now() + delay;
if self
.image_capture
.callback_deadline
.is_none_or(|previous| deadline < previous)
{
if let Some(timer) = self.image_capture.callback_timer.take() {
self.loop_handle.remove(timer);
}
self.image_capture.callback_deadline = Some(deadline);
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
match self
.loop_handle
.insert_source(Timer::from_duration(delay), |_, _, state| {
state.image_capture.callback_timer = None;
state.image_capture.callback_deadline = None;
TimeoutAction::Drop
}) {
Ok(token) => self.image_capture.callback_timer = Some(token),
Err(error) => {
self.image_capture.callback_deadline = None;
tracing::warn!(%error, "could not schedule capture callbacks")
}
}
}
} else if let Some(timer) = self.image_capture.callback_timer.take() {
self.loop_handle.remove(timer);
self.image_capture.callback_deadline = None;
}
}
pub(crate) fn process_output_streams(
&mut self,
renderer: &mut GlesRenderer,
texture: &GlesTexture,
output: &Output,
cursor: bool,
revision: u64,
) {
let mut sessions = std::mem::take(&mut self.image_capture.active);
for active in &mut sessions {
let matches = matches!(active.session.source().user_data().get::<Source>(), Some(Source::Output(weak)) if weak.upgrade().as_ref() == Some(output));
if matches {
let size = output.current_transform().transform_size(texture.size());
if active.size != Size::from((size.w, size.h)) {
if let Some(frame) = active.pending.take() {
frame.fail(FailureReason::BufferConstraints);
}
active.revision = None;
continue;
}
}
if matches
&& active.session.draw_cursor() == cursor
&& active.revision != Some(revision)
&& let Some(frame) = active.pending.take()
{
let result = crate::render::capture_stream_texture(
renderer,
texture,
&frame.buffer(),
output.current_transform(),
false,
);
active.revision = Some(revision);
self.finish_stream_frame(frame, active.session.as_ref(), result);
}
}
self.image_capture.active = sessions;
}
fn cancel_session_fences(&mut self, session: SessionRef) {
let fences: Vec<_> = self
.image_capture
.in_flight
.iter()
.filter_map(|(id, pending)| (pending.session == session).then_some(*id))
.collect();
for id in fences {
self.cancel_capture_fence(id);
}
}
fn cancel_capture_fence(&mut self, id: u64) {
if let Some(pending) = self.image_capture.in_flight.remove(&id) {
self.loop_handle.remove(pending.fence);
self.loop_handle.remove(pending.timeout);
}
}
pub(super) fn finish_stream_frame(
&mut self,
frame: Frame,
session: SessionRef,
result: Result<smithay::backend::renderer::sync::SyncPoint, String>,
) {
match result {
Ok(sync) if sync.is_reached() => frame.success(
Transform::Normal,
None,
smithay::utils::Clock::<smithay::utils::Monotonic>::new().now(),
),
Ok(sync) => {
let Some(fd) = sync.export() else {
frame.fail(FailureReason::Unknown);
return;
};
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use smithay::reexports::calloop::{Interest, Mode, PostAction, generic::Generic};
self.image_capture.next_fence = self.image_capture.next_fence.wrapping_add(1);
let id = self.image_capture.next_fence;
let source = Generic::new(fd, Interest::READ, Mode::OneShot);
let fence = match self.loop_handle.insert_source(source, move |_, _, state| {
if let Some(pending) = state.image_capture.in_flight.remove(&id) {
state.loop_handle.remove(pending.timeout);
if pending.epoch == state.capture_epoch
&& state
.capture_source_geometry(&pending.session.source())
.is_some()
&& !pending.frame.has_failed()
{
pending.frame.success(
Transform::Normal,
None,
smithay::utils::Clock::<smithay::utils::Monotonic>::new().now(),
);
} else {
pending.frame.fail(
if state
.capture_source_geometry(&pending.session.source())
.is_some()
{
FailureReason::BufferConstraints
} else {
FailureReason::Stopped
},
);
}
}
Ok(PostAction::Remove)
}) {
Ok(token) => token,
Err(error) => {
tracing::warn!(%error, "could not register capture fence");
frame.fail(FailureReason::Unknown);
return;
}
};
let timeout = match self.loop_handle.insert_source(
Timer::from_duration(Duration::from_secs(5)),
move |_, _, state| {
if let Some(pending) = state.image_capture.in_flight.remove(&id) {
state.loop_handle.remove(pending.fence);
pending.frame.fail(FailureReason::Stopped);
state
.image_capture
.active
.retain(|active| active.session.as_ref() != pending.session);
}
TimeoutAction::Drop
},
) {
Ok(token) => token,
Err(error) => {
self.loop_handle.remove(fence);
tracing::warn!(%error, "could not register capture deadline");
frame.fail(FailureReason::Unknown);
return;
}
};
self.image_capture.in_flight.insert(
id,
InFlight {
frame,
session,
epoch: self.capture_epoch,
fence,
timeout,
},
);
}
Err(error) => {
tracing::warn!(%error, "stream capture failed");
frame.fail(FailureReason::Unknown);
}
}
}
}
#[cfg(test)]
#[path = "workspace_capture_tests.rs"]
mod workspace_tests;
+109 -7
View File
@@ -1,25 +1,32 @@
use smithay::desktop::{PopupKind, WindowSurfaceType, layer_map_for_output};
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Rectangle};
use smithay::utils::{Point, Size};
use smithay::wayland::input_method::{InputMethodHandler, PopupSurface};
use crate::Ferese;
impl InputMethodHandler for Ferese {
fn new_popup(&mut self, surface: PopupSurface) {
if let Err(error) = self.popups.track_popup(PopupKind::InputMethod(surface.clone())) {
self.position_input_popup(&surface);
if let Err(error) = self
.popups
.track_popup(PopupKind::InputMethod(surface.clone()))
{
tracing::warn!(?error, "failed to track input method popup");
}
crate::backends::direct::render_all(self);
crate::backends::direct::render_surface(self, surface.wl_surface());
}
fn dismiss_popup(&mut self, _surface: PopupSurface) {
fn dismiss_popup(&mut self, surface: PopupSurface) {
let outputs = self.surface_outputs(surface.wl_surface());
self.popups.cleanup();
crate::backends::direct::render_all(self);
crate::backends::direct::render_on(self, &outputs);
}
fn popup_repositioned(&mut self, _surface: PopupSurface) {
crate::backends::direct::render_all(self);
fn popup_repositioned(&mut self, surface: PopupSurface) {
self.position_input_popup(&surface);
crate::backends::direct::render_surface(self, surface.wl_surface());
}
fn parent_geometry(&self, parent: &WlSurface) -> Rectangle<i32, Logical> {
@@ -33,7 +40,10 @@ impl InputMethodHandler for Ferese {
return geometry;
}
let Some(layer) = self.space.layer_for_surface(parent, WindowSurfaceType::TOPLEVEL) else {
let Some(layer) = self
.space
.layer_for_surface(parent, WindowSurfaceType::TOPLEVEL)
else {
return Rectangle::default();
};
@@ -51,3 +61,95 @@ impl InputMethodHandler for Ferese {
.unwrap_or_default()
}
}
impl Ferese {
pub(crate) fn position_committed_input_popup(&mut self, surface: &WlSurface) {
if let Some(PopupKind::InputMethod(popup)) = self.popups.find_popup(surface) {
self.position_input_popup(&popup);
}
}
fn position_input_popup(&self, popup: &PopupSurface) {
let Some(parent) = popup.get_parent() else {
return;
};
let parent = self.parent_geometry(&parent.surface);
let cursor = popup.text_input_rectangle();
let caret = parent.loc + cursor.loc;
let output = self
.space
.outputs()
.filter_map(|output| self.space.output_geometry(output))
.find(|geometry| geometry.contains(caret))
.or_else(|| {
self.space
.outputs()
.find_map(|output| self.space.output_geometry(output))
});
let Some(output) = output else {
return;
};
let bounds = smithay::desktop::utils::bbox_from_surface_tree(popup.wl_surface(), (0, 0));
popup.set_location(input_popup_location(parent, cursor, bounds.size, output));
}
}
fn input_popup_location(
parent: Rectangle<i32, Logical>,
cursor: Rectangle<i32, Logical>,
size: Size<i32, Logical>,
output: Rectangle<i32, Logical>,
) -> Point<i32, Logical> {
let caret = parent.loc + cursor.loc;
let mut y = caret.y + cursor.size.h;
if y + size.h > output.loc.y + output.size.h {
y = caret.y - size.h;
}
let x = caret.x.clamp(
output.loc.x,
(output.loc.x + output.size.w - size.w).max(output.loc.x),
);
let y = y.clamp(
output.loc.y,
(output.loc.y + output.size.h - size.h).max(output.loc.y),
);
Point::from((x, y)) - parent.loc
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_popup_uses_cursor_and_flips_at_output_edges() {
let output = Rectangle::new((0, 0).into(), (1280, 800).into());
let parent = Rectangle::new((400, 36).into(), (880, 560).into());
assert_eq!(
input_popup_location(
parent,
Rectangle::new((28, 164).into(), (1, 24).into()),
(420, 219).into(),
output
),
Point::from((28, 188))
);
assert_eq!(
input_popup_location(
Rectangle::default(),
Rectangle::new((1230, 750).into(), (1, 20).into()),
(420, 220).into(),
output
),
Point::from((860, 530))
);
let left = Rectangle::new((-1280, 0).into(), (1280, 800).into());
assert_eq!(
input_popup_location(
Rectangle::default(),
Rectangle::new((-10, 780).into(), (1, 20).into()),
(420, 220).into(),
left
),
Point::from((-420, 560))
);
}
}
+315 -41
View File
@@ -1,4 +1,6 @@
use smithay::desktop::{LayerSurface as DesktopLayerSurface, PopupKind, WindowSurfaceType, layer_map_for_output};
use smithay::desktop::{
LayerSurface as DesktopLayerSurface, PopupKind, WindowSurfaceType, layer_map_for_output,
};
use smithay::output::Output;
use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
@@ -14,26 +16,45 @@ impl WlrLayerShellHandler for Ferese {
&mut self.layer_shell_state
}
fn new_layer_surface(&mut self, surface: LayerSurface, output: Option<WlOutput>, _layer: Layer, namespace: String) {
let output = output
.as_ref()
.and_then(Output::from_resource)
.or_else(|| self.space.outputs().next().cloned());
fn new_layer_surface(
&mut self,
surface: LayerSurface,
output: Option<WlOutput>,
_layer: Layer,
namespace: String,
) {
// A wl_output resource can outlive its registered desktop output.
// Explicit requests retain their identity; only unspecified requests
// may use the focused/default output policy.
let usable = |output: &Output| {
self.output_id(output).is_some()
&& output.current_mode().is_some()
&& self.space.outputs().any(|candidate| candidate == output)
};
let output = match output {
Some(resource) => Output::from_resource(&resource).filter(usable),
None => self
.focused_output()
.filter(|output| usable(output))
.cloned()
.or_else(|| self.space.outputs().find(|output| usable(output)).cloned()),
};
let Some(output) = output else {
tracing::warn!(%namespace, "layer surface has no available output");
surface.send_close();
return;
};
let layer = DesktopLayerSurface::new(surface, namespace.clone());
if let Err(error) = layer_map_for_output(&output).map_layer(&layer) {
tracing::warn!(%namespace, %error, "failed to map layer surface");
layer.layer_surface().send_close();
return;
}
tracing::debug!(%namespace, output = %output.name(), "mapped layer surface");
super::set_surface_tree_output(layer.wl_surface(), &output);
self.relayout();
crate::backends::direct::render_all(self);
self.relayout_on(&[output]);
}
fn new_popup(&mut self, parent: LayerSurface, popup: PopupSurface) {
@@ -52,6 +73,13 @@ impl WlrLayerShellHandler for Ferese {
}
fn layer_destroyed(&mut self, surface: LayerSurface) {
if self
.pending_layer_focus
.as_ref()
.is_some_and(|(target, _)| target == surface.wl_surface())
{
self.pending_layer_focus = None;
}
let Some(layer) = self
.space
.layer_for_surface(surface.wl_surface(), WindowSurfaceType::TOPLEVEL)
@@ -59,61 +87,307 @@ impl WlrLayerShellHandler for Ferese {
return;
};
let restore_focus = layer_has_keyboard_focus(self, &layer);
let outputs = self.space.outputs().cloned().collect::<Vec<_>>();
let outputs = self
.space
.outputs()
.filter(|output| {
layer_map_for_output(output)
.layer_geometry(&layer)
.is_some()
})
.cloned()
.collect::<Vec<_>>();
for output in outputs {
layer_map_for_output(&output).unmap_layer(&layer);
for output in &outputs {
layer_map_for_output(output).unmap_layer(&layer);
}
self.relayout();
self.relayout_on(&outputs);
if restore_focus {
self.restore_keyboard_focus();
}
crate::backends::direct::render_all(self);
crate::backends::direct::render_on(self, &outputs);
}
}
#[derive(Clone, Debug, PartialEq)]
struct LayoutState {
size: smithay::utils::Size<i32, smithay::utils::Logical>,
anchor: smithay::wayland::shell::wlr_layer::Anchor,
zone: smithay::wayland::shell::wlr_layer::ExclusiveZone,
margin: [i32; 4],
layer: Layer,
bounds: smithay::utils::Rectangle<i32, smithay::utils::Logical>,
}
impl LayoutState {
fn current(layer: &DesktopLayerSurface) -> Self {
Self::from_policy(layer.cached_state(), layer.bbox())
}
fn from_policy(
policy: smithay::wayland::shell::wlr_layer::LayerSurfaceCachedState,
bounds: smithay::utils::Rectangle<i32, smithay::utils::Logical>,
) -> Self {
Self {
size: policy.size,
anchor: policy.anchor,
zone: policy.exclusive_zone,
margin: [
policy.margin.top,
policy.margin.right,
policy.margin.bottom,
policy.margin.left,
],
layer: policy.layer,
bounds,
}
}
}
pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) {
let Some(layer) = state.space.layer_for_surface(surface, WindowSurfaceType::TOPLEVEL) else {
let Some(layer) = state
.space
.layer_for_surface(surface, WindowSurfaceType::TOPLEVEL)
else {
return;
};
let outputs = state.space.outputs().cloned().collect::<Vec<_>>();
let outputs = state
.space
.outputs()
.filter(|output| {
layer_map_for_output(output)
.layer_geometry(&layer)
.is_some()
})
.cloned()
.collect::<Vec<_>>();
for output in outputs {
let mut map = layer_map_for_output(&output);
if map.layer_geometry(&layer).is_some() {
map.arrange();
layer.layer_surface().send_pending_configure();
break;
}
}
let policy = layer.cached_state();
if !state.session_lock.active
&& !state.input_capture.captures(1)
&& policy.keyboard_interactivity == KeyboardInteractivity::Exclusive
&& matches!(policy.layer, Layer::Top | Layer::Overlay)
if !state.layer_mapped_and_visible(&layer)
&& state
.pending_layer_focus
.as_ref()
.is_some_and(|(target, _)| target == surface)
{
state.seat.get_keyboard().expect("seat has a keyboard").set_focus(
state,
Some(layer.wl_surface().clone()),
smithay::utils::SERIAL_COUNTER.next_serial(),
);
} else if !layer.can_receive_keyboard_focus() && layer_has_keyboard_focus(state, &layer) {
state.restore_keyboard_focus();
state.pending_layer_focus = None;
}
let policy = layer.cached_state();
let current = LayoutState::current(&layer);
let previous = layer.user_data().get_or_insert_threadsafe(|| {
std::sync::Mutex::new(None::<(LayoutState, KeyboardInteractivity)>)
});
let (changed, requested_focus) = {
let mut previous = previous
.lock()
.expect("layer configure state must remain consistent across callbacks");
let changed = previous.as_ref().map(|(layout, _)| layout) != Some(&current);
let requested_focus = policy.keyboard_interactivity == KeyboardInteractivity::OnDemand
&& previous
.as_ref()
.is_some_and(|(_, keyboard)| *keyboard == KeyboardInteractivity::None);
*previous = Some((current, policy.keyboard_interactivity));
(changed, requested_focus)
};
if changed {
state.relayout_on(&outputs);
}
// Configure acknowledgement and keyboard policy are independent of layout.
layer.layer_surface().send_pending_configure();
// Consume the opening interaction once. Later commits, pointer hover, or
// another input target must not give a panel a fresh claim to focus.
let opening_interaction = if requested_focus
&& state
.pending_layer_focus
.as_ref()
.is_some_and(|(target, _)| target == surface)
{
state
.pending_layer_focus
.take()
.is_some_and(|(_, at)| at.elapsed() <= std::time::Duration::from_secs(5))
} else {
false
};
state.reconcile_layer_keyboard_focus();
if opening_interaction
&& !state
.seat
.get_keyboard()
.is_some_and(|keyboard| keyboard.is_grabbed())
&& !state.session_lock.active()
&& !state.input_capture.captures(1)
&& state.layer_keyboard_eligible(&layer)
&& state.exclusive_keyboard_layer().is_none()
&& matches!(policy.layer, Layer::Top | Layer::Overlay)
&& !layer_has_keyboard_focus(state, &layer)
{
state
.seat
.get_keyboard()
.expect("seat has a keyboard")
.set_focus(
state,
Some(layer.wl_surface().clone()),
smithay::utils::SERIAL_COUNTER.next_serial(),
);
}
state.relayout();
if let Err(error) = state.display_handle.flush_clients() {
tracing::debug!(%error, "failed to flush layer-surface configure");
}
}
impl Ferese {
pub(crate) fn layer_keyboard_eligible(&self, layer: &DesktopLayerSurface) -> bool {
layer.can_receive_keyboard_focus() && self.layer_mapped_and_visible(layer)
}
/// Layer-map membership tracks the role and output, not buffer mapping.
/// Use the renderer state updated by on_commit_buffer_handler, as for windows.
fn layer_mapped_and_visible(&self, layer: &DesktopLayerSurface) -> bool {
layer.layer_surface().alive()
&& smithay::backend::renderer::utils::with_renderer_surface_state(
layer.wl_surface(),
|state| state.buffer().is_some(),
)
.unwrap_or(false)
&& self.space.outputs().any(|output| {
self.layer_visible_on_output(output, layer.layer())
&& layer_map_for_output(output)
.layers()
.any(|candidate| candidate == layer)
})
}
fn keyboard_layer_root(&self) -> Option<WlSurface> {
let surface = self.seat.get_keyboard()?.current_focus()?;
let root = self
.popups
.find_popup(&surface)
.and_then(|popup| smithay::desktop::find_popup_root_surface(&popup).ok())
.unwrap_or(surface);
(smithay::wayland::compositor::get_role(&root)
== Some(smithay::wayland::shell::wlr_layer::LAYER_SURFACE_ROLE))
.then_some(root)
}
pub(crate) fn keyboard_focused_layer(&self) -> Option<DesktopLayerSurface> {
self.space
.layer_for_surface(&self.keyboard_layer_root()?, WindowSurfaceType::ALL)
}
pub(crate) fn exclusive_keyboard_layer(&self) -> Option<DesktopLayerSurface> {
// Overlay outranks Top. Keep the current owner among peers; the protocol
// leaves ordering within one layer to the compositor.
let focused = self.keyboard_focused_layer();
for priority in [Layer::Overlay, Layer::Top] {
if let Some(layer) = focused.as_ref().filter(|layer| {
layer.layer() == priority
&& layer.cached_state().keyboard_interactivity
== KeyboardInteractivity::Exclusive
&& self.layer_keyboard_eligible(layer)
}) {
return Some(layer.clone());
}
for output in self.space.outputs() {
let layers = layer_map_for_output(output)
.layers_on(priority)
.rev()
.cloned()
.collect::<Vec<_>>();
if let Some(layer) = layers.into_iter().find(|layer| {
layer.cached_state().keyboard_interactivity == KeyboardInteractivity::Exclusive
&& self.layer_keyboard_eligible(layer)
}) {
return Some(layer.clone());
}
}
}
None
}
pub(crate) fn reconcile_layer_keyboard_focus(&mut self) {
if self.session_lock.active() || self.input_capture.captures(1) {
return;
}
let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
if let Some(root) = self.keyboard_layer_root().filter(|root| {
!self.keyboard_focused_layer().is_some_and(|layer| {
// A popup owns its keyboard focus independently of the bar's
// interactivity policy, but still needs a visible, mapped parent.
if keyboard.current_focus().as_ref() != Some(root) {
self.layer_mapped_and_visible(&layer)
} else {
self.layer_keyboard_eligible(&layer)
}
})
}) {
// A popup grab cannot keep an ineligible layer alive as input owner.
for (popup, _) in smithay::desktop::PopupManager::popups_for_surface(&root) {
let _ = smithay::desktop::PopupManager::dismiss_popup(&root, &popup);
}
keyboard.unset_grab(self);
self.restore_keyboard_focus();
} else if !keyboard.is_grabbed()
&& let Some(layer) = self.exclusive_keyboard_layer()
&& !layer_has_keyboard_focus(self, &layer)
{
keyboard.set_focus(
self,
Some(layer.wl_surface().clone()),
smithay::utils::SERIAL_COUNTER.next_serial(),
);
}
}
}
fn layer_has_keyboard_focus(state: &Ferese, layer: &DesktopLayerSurface) -> bool {
state
.seat
.get_keyboard()
.and_then(|keyboard| keyboard.current_focus())
.and_then(|surface| state.space.layer_for_surface(&surface, WindowSurfaceType::ALL))
.keyboard_focused_layer()
.is_some_and(|focused| focused == *layer)
}
#[cfg(test)]
mod tests {
use smithay::utils::Rectangle;
use smithay::wayland::shell::wlr_layer::{Anchor, ExclusiveZone, LayerSurfaceCachedState};
use super::*;
#[test]
fn only_arrangement_and_mapping_changes_require_layout() {
let policy = LayerSurfaceCachedState::default();
let bounds = Rectangle::from_size((800, 32).into());
let original = LayoutState::from_policy(policy, bounds);
assert_eq!(original, LayoutState::from_policy(policy, bounds));
let mut keyboard = policy;
keyboard.keyboard_interactivity = KeyboardInteractivity::Exclusive;
assert_eq!(original, LayoutState::from_policy(keyboard, bounds));
for mutate in [
|p: &mut LayerSurfaceCachedState| p.size.w += 1,
|p: &mut LayerSurfaceCachedState| p.anchor = Anchor::TOP,
|p: &mut LayerSurfaceCachedState| p.exclusive_zone = ExclusiveZone::Exclusive(32),
|p: &mut LayerSurfaceCachedState| p.margin.left += 1,
|p: &mut LayerSurfaceCachedState| p.layer = Layer::Overlay,
] {
let mut changed = policy;
mutate(&mut changed);
assert_ne!(original, LayoutState::from_policy(changed, bounds));
}
assert_ne!(
original,
LayoutState::from_policy(policy, Rectangle::from_size((800, 33).into()))
);
assert_ne!(
original,
LayoutState::from_policy(policy, Rectangle::default())
);
}
}
#[cfg(test)]
mod output_tests;
@@ -0,0 +1,321 @@
use super::*;
use crate::state::{ClientState, dispatch_test_client as dispatch, test_client_request as request};
use smithay::output::{Mode, PhysicalProperties, Subpixel};
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_protocols_wlr::layer_shell::v1::server::zwlr_layer_shell_v1::ZwlrLayerShellV1;
use smithay::reexports::wayland_server::Resource;
use smithay::reexports::wayland_server::protocol::wl_compositor::WlCompositor;
use std::io::Read;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
fn fixture() -> (Ferese, EventLoop<'static, Ferese>) {
let mut events = EventLoop::try_new().unwrap();
let state = crate::startup_tests::state(&mut events);
(state, events)
}
fn output(state: &mut Ferese, name: &str, x: i32) -> Output {
let output = Output::new(
name.into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: (800, 600).into(),
refresh: 60_000,
}),
None,
None,
None,
);
state.space.map_output(&output, (x, 0));
state.register_output(&output, name.into());
output
}
fn read(wire: &mut UnixStream) -> Vec<Vec<u32>> {
wire.set_nonblocking(true).unwrap();
let mut bytes = Vec::new();
assert_eq!(
wire.read_to_end(&mut bytes).unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
wire.set_nonblocking(false).unwrap();
let mut messages = Vec::new();
let mut offset = 0;
while offset < bytes.len() {
let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap());
let len = (header >> 16) as usize;
messages.push(
bytes[offset..offset + len]
.chunks_exact(4)
.map(|word| u32::from_ne_bytes(word.try_into().unwrap()))
.collect(),
);
offset += len;
}
messages
}
struct Client {
client: smithay::reexports::wayland_server::Client,
wire: UnixStream,
compositor: WlCompositor,
shell: ZwlrLayerShellV1,
}
impl Client {
fn new(state: &mut Ferese) -> Self {
let (server, mut wire) = UnixStream::pair().unwrap();
let client = state
.display_handle
.insert_client(server, Arc::new(ClientState::default()))
.unwrap();
let compositor = client
.create_resource::<WlCompositor, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
6,
smithay::wayland::GlobalData,
)
.unwrap();
let shell = client
.create_resource::<ZwlrLayerShellV1, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
4,
smithay::wayland::GlobalData,
)
.unwrap();
request(&mut wire, 1, 1, &[2], None); // Registry.
request(&mut wire, compositor.id().protocol_id(), 0, &[3], None);
// Keep client IDs contiguous: 5 is the optional wl_output, 6 the role.
request(&mut wire, compositor.id().protocol_id(), 0, &[4], None);
Self {
client,
wire,
compositor,
shell,
}
}
fn bind_output(
&mut self,
state: &mut Ferese,
events: &mut EventLoop<'static, Ferese>,
output: &Output,
) -> smithay::reexports::wayland_server::backend::GlobalId {
let global = output.create_global::<Ferese>(&state.display_handle);
dispatch(events, state);
let names = read(&mut self.wire)
.into_iter()
.filter(|message| {
message[0] == 2
&& message[1] & 0xffff == 0
&& message[3] == 10
&& message.get(4) == Some(&u32::from_ne_bytes(*b"wl_o"))
})
.map(|message| message[2])
.collect::<Vec<_>>();
assert_eq!(names.len(), 1);
request(
&mut self.wire,
2,
0,
&[
names[0],
10,
u32::from_ne_bytes(*b"wl_o"),
u32::from_ne_bytes(*b"utpu"),
u32::from_ne_bytes([b't', 0, 0, 0]),
4,
5,
],
None,
);
dispatch(events, state);
read(&mut self.wire);
global
}
fn create_layer(
&mut self,
state: &mut Ferese,
events: &mut EventLoop<'static, Ferese>,
output: u32,
) -> Vec<Vec<u32>> {
if output == 0 {
// Reserve the output slot with a spare surface for unspecified requests.
request(
&mut self.wire,
self.compositor.id().protocol_id(),
0,
&[5],
None,
);
}
request(
&mut self.wire,
self.shell.id().protocol_id(),
0,
&[6, 3, output, 2, 4, u32::from_ne_bytes(*b"bar\0")],
None,
);
request(&mut self.wire, 6, 0, &[64, 48], None);
request(&mut self.wire, 3, 6, &[], None);
dispatch(events, state);
read(&mut self.wire)
}
fn mapped_on(&self, output: &Output) -> bool {
layer_map_for_output(output).layers().any(|layer| {
layer
.wl_surface()
.client()
.is_some_and(|client| client.id() == self.client.id())
})
}
}
fn assert_closed_without_configure(messages: &[Vec<u32>]) {
assert!(
messages
.iter()
.any(|message| message[0] == 6 && message[1] & 0xffff == 1),
"layer must receive closed"
);
assert!(
!messages
.iter()
.any(|message| message[0] == 6 && message[1] & 0xffff == 0),
"closed layers must not wait for configure"
);
assert!(
!messages
.iter()
.any(|message| message[0] == 1 && message[1] & 0xffff == 0),
"no client protocol error"
);
}
#[test]
fn zero_outputs_close_new_layer_roles() {
if !crate::startup_tests::private_runtime(
"handlers::layer_shell::output_tests::zero_outputs_close_new_layer_roles",
) {
return;
}
let (mut state, mut events) = fixture();
let mut client = Client::new(&mut state);
assert_closed_without_configure(&client.create_layer(&mut state, &mut events, 0));
}
#[test]
fn stale_explicit_output_closes_instead_of_retargeting() {
if !crate::startup_tests::private_runtime(
"handlers::layer_shell::output_tests::stale_explicit_output_closes_instead_of_retargeting",
) {
return;
}
let (mut state, mut events) = fixture();
let stale = output(&mut state, "stale", 0);
let survivor = output(&mut state, "survivor", 800);
let mut client = Client::new(&mut state);
let global = client.bind_output(&mut state, &mut events, &stale);
state.unregister_output(&stale);
state.display_handle.remove_global::<Ferese>(global);
dispatch(&mut events, &mut state);
read(&mut client.wire);
let messages = client.create_layer(&mut state, &mut events, 5);
assert_closed_without_configure(&messages);
assert!(!client.mapped_on(&stale));
assert!(
!client.mapped_on(&survivor),
"explicit requests must not silently move to another output"
);
}
#[test]
fn unspecified_output_uses_focused_registered_output() {
if !crate::startup_tests::private_runtime(
"handlers::layer_shell::output_tests::unspecified_output_uses_focused_registered_output",
) {
return;
}
let (mut state, mut events) = fixture();
let first = output(&mut state, "first", 0);
let focused = output(&mut state, "focused", 800);
state.focus_output_at((820., 20.).into());
assert_eq!(state.focused_output(), Some(&focused));
let mut client = Client::new(&mut state);
let messages = client.create_layer(&mut state, &mut events, 0);
assert!(client.mapped_on(&focused));
assert!(!client.mapped_on(&first));
assert!(
messages
.iter()
.any(|message| message[0] == 6 && message[1] & 0xffff == 0),
"valid layer receives configure"
);
}
#[test]
fn valid_explicit_output_is_preserved() {
if !crate::startup_tests::private_runtime(
"handlers::layer_shell::output_tests::valid_explicit_output_is_preserved",
) {
return;
}
let (mut state, mut events) = fixture();
let explicit = output(&mut state, "explicit", 0);
let focused = output(&mut state, "focused", 800);
state.focus_output_at((820., 20.).into());
let mut client = Client::new(&mut state);
client.bind_output(&mut state, &mut events, &explicit);
let messages = client.create_layer(&mut state, &mut events, 5);
assert!(client.mapped_on(&explicit));
assert!(!client.mapped_on(&focused));
assert!(
messages
.iter()
.any(|message| message[0] == 6 && message[1] & 0xffff == 0)
);
}
#[test]
fn unregistered_output_is_not_a_creation_fallback() {
if !crate::startup_tests::private_runtime(
"handlers::layer_shell::output_tests::unregistered_output_is_not_a_creation_fallback",
) {
return;
}
let (mut state, mut events) = fixture();
let ghost = Output::new(
"ghost".into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
ghost.change_current_state(
Some(Mode {
size: (800, 600).into(),
refresh: 60_000,
}),
None,
None,
None,
);
state.space.map_output(&ghost, (0, 0));
let mut client = Client::new(&mut state);
assert_closed_without_configure(&client.create_layer(&mut state, &mut events, 0));
assert!(!client.mapped_on(&ghost));
}
+151 -69
View File
@@ -1,15 +1,24 @@
mod activation;
pub(crate) mod activation;
mod compositor;
mod dispatch;
pub(crate) mod gamma_control;
pub(crate) mod image_capture;
mod input_method;
mod layer_shell;
pub(crate) mod screencopy;
pub(crate) mod screenshot;
pub(crate) mod screenshot_worker;
pub(crate) mod security_context;
#[cfg(test)]
mod toplevel_icon_tests;
mod virtual_keyboard;
mod virtual_pointer;
pub(crate) mod window_capture;
mod xdg_shell;
use smithay::desktop::{PopupManager, layer_map_for_output};
use smithay::input::pointer::{CursorImageStatus, PointerHandle};
use smithay::input::tablet::TabletSeatHandler;
use smithay::input::{Seat, SeatHandler, SeatState};
use smithay::reexports::wayland_server::Resource;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
@@ -22,15 +31,16 @@ use smithay::wayland::keyboard_shortcuts_inhibit::{
KeyboardShortcutsInhibitorSeat,
};
use smithay::wayland::output::OutputHandler;
use smithay::wayland::pointer_constraints::{PointerConstraint, PointerConstraintsHandler, with_pointer_constraint};
use smithay::wayland::pointer_constraints::{
PointerConstraint, PointerConstraintsHandler, with_pointer_constraint,
};
use smithay::wayland::selection::SelectionHandler;
use smithay::wayland::selection::data_device::{
ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler, set_data_device_focus,
DataDeviceHandler, DataDeviceState, WaylandDndGrabHandler, set_data_device_focus,
};
use smithay::wayland::selection::primary_selection::{
PrimarySelectionHandler, PrimarySelectionState, set_primary_focus,
};
use smithay::wayland::tablet_manager::TabletSeatHandler;
use smithay::wayland::xdg_foreign::{XdgForeignHandler, XdgForeignState};
use smithay::wayland::xdg_toplevel_icon::XdgToplevelIconHandler;
@@ -46,12 +56,24 @@ impl SeatHandler for Ferese {
}
fn cursor_image(&mut self, _seat: &Seat<Self>, image: CursorImageStatus) {
if let CursorImageStatus::Named(icon) = &image {
self.named_cursors
.entry(*icon)
.or_insert_with(|| crate::cursor::load_named_cursor(&self.cursor_theme, *icon));
if let CursorImageStatus::Named(icon) = &image
&& !self.named_cursors.contains_key(icon)
{
self.cursor_loader
.request(*icon, self.cursor_animation.buffer_scale);
}
self.cursor_animation.reset |= match (&self.cursor_status, &image) {
(CursorImageStatus::Named(previous), CursorImageStatus::Named(next)) => {
previous != next
}
(CursorImageStatus::Hidden, CursorImageStatus::Hidden) => false,
(CursorImageStatus::Surface(previous), CursorImageStatus::Surface(next)) => {
previous != next
}
_ => true,
};
self.cursor_status = image;
self.update_capture_cursor_privacy();
// Pointer focus transitions invoke this with the pointer lock held.
// render_all -> cursor_elements -> current_location would try to
// acquire that same lock, freezing the entire compositor thread.
@@ -59,14 +81,18 @@ impl SeatHandler for Ferese {
}
fn focus_changed(&mut self, seat: &Seat<Self>, focused: Option<&WlSurface>) {
if let Some(id) = focused.and_then(|surface| {
self.windows.ids().iter().find_map(|(window, id)| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface)
.then_some(*id)
})
}) {
self.super_tap.cancel();
self.pending_layer_focus = None;
// Smithay has already sent enter. last_enter uses a separate mutex;
// querying current_focus here would reenter the held keyboard lock.
if let Some(serial) = seat
.get_keyboard()
.and_then(|keyboard| keyboard.last_enter())
{
self.record_activation_input(serial, focused.cloned());
}
if let Some(id) = focused.and_then(|surface| self.windows.id_for_surface(surface)) {
self.focus_history.record(id);
}
if let Some(inhibitor) = self.active_shortcuts_inhibitor.take()
@@ -74,7 +100,9 @@ impl SeatHandler for Ferese {
{
inhibitor.inactivate();
}
if let Some(inhibitor) = focused.and_then(|surface| seat.keyboard_shortcuts_inhibitor_for_surface(surface)) {
if let Some(inhibitor) =
focused.and_then(|surface| seat.keyboard_shortcuts_inhibitor_for_surface(surface))
{
inhibitor.activate();
self.active_shortcuts_inhibitor = Some(inhibitor);
}
@@ -86,6 +114,7 @@ impl SeatHandler for Ferese {
}
impl OutputHandler for Ferese {}
impl smithay::wayland::text_input::TextInputActivation for Ferese {}
impl FractionalScaleHandler for Ferese {
fn new_fractional_scale(&mut self, surface: WlSurface) {
@@ -103,7 +132,7 @@ impl IdleInhibitHandler for Ferese {
fn inhibit(&mut self, surface: WlSurface) {
let count = self.idle_inhibitors.entry(surface).or_default();
*count = count.saturating_add(1);
self.idle_notifier_state.set_is_inhibited(true);
self.refresh_idle_inhibition();
}
fn uninhibit(&mut self, surface: WlSurface) {
@@ -123,9 +152,13 @@ impl KeyboardShortcutsInhibitHandler for Ferese {
}
fn new_inhibitor(&mut self, inhibitor: KeyboardShortcutsInhibitor) {
let focused = self.seat.get_keyboard().and_then(|keyboard| keyboard.current_focus());
let focused = self
.seat
.get_keyboard()
.and_then(|keyboard| keyboard.current_focus());
if focused.as_ref() == Some(inhibitor.wl_surface()) {
self.super_tap.cancel();
inhibitor.activate();
self.active_shortcuts_inhibitor = Some(inhibitor);
}
@@ -139,6 +172,38 @@ impl KeyboardShortcutsInhibitHandler for Ferese {
}
impl Ferese {
pub(crate) fn surface_outputs(&self, surface: &WlSurface) -> Vec<smithay::output::Output> {
use smithay::desktop::{WindowSurfaceType, find_popup_root_surface};
use smithay::wayland::compositor::get_parent;
let mut root = surface.clone();
while let Some(parent) = get_parent(&root) {
root = parent;
}
if let Some(popup) = self.popups.find_popup(&root)
&& let Ok(parent) = find_popup_root_surface(&popup)
{
root = parent;
}
let window = self.windows.id_for_surface(&root);
self.space
.outputs()
.filter(|output| {
window.is_some_and(|id| self.window_belongs_to_output(id, output))
|| layer_map_for_output(output)
.layer_for_surface(&root, WindowSurfaceType::ALL)
.is_some()
|| self
.session_lock
.surfaces
.get(output)
.is_some_and(|lock| lock.wl_surface() == &root)
})
.cloned()
.collect()
}
pub(crate) fn update_surface_preferences(&self, surface: &WlSurface) {
use smithay::desktop::{WindowSurfaceType, find_popup_root_surface};
use smithay::wayland::compositor::get_parent;
@@ -152,12 +217,7 @@ impl Ferese {
{
root = parent;
}
let window = self.windows.ids().iter().find_map(|(window, id)| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == &root)
.then_some(*id)
});
let window = self.windows.id_for_surface(&root);
let output = self.space.outputs().find(|output| {
window.is_some_and(|id| self.window_belongs_to_output(id, output))
|| layer_map_for_output(output)
@@ -176,7 +236,9 @@ impl Ferese {
let output = output
.or_else(|| self.focused_output())
.or_else(|| self.space.outputs().next());
with_states(surface, |states| set_surface_preferences(surface, states, output));
with_states(surface, |states| {
set_surface_preferences(surface, states, output)
});
}
}
@@ -186,9 +248,18 @@ fn set_surface_preferences(
output: Option<&smithay::output::Output>,
) {
let scale = output.map_or(1.0, |output| output.current_scale().fractional_scale());
let transform = output.map_or(smithay::utils::Transform::Normal, |output| output.current_transform());
smithay::wayland::compositor::send_surface_state(surface, states, scale.ceil() as i32, transform);
with_fractional_scale(states, |surface_scale| surface_scale.set_preferred_scale(scale));
let transform = output.map_or(smithay::utils::Transform::Normal, |output| {
output.current_transform()
});
smithay::wayland::compositor::send_surface_state(
surface,
states,
scale.ceil() as i32,
transform,
);
with_fractional_scale(states, |surface_scale| {
surface_scale.set_preferred_scale(scale)
});
}
pub(crate) fn set_surface_tree_output(surface: &WlSurface, output: &smithay::output::Output) {
@@ -245,31 +316,70 @@ impl SelectionHandler for Ferese {
type SelectionUserData = ();
}
impl ClientDndGrabHandler for Ferese {}
impl ServerDndGrabHandler for Ferese {}
impl TabletSeatHandler for Ferese {}
impl smithay::input::dnd::DndGrabHandler for Ferese {}
impl WaylandDndGrabHandler for Ferese {
fn dnd_requested<S: smithay::input::dnd::Source>(
&mut self,
source: S,
_icon: Option<WlSurface>,
seat: Seat<Self>,
serial: smithay::utils::Serial,
type_: smithay::input::dnd::GrabType,
) {
use smithay::input::dnd::{DnDGrab, GrabType};
match type_ {
GrabType::Pointer => {
let pointer = seat.get_pointer().expect("validated pointer drag");
let start = pointer.grab_start_data().expect("validated pointer grab");
pointer.set_grab(
self,
DnDGrab::new_pointer(&self.display_handle, start, source, seat),
serial,
smithay::input::pointer::Focus::Keep,
);
}
GrabType::Touch => {
let touch = seat.get_touch().expect("validated touch drag");
let start = touch.grab_start_data().expect("validated touch grab");
touch.set_grab(
self,
DnDGrab::new_touch(&self.display_handle, start, source, seat),
serial,
);
}
}
}
}
impl TabletSeatHandler for Ferese {
type ToolFocus = WlSurface;
}
impl DataDeviceHandler for Ferese {
fn data_device_state(&self) -> &DataDeviceState {
&self.data_device_state
fn data_device_state(&mut self) -> &mut DataDeviceState {
&mut self.data_device_state
}
}
impl PrimarySelectionHandler for Ferese {
fn primary_selection_state(&self) -> &PrimarySelectionState {
&self.primary_selection_state
fn primary_selection_state(&mut self) -> &mut PrimarySelectionState {
&mut self.primary_selection_state
}
}
impl smithay::wayland::selection::wlr_data_control::DataControlHandler for Ferese {
fn data_control_state(&self) -> &smithay::wayland::selection::wlr_data_control::DataControlState {
&self.wlr_data_control_state
fn data_control_state(
&mut self,
) -> &mut smithay::wayland::selection::wlr_data_control::DataControlState {
&mut self.wlr_data_control_state
}
}
impl smithay::wayland::selection::ext_data_control::DataControlHandler for Ferese {
fn data_control_state(&self) -> &smithay::wayland::selection::ext_data_control::DataControlState {
&self.ext_data_control_state
fn data_control_state(
&mut self,
) -> &mut smithay::wayland::selection::ext_data_control::DataControlState {
&mut self.ext_data_control_state
}
}
@@ -281,32 +391,4 @@ impl XdgForeignHandler for Ferese {
impl XdgToplevelIconHandler for Ferese {}
smithay::delegate_compositor!(Ferese);
smithay::delegate_alpha_modifier!(Ferese);
smithay::delegate_cursor_shape!(Ferese);
smithay::delegate_data_device!(Ferese);
smithay::delegate_dmabuf!(Ferese);
smithay::delegate_fractional_scale!(Ferese);
smithay::delegate_idle_inhibit!(Ferese);
smithay::delegate_idle_notify!(Ferese);
smithay::delegate_input_method_manager!(Ferese);
smithay::delegate_keyboard_shortcuts_inhibit!(Ferese);
smithay::delegate_layer_shell!(Ferese);
smithay::delegate_output!(Ferese);
smithay::delegate_pointer_constraints!(Ferese);
smithay::delegate_presentation!(Ferese);
smithay::delegate_primary_selection!(Ferese);
smithay::delegate_relative_pointer!(Ferese);
smithay::delegate_seat!(Ferese);
smithay::delegate_shm!(Ferese);
smithay::delegate_single_pixel_buffer!(Ferese);
smithay::delegate_text_input_manager!(Ferese);
smithay::delegate_viewporter!(Ferese);
smithay::delegate_xdg_activation!(Ferese);
smithay::delegate_xdg_decoration!(Ferese);
smithay::delegate_xdg_foreign!(Ferese);
smithay::delegate_xdg_shell!(Ferese);
smithay::delegate_xdg_toplevel_icon!(Ferese);
smithay::delegate_data_control!(Ferese);
smithay::delegate_ext_data_control!(Ferese);
pub(crate) mod workspace_capture;
+177 -31
View File
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -11,7 +12,9 @@ use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::{
};
use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer;
use smithay::reexports::wayland_server::protocol::wl_shm;
use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource};
use smithay::reexports::wayland_server::{
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
};
use smithay::utils::{Buffer, Logical, Rectangle, Size};
use smithay::wayland::shm::with_buffer_contents_mut;
@@ -20,6 +23,8 @@ use crate::Ferese;
const BYTES_PER_PIXEL: usize = 4;
type DamageHistory = Arc<Mutex<HashMap<(Output, bool), u64>>>;
#[derive(Debug)]
pub(crate) struct FrameData {
pub(super) output: Option<Output>,
@@ -27,6 +32,7 @@ pub(crate) struct FrameData {
pub(super) overlay_cursor: bool,
pub(super) used: Mutex<bool>,
pub(super) snapshot: Mutex<Option<super::window_capture::Snapshot>>,
pub(super) history: Option<DamageHistory>,
}
#[derive(Debug)]
@@ -36,6 +42,7 @@ pub(crate) struct PendingScreencopy {
region: Rectangle<i32, Buffer>,
overlay_cursor: bool,
with_damage: bool,
history: Option<DamageHistory>,
}
#[derive(Debug)]
@@ -49,6 +56,7 @@ pub(crate) enum CaptureSink {
part: usize,
complete: PartSender,
published: AtomicBool,
permit: Option<super::screenshot::BudgetPermit>,
},
}
@@ -59,6 +67,7 @@ impl CaptureSink {
part,
complete,
published: AtomicBool::new(false),
permit: None,
}
}
@@ -73,12 +82,14 @@ impl CaptureSink {
part,
complete,
published,
permit,
} => {
if !published.swap(true, Ordering::AcqRel) {
let _ = complete.send(PartOutcome {
request: *request,
part: *part,
result: Err("Screenshot part failed".to_string()),
permit: permit.clone(),
});
}
}
@@ -93,6 +104,7 @@ impl CaptureSink {
part,
complete,
published,
permit,
} = self
else {
return false;
@@ -105,12 +117,20 @@ impl CaptureSink {
request: *request,
part: *part,
result: Ok(buffer),
permit: permit.clone(),
})
.is_ok()
}
}
impl PendingScreencopy {
pub(crate) fn with_permit(mut self, permit: Option<super::screenshot::BudgetPermit>) -> Self {
if let CaptureSink::Owned { permit: stored, .. } = &mut self.sink {
*stored = permit;
}
self
}
pub(crate) fn owned(
request: u64,
part: usize,
@@ -125,6 +145,7 @@ impl PendingScreencopy {
// The compositor-owned path deliberately omits the cursor.
overlay_cursor: false,
with_damage: false,
history: None,
}
}
@@ -144,17 +165,21 @@ impl PendingScreencopy {
pub(crate) fn capture_allowed() -> bool {
static ALLOWED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ALLOWED.get_or_init(|| capture_opted_in(std::env::var_os("FERESE_ENABLE_SCREENCOPY").as_deref()))
*ALLOWED
.get_or_init(|| capture_opted_in(std::env::var_os("FERESE_ENABLE_SCREENCOPY").as_deref()))
}
fn capture_opted_in(value: Option<&std::ffi::OsStr>) -> bool {
value.is_some_and(|value| value == "1")
}
pub(crate) fn init_global(display: &DisplayHandle) {
pub(crate) fn init_global(
display: &DisplayHandle,
loop_handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>,
) {
if capture_allowed() {
display.create_global::<Ferese, ZwlrScreencopyManagerV1, ()>(3, ());
super::window_capture::init_global(display);
super::window_capture::init_global(display, loop_handle);
tracing::info!("authorized screencopy is enabled for this session");
}
}
@@ -168,17 +193,17 @@ impl GlobalDispatch<ZwlrScreencopyManagerV1, ()> for Ferese {
_global_data: &(),
data_init: &mut DataInit<'_, Self>,
) {
data_init.init(resource, ());
data_init.init(resource, DamageHistory::default());
}
}
impl Dispatch<ZwlrScreencopyManagerV1, ()> for Ferese {
impl Dispatch<ZwlrScreencopyManagerV1, DamageHistory> for Ferese {
fn request(
_state: &mut Self,
_client: &Client,
_manager: &ZwlrScreencopyManagerV1,
request: zwlr_screencopy_manager_v1::Request,
_data: &(),
history: &DamageHistory,
_display: &DisplayHandle,
data_init: &mut DataInit<'_, Self>,
) {
@@ -188,7 +213,14 @@ impl Dispatch<ZwlrScreencopyManagerV1, ()> for Ferese {
overlay_cursor,
output,
} => {
create_frame(data_init, frame, output, None, overlay_cursor != 0);
create_frame(
data_init,
frame,
output,
None,
overlay_cursor != 0,
history.clone(),
);
}
zwlr_screencopy_manager_v1::Request::CaptureOutputRegion {
frame,
@@ -205,6 +237,7 @@ impl Dispatch<ZwlrScreencopyManagerV1, ()> for Ferese {
output,
Some(Rectangle::new((x, y).into(), (width, height).into())),
overlay_cursor != 0,
history.clone(),
);
}
zwlr_screencopy_manager_v1::Request::Destroy => {}
@@ -231,7 +264,10 @@ impl Dispatch<ZwlrScreencopyFrameV1, Arc<FrameData>> for Ferese {
state.queue_screencopy(frame, data, buffer, true);
}
zwlr_screencopy_frame_v1::Request::Destroy => {
data.snapshot.lock().unwrap().take();
data.snapshot
.lock()
.expect("surface rendering state must remain consistent across callbacks")
.take();
}
_ => unreachable!(),
}
@@ -246,12 +282,20 @@ impl Ferese {
buffer: WlBuffer,
with_damage: bool,
) {
if data.snapshot.lock().unwrap().is_some() {
if data
.snapshot
.lock()
.expect("surface rendering state must remain consistent across callbacks")
.is_some()
{
super::window_capture::copy_snapshot(self, frame, data, &buffer);
return;
}
let mut used = data.used.lock().unwrap();
if self.session_lock.active {
let mut used = data
.used
.lock()
.expect("surface rendering state must remain consistent across callbacks");
if self.session_lock.active() {
frame.failed();
return;
}
@@ -277,17 +321,39 @@ impl Ferese {
return;
};
if let Some(history) = &data.history {
history
.lock()
.expect("capture damage history mutex poisoned")
.retain(|(output, _), _| self.output_ids.contains_key(output));
}
self.pending_screencopies.push(PendingScreencopy {
sink: CaptureSink::Shm {
frame: frame.clone(),
buffer,
},
output,
output: output.clone(),
region: data.region,
overlay_cursor: data.overlay_cursor,
with_damage,
history: data.history.clone(),
});
crate::backends::direct::schedule_capture(self, &[output]);
}
pub(crate) fn prune_screencopies(&mut self) {
self.pending_screencopies.retain(|capture| {
if let CaptureSink::Shm { frame, buffer } = &capture.sink {
if !frame.is_alive() {
return false;
}
if !buffer.is_alive() {
frame.failed();
return false;
}
}
true
});
crate::backends::direct::render_all(self);
}
pub(crate) fn process_screencopies<R>(
@@ -300,12 +366,25 @@ impl Ferese {
where
R: ExportMem,
{
self.process_screencopies_with_revision(renderer, framebuffer, output, overlay_cursor, None)
}
pub(crate) fn process_screencopies_with_revision<R: ExportMem>(
&mut self,
renderer: &mut R,
framebuffer: &R::Framebuffer<'_>,
output: &Output,
overlay_cursor: bool,
revision: Option<u64>,
) -> bool {
let mut remaining = Vec::new();
let mut framebuffer_binding_changed = false;
let captures = std::mem::take(&mut self.pending_screencopies);
for capture in captures {
if &capture.output != output || !cursor_overlay_matches(capture.overlay_cursor, overlay_cursor) {
if &capture.output != output
|| !cursor_overlay_matches(capture.overlay_cursor, overlay_cursor)
{
remaining.push(capture);
continue;
}
@@ -315,9 +394,33 @@ impl Ferese {
continue;
}
let key = (output.clone(), overlay_cursor);
if capture.history.is_some() && revision.is_none() {
remaining.push(capture);
continue;
}
if capture.with_damage {
let previous = capture.history.as_ref().and_then(|history| {
history
.lock()
.expect("capture damage history mutex poisoned")
.get(&key)
.copied()
});
if revision.is_none() || previous == revision {
remaining.push(capture);
continue;
}
}
framebuffer_binding_changed = true;
if complete_capture(renderer, framebuffer, &capture, self.start_time.elapsed()).is_err() {
if complete_capture(renderer, framebuffer, &capture, self.start_time.elapsed()).is_err()
{
capture.fail();
} else if let (Some(history), Some(revision)) = (&capture.history, revision) {
history
.lock()
.expect("capture damage history mutex poisoned")
.insert(key, revision);
}
}
@@ -326,9 +429,26 @@ impl Ferese {
}
pub(crate) fn has_pending_screencopy(&self, output: &Output, overlay_cursor: bool) -> bool {
self.pending_screencopies
.iter()
.any(|capture| capture.output == *output && cursor_overlay_matches(capture.overlay_cursor, overlay_cursor))
self.image_capture.pending_output(output, overlay_cursor)
|| self.pending_screencopies.iter().any(|capture| {
capture.output == *output
&& cursor_overlay_matches(capture.overlay_cursor, overlay_cursor)
})
}
pub(crate) fn fail_screencopies(&mut self, output: &Output, overlay_cursor: bool) {
let mut remaining = Vec::new();
for capture in std::mem::take(&mut self.pending_screencopies) {
if capture.output == *output
&& cursor_overlay_matches(capture.overlay_cursor, overlay_cursor)
{
capture.sink.fail();
} else {
remaining.push(capture);
}
}
self.pending_screencopies = remaining;
}
}
@@ -342,6 +462,7 @@ fn create_frame(
wl_output: smithay::reexports::wayland_server::protocol::wl_output::WlOutput,
requested: Option<Rectangle<i32, Logical>>,
overlay_cursor: bool,
history: DamageHistory,
) {
let Some(output) = Output::from_resource(&wl_output) else {
let frame = data_init.init(
@@ -352,6 +473,7 @@ fn create_frame(
overlay_cursor,
used: Mutex::new(true),
snapshot: Mutex::new(None),
history: Some(history.clone()),
}),
);
frame.failed();
@@ -366,6 +488,7 @@ fn create_frame(
overlay_cursor,
used: Mutex::new(true),
snapshot: Mutex::new(None),
history: Some(history.clone()),
}),
);
frame.failed();
@@ -379,6 +502,7 @@ fn create_frame(
overlay_cursor,
used: Mutex::new(false),
snapshot: Mutex::new(None),
history: Some(history.clone()),
});
let frame = data_init.init(frame, data);
@@ -393,7 +517,10 @@ fn create_frame(
}
}
fn capture_region(output: &Output, requested: Option<Rectangle<i32, Logical>>) -> Option<Rectangle<i32, Buffer>> {
fn capture_region(
output: &Output,
requested: Option<Rectangle<i32, Logical>>,
) -> Option<Rectangle<i32, Buffer>> {
let mode = output.current_mode()?;
let scale = output.current_scale().fractional_scale();
let transform = output.current_transform();
@@ -418,20 +545,30 @@ pub(crate) fn capture_region_for_geometry(
transform: smithay::utils::Transform,
requested: Option<Rectangle<i32, Logical>>,
) -> Option<Rectangle<i32, Buffer>> {
let buffer_bounds = Rectangle::from_size(Size::<i32, Buffer>::from((mode_size.w, mode_size.h)));
if mode_size.w <= 0 || mode_size.h <= 0 {
return None;
}
// Full-output captures must match the framebuffer exactly. Rounding the
// logical size and scaling it back can add a pixel at fractional scales.
if requested.is_none() {
return Some(buffer_bounds);
}
let logical_size = output_logical_size(mode_size, scale, transform);
let output_region = Rectangle::from_size(logical_size);
let logical_region = requested.unwrap_or(output_region).intersection(output_region)?;
let logical_region = requested
.unwrap_or(output_region)
.intersection(output_region)?;
if logical_region.size.w <= 0 || logical_region.size.h <= 0 {
return None;
}
Some(
logical_region
.to_f64()
.to_buffer(scale, transform, &logical_size.to_f64())
.to_i32_round(),
)
logical_region
.to_f64()
.to_buffer(scale, transform, &logical_size.to_f64())
.to_i32_round()
.intersection(buffer_bounds)
}
pub(super) fn valid_shm_buffer(buffer: &WlBuffer, expected: Size<i32, Buffer>) -> bool {
@@ -472,7 +609,9 @@ where
tracing::warn!(?error, "failed to map output framebuffer copy");
})?;
let expected = capture.region.size;
let stride = (expected.w as usize).checked_mul(BYTES_PER_PIXEL).ok_or(())?;
let stride = (expected.w as usize)
.checked_mul(BYTES_PER_PIXEL)
.ok_or(())?;
let bytes = stride.checked_mul(expected.h as usize).ok_or(())?;
if source.len() < bytes {
return Err(());
@@ -530,7 +669,11 @@ where
frame.flags(zwlr_screencopy_frame_v1::Flags::empty());
let seconds = timestamp.as_secs();
frame.ready((seconds >> 32) as u32, seconds as u32, timestamp.subsec_nanos());
frame.ready(
(seconds >> 32) as u32,
seconds as u32,
timestamp.subsec_nanos(),
);
}
Ok(())
}
@@ -559,7 +702,8 @@ mod tests {
let mode = Size::<i32, Physical>::from((1_920, 1_080));
let requested = Rectangle::new((-10, 20).into(), (110, 50).into());
let region = capture_region_for_geometry(mode, 2.0, Transform::Normal, Some(requested)).unwrap();
let region =
capture_region_for_geometry(mode, 2.0, Transform::Normal, Some(requested)).unwrap();
assert_eq!(region, Rectangle::new((0, 40).into(), (200, 100).into()));
}
@@ -569,7 +713,9 @@ mod tests {
let mode = Size::<i32, Physical>::from((1_920, 1_080));
let requested = Rectangle::new((1_000, 600).into(), (100, 100).into());
assert!(capture_region_for_geometry(mode, 2.0, Transform::Normal, Some(requested),).is_none());
assert!(
capture_region_for_geometry(mode, 2.0, Transform::Normal, Some(requested),).is_none()
);
}
#[test]
File diff suppressed because it is too large. Load diff
+105 -34
View File
@@ -20,6 +20,7 @@ const FILE_MODE: u32 = 0o600;
pub(crate) struct Job {
pub(crate) request: u64,
pub(crate) frames: Vec<OutputFrame>,
pub(crate) _permit: Option<super::screenshot::BudgetPermit>,
}
pub(crate) struct Encoded {
@@ -53,18 +54,13 @@ impl Worker {
fn run(receiver: Receiver<Job>, results: channel::SyncSender<Encoded>) {
while let Ok(job) = receiver.recv() {
let result = encode(&job);
let request = job.request;
let result = encode(job);
// This send cannot block. Each request produces exactly one Encode, so at
// most QUEUE_CAPACITY jobs can be queued while one more is being encoded,
// and RESULT_QUEUE_CAPACITY is asserted to exceed that. Blocking here
// would stall the worker with no way for the loop to recover it.
if results
.send(Encoded {
request: job.request,
result,
})
.is_err()
{
if results.send(Encoded { request, result }).is_err() {
return;
}
}
@@ -102,8 +98,10 @@ pub(crate) fn sweep_stale_files() {
}
}
fn encode(job: &Job) -> Result<PathBuf, String> {
fn encode(job: Job) -> Result<PathBuf, String> {
let canvas = compose(&job.frames)?;
drop(job);
let png = encode_png(&canvas)?;
write_private_file(&png)
}
@@ -115,46 +113,80 @@ fn encode_png(canvas: &Canvas) -> Result<Vec<u8>, String> {
if width == 0 || height == 0 {
return Err("Screenshot is empty".into());
}
let image = image::RgbaImage::from_raw(width, height, canvas.pixels.clone())
.ok_or("Screenshot buffer does not match its dimensions")?;
let image =
image::ImageBuffer::<image::Rgba<u8>, _>::from_raw(width, height, canvas.pixels.as_slice())
.ok_or("Screenshot buffer does not match its dimensions")?;
let mut out = Vec::new();
let encoder = image::codecs::png::PngEncoder::new_with_quality(
std::io::Cursor::new(&mut out),
image::codecs::png::CompressionType::Fast,
image::codecs::png::FilterType::Adaptive,
);
image::ImageEncoder::write_image(encoder, image.as_raw(), width, height, image::ExtendedColorType::Rgba8)
.map_err(|error| format!("Could not encode screenshot: {error}"))?;
image::ImageEncoder::write_image(
encoder,
image.as_raw(),
width,
height,
image::ExtendedColorType::Rgba8,
)
.map_err(|error| format!("Could not encode screenshot: {error}"))?;
tracing::debug!(encoded = out.len(), "encoded screenshot");
Ok(out)
}
// The client is told to open this path and unlink it immediately, so the
// directory is ours, private, and files are created with the final mode.
struct StagedFile {
path: PathBuf,
keep: bool,
}
impl Drop for StagedFile {
fn drop(&mut self) {
if !self.keep {
let _ = fs::remove_file(&self.path);
}
}
}
fn write_private_file(png: &[u8]) -> Result<PathBuf, String> {
let directory = private_directory()?;
for attempt in 0..64u32 {
let path = directory.join(format!("shot-{}-{attempt}.png", std::process::id()));
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true).mode(FILE_MODE);
match options.open(&path) {
Ok(mut file) => {
use io::Write;
file.write_all(png)
.and_then(|()| file.sync_all())
.map_err(|error| format!("Could not write screenshot: {error}"))?;
return Ok(path);
}
write_private_file_in(&private_directory()?, png, |file, bytes| {
use io::Write;
file.write_all(bytes)?;
file.sync_all()
})
}
fn write_private_file_in(
directory: &std::path::Path,
png: &[u8],
write: impl FnOnce(&mut fs::File, &[u8]) -> io::Result<()>,
) -> Result<PathBuf, String> {
static NEXT_FILE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let (mut file, mut staged) = loop {
let sequence = NEXT_FILE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = directory.join(format!("shot-{}-{sequence}.png", std::process::id()));
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(FILE_MODE)
.open(&path)
{
Ok(file) => break (file, StagedFile { path, keep: false }),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(format!("Could not write screenshot: {error}")),
}
}
Err("Could not find an unused screenshot filename".into())
};
write(&mut file, png).map_err(|error| format!("Could not write screenshot: {error}"))?;
staged.keep = true;
Ok(std::mem::take(&mut staged.path))
}
fn private_directory() -> Result<PathBuf, String> {
let runtime =
std::env::var_os("XDG_RUNTIME_DIR").ok_or("XDG_RUNTIME_DIR is not set, so screenshots cannot be staged")?;
let runtime = std::env::var_os("XDG_RUNTIME_DIR")
.ok_or("XDG_RUNTIME_DIR is not set, so screenshots cannot be staged")?;
let root = PathBuf::from(runtime);
let directory = root.join("ferese-screenshots");
match fs::symlink_metadata(&directory) {
@@ -163,13 +195,16 @@ fn private_directory() -> Result<PathBuf, String> {
}
Ok(metadata) => {
use std::os::unix::fs::MetadataExt;
// SAFETY: geteuid has no pointer arguments or caller-side preconditions.
if !metadata.is_dir() || metadata.uid() != unsafe { libc::geteuid() } {
return Err("Refusing to stage screenshots in an untrusted directory".into());
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
fs::create_dir(&directory)
.and_then(|()| fs::set_permissions(&directory, fs::Permissions::from_mode(DIR_MODE)))
.and_then(|()| {
fs::set_permissions(&directory, fs::Permissions::from_mode(DIR_MODE))
})
.map_err(|error| format!("Could not create screenshot directory: {error}"))?;
}
Err(error) => return Err(format!("Could not inspect screenshot directory: {error}")),
@@ -209,8 +244,13 @@ mod tests {
let job = Job {
request: 1,
frames: vec![frame(4, 3)],
_permit: None,
};
let canvas = compose(&job.frames).unwrap();
let mut canvas = compose(&job.frames).unwrap();
for (index, pixel) in canvas.pixels.chunks_exact_mut(4).enumerate() {
pixel[..3].copy_from_slice(&[index as u8, 137, 241]);
}
let png = encode_png(&canvas).expect("png");
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n", "png signature");
@@ -218,17 +258,45 @@ mod tests {
.expect("decodes back")
.to_rgba8();
assert_eq!(decoded.dimensions(), (4, 3));
assert_eq!(
decoded.as_raw(),
&canvas.pixels,
"encoding changed the pixel data"
);
// The opaque alpha we force is preserved through the round trip.
assert_eq!(decoded.get_pixel(0, 0)[3], 255);
}
#[test]
fn staging_has_no_fixed_filename_capacity_and_cleans_partial_writes() {
let directory = tempfile::tempdir().unwrap();
for _ in 0..100 {
write_private_file_in(directory.path(), b"png", |file, bytes| {
use io::Write;
file.write_all(bytes)
})
.unwrap();
}
assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 100);
let result = write_private_file_in(directory.path(), b"partial", |file, bytes| {
use io::Write;
file.write_all(bytes)?;
Err(io::Error::other("injected write/sync failure"))
});
assert!(result.is_err());
assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 100);
}
#[test]
fn staged_files_are_owner_only() {
let png = b"\x89PNG\r\n\x1a\nnot really a png".to_vec();
let path = write_private_file(&png).expect("staged");
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, FILE_MODE, "staged screenshots are not group/world readable");
assert_eq!(
mode, FILE_MODE,
"staged screenshots are not group/world readable"
);
fs::remove_file(&path).unwrap();
}
@@ -242,7 +310,10 @@ mod tests {
fs::write(&other, b"not a screenshot").unwrap();
sweep_stale_files();
assert!(fresh.exists(), "a freshly staged screenshot is still in use");
assert!(
fresh.exists(),
"a freshly staged screenshot is still in use"
);
assert!(other.exists(), "unrelated files are left alone");
fs::remove_file(&fresh).unwrap();
@@ -0,0 +1,34 @@
use std::sync::Arc;
use smithay::reexports::wayland_server::Client;
use smithay::wayland::security_context::{
SecurityContext, SecurityContextHandler, SecurityContextListenerSource,
};
use crate::Ferese;
use crate::state::ClientState;
pub(crate) fn unrestricted_client(client: &Client) -> bool {
client
.get_data::<ClientState>()
.is_some_and(|state| state.security_context.is_none())
}
impl SecurityContextHandler for Ferese {
fn context_created(&mut self, source: SecurityContextListenerSource, context: SecurityContext) {
if let Err(error) = self
.loop_handle
.insert_source(source, move |stream, _, state| {
let data = ClientState {
security_context: Some(context.clone()),
..Default::default()
};
if let Err(error) = state.display_handle.insert_client(stream, Arc::new(data)) {
tracing::warn!(%error, "failed to register sandboxed Wayland client");
}
})
{
tracing::warn!(%error, "failed to register security context listener");
}
}
}
@@ -0,0 +1,124 @@
use std::io::{Read, Write};
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_wm_base::XdgWmBase;
use smithay::reexports::wayland_protocols::xdg::toplevel_icon::v1::server::xdg_toplevel_icon_manager_v1::XdgToplevelIconManagerV1;
use smithay::reexports::wayland_server::Resource;
use smithay::reexports::wayland_server::protocol::{wl_compositor::WlCompositor, wl_shm::WlShm};
use smithay::wayland::GlobalData;
use smithay::wayland::shell::xdg::XdgWmBaseUserData;
use crate::Ferese;
use crate::state::{ClientState, dispatch_test_client, test_client_request as request};
// Replay the native JetBrains lifecycle: assign an icon, destroy the icon
// object, then free its SHM buffer. The next sync must still reach the client.
fn icon_cleanup_messages(destroy_icon: bool) -> Vec<(u32, u16, Vec<u32>)> {
let mut events = EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let (server, mut wire) = UnixStream::pair().unwrap();
let client = state
.display_handle
.insert_client(server, Arc::new(ClientState::default()))
.unwrap();
let compositor = client
.create_resource::<WlCompositor, GlobalData, Ferese>(&state.display_handle, 6, GlobalData)
.unwrap();
let shell = client
.create_resource::<XdgWmBase, XdgWmBaseUserData, Ferese>(
&state.display_handle,
6,
Default::default(),
)
.unwrap();
let shm = client
.create_resource::<WlShm, GlobalData, Ferese>(&state.display_handle, 1, GlobalData)
.unwrap();
let manager = client
.create_resource::<XdgToplevelIconManagerV1, GlobalData, Ferese>(
&state.display_handle,
1,
GlobalData,
)
.unwrap();
request(&mut wire, compositor.id().protocol_id(), 0, &[2], None);
request(&mut wire, shell.id().protocol_id(), 2, &[3, 2], None);
request(&mut wire, 3, 1, &[4], None);
let mut file = tempfile::tempfile().unwrap();
file.write_all(&0xff112233u32.to_ne_bytes().repeat(32 * 32))
.unwrap();
request(
&mut wire,
shm.id().protocol_id(),
0,
&[5, 32 * 32 * 4],
Some(file.as_raw_fd()),
);
request(&mut wire, 5, 0, &[6, 0, 32, 32, 128, 0], None);
request(&mut wire, manager.id().protocol_id(), 1, &[7], None);
request(&mut wire, 7, 2, &[6, 1], None);
request(&mut wire, manager.id().protocol_id(), 2, &[4, 7], None);
if destroy_icon {
request(&mut wire, 7, 0, &[], None);
}
request(&mut wire, 6, 0, &[], None);
request(&mut wire, 1, 0, &[8], None);
dispatch_test_client(&mut events, &mut state);
wire.set_nonblocking(true).unwrap();
let mut bytes = Vec::new();
let _ = wire.read_to_end(&mut bytes);
let mut messages = Vec::new();
let mut offset = 0;
while offset < bytes.len() {
let object = u32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap());
let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap());
let size = (header >> 16) as usize;
let arguments = bytes[offset + 8..offset + size]
.chunks_exact(4)
.map(|word| u32::from_ne_bytes(word.try_into().unwrap()))
.collect();
messages.push((object, header as u16, arguments));
offset += size;
}
messages
}
#[test]
fn destroying_an_assigned_icon_before_its_buffer_keeps_the_client_connected() {
if !crate::startup_tests::private_runtime(
"handlers::toplevel_icon_tests::destroying_an_assigned_icon_before_its_buffer_keeps_the_client_connected",
) {
return;
}
let messages = icon_cleanup_messages(true);
assert!(
!messages
.iter()
.any(|(object, opcode, _)| *object == 1 && *opcode == 0)
);
assert!(
messages
.iter()
.any(|(object, opcode, _)| *object == 8 && *opcode == 0),
"valid icon cleanup disconnected the client before its sync reply: {messages:?}"
);
}
#[test]
fn destroying_a_buffer_while_its_icon_is_alive_still_reports_no_buffer() {
if !crate::startup_tests::private_runtime(
"handlers::toplevel_icon_tests::destroying_a_buffer_while_its_icon_is_alive_still_reports_no_buffer",
) {
return;
}
let messages = icon_cleanup_messages(false);
assert!(
messages.iter().any(|(object, opcode, args)| {
*object == 1 && *opcode == 0 && args.first() == Some(&7) && args.get(1) == Some(&3)
}),
"destroying a live icon's buffer must report the protocol error: {messages:?}"
);
}
@@ -0,0 +1,89 @@
use smithay::backend::input::{Event, InputEvent, KeyboardKeyEvent};
use smithay::utils::SERIAL_COUNTER;
use smithay::wayland::input_method::InputMethodSeat;
use smithay::wayland::virtual_keyboard::{
VirtualKeyboardBackend, VirtualKeyboardHandler, VirtualKeyboardSpecialEvent,
};
use crate::Ferese;
impl VirtualKeyboardHandler for Ferese {
fn process_virtual_keyboard_event(&mut self, event: InputEvent<VirtualKeyboardBackend>) {
let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
if self.session_lock.active() {
// Synthetic input never enters a lock surface. Still reconcile
// keys from a departing/previously active auxiliary device.
let device = match &event {
InputEvent::Keyboard { event } => Some(event.device()),
InputEvent::DeviceRemoved { device } => Some(device.clone()),
_ => None,
};
if let Some(device) = device {
self.super_tap.cancel();
keyboard.release_source(self, device.source());
}
return;
}
match event {
InputEvent::DeviceAdded { device }
| InputEvent::Special(VirtualKeyboardSpecialEvent::KeymapChanged { device }) => {
if let Some(keymap) = device.keymap() {
let current = keyboard.with_xkb_state(self, |context| {
context.xkb().lock().unwrap().keymap_as_string()
});
if current != *keymap
&& let Err(error) =
keyboard.set_keymap_from_string(self, keymap.to_string())
{
tracing::warn!(%error, "invalid virtual keyboard keymap");
}
}
}
InputEvent::Keyboard { event } => {
let device = event.device();
let grab_client = self.seat.input_method().keyboard_grab_client();
if grab_client.is_some() && grab_client == device.client() {
// An IME forwards keys it did not consume. The physical path
// already updated XKB and compositor shortcuts: forwarding
// through that path again would recurse into the IME grab.
keyboard.input_forward_bypassing_grab(
self,
event.key_code(),
event.state(),
SERIAL_COUNTER.next_serial(),
event.time(),
true,
);
} else {
self.process_input_event_from_source::<VirtualKeyboardBackend>(
InputEvent::Keyboard { event },
device.source(),
);
}
}
InputEvent::Special(VirtualKeyboardSpecialEvent::Modifiers {
device,
mods_depressed,
mods_latched,
mods_locked,
group,
}) => {
let grab_client = self.seat.input_method().keyboard_grab_client();
if grab_client.is_some() && grab_client == device.client() {
// The physical path already owns these modifiers. An
// asynchronous IME echo must not restore an old mask after
// the physical release has arrived.
return;
}
keyboard.with_xkb_state(self, |mut context| {
context.set_modifier_mask(mods_depressed, mods_latched, mods_locked, group)
});
}
InputEvent::DeviceRemoved { device } => {
self.super_tap.cancel();
keyboard.release_source(self, device.source());
}
_ => (),
}
}
}
@@ -0,0 +1,13 @@
use crate::Ferese;
use smithay::backend::input::InputEvent;
use smithay::wayland::virtual_pointer::{VirtualPointerBackend, VirtualPointerHandler};
impl VirtualPointerHandler for Ferese {
fn process_virtual_pointer_event(&mut self, event: InputEvent<VirtualPointerBackend>) {
// The global exists only with the explicit test-pointer environment
// switch. Synthetic pointers must never enter a real lock surface.
if !self.session_lock.active() {
self.process_input_event(event);
}
}
}
+540 -55
View File
@@ -4,12 +4,16 @@ use std::sync::{Arc, Mutex};
use ferese_layout::WindowId;
use ferese_protocols::window_capture::v1::server::ferese_window_capture_manager_v1 as manager;
use ferese_protocols::window_capture::v1::server::ferese_window_capture_manager_v1::FereseWindowCaptureManagerV1;
use smithay::backend::renderer::utils::{RendererSurfaceStateUserData, SurfaceView};
use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1 as frame;
use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1::ZwlrScreencopyFrameV1;
use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer;
use smithay::reexports::wayland_server::protocol::wl_shm;
use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource};
use smithay::utils::Rectangle;
use smithay::reexports::wayland_server::{
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
};
use smithay::utils::{Logical, Point, Rectangle};
use smithay::wayland::compositor::{TraversalAction, with_surface_tree_downward};
use smithay::wayland::shm::with_buffer_contents_mut;
use super::screencopy::FrameData;
@@ -19,6 +23,40 @@ use crate::Ferese;
const MAX_BYTES: usize = 128 * 1024 * 1024;
const MAX_FRAMES: usize = 8;
fn capture_scale(output_scale: f64, buffer_scale: i32, view: SurfaceView) -> f64 {
if view.dst.w <= 0 || view.dst.h <= 0 {
return output_scale;
}
let width = view.src.size.w * f64::from(buffer_scale);
let height = view.src.size.h * f64::from(buffer_scale);
let logical_width = f64::from(view.dst.w);
let logical_height = f64::from(view.dst.h);
let matches_native = |scale: f64| {
(logical_width * scale).round() == width && (logical_height * scale).round() == height
};
// Fractional clients round their backing dimensions independently. Taking
// the larger density can add a pixel on the other axis and filter every
// row or column, even though the client already rendered at this scale.
if matches_native(output_scale) {
return output_scale;
}
let scale = output_scale
.max(width / logical_width)
.max(height / logical_height);
if matches_native(scale) {
return scale;
}
// Prefer a common density whose rounded dimensions preserve both axes.
// Stay strictly below the upper rounding boundary, including FP error.
let upper = ((width + 0.5) / logical_width).min((height + 0.5) / logical_height);
let adjusted = upper * (1.0 - 8.0 * f64::EPSILON);
if adjusted >= output_scale && matches_native(adjusted) {
adjusted
} else {
scale
}
}
#[derive(Debug, Default)]
pub(crate) struct Budget {
bytes: usize,
@@ -33,8 +71,13 @@ struct Permit {
impl Permit {
fn reserve(budget: &Arc<Mutex<Budget>>, bytes: usize) -> Option<Self> {
let mut available = budget.lock().unwrap();
if bytes == 0 || bytes > MAX_BYTES.saturating_sub(available.bytes) || available.frames >= MAX_FRAMES {
let mut available = budget
.lock()
.expect("surface rendering state must remain consistent across callbacks");
if bytes == 0
|| bytes > MAX_BYTES.saturating_sub(available.bytes)
|| available.frames >= MAX_FRAMES
{
return None;
}
available.bytes += bytes;
@@ -48,7 +91,10 @@ impl Permit {
impl Drop for Permit {
fn drop(&mut self) {
let mut budget = self.budget.lock().unwrap();
let mut budget = self
.budget
.lock()
.expect("surface rendering state must remain consistent across callbacks");
budget.bytes -= self.bytes;
budget.frames -= 1;
}
@@ -57,18 +103,31 @@ impl Drop for Permit {
#[derive(Debug)]
pub(super) struct Snapshot {
id: WindowId,
epoch: u64,
logical_size: (u32, u32),
pixels: CaptureBuffer,
_permit: Permit,
}
#[derive(Clone)]
pub(crate) struct Global {
budget: Arc<Mutex<Budget>>,
worker: std::sync::mpsc::SyncSender<Conversion>,
}
struct Conversion {
snapshot: Snapshot,
frame: ZwlrScreencopyFrameV1,
manager: FereseWindowCaptureManagerV1,
}
pub(crate) fn is_portal(stream: &std::os::unix::net::UnixStream) -> bool {
use std::os::fd::AsRawFd;
let mut credentials = libc::ucred { pid: 0, uid: 0, gid: 0 };
let mut credentials = libc::ucred {
pid: 0,
uid: 0,
gid: 0,
};
let mut length = std::mem::size_of_val(&credentials) as libc::socklen_t;
// SAFETY: the connected socket is live and both out pointers cover their
// initialized stack allocations. SO_PEERCRED identifies the connecting process.
@@ -86,23 +145,110 @@ pub(crate) fn is_portal(stream: &std::os::unix::net::UnixStream) -> bool {
}
let expected = std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join("xdg-desktop-portal-ferese")))
.and_then(|exe| {
exe.parent()
.map(|dir| dir.join("xdg-desktop-portal-ferese"))
})
.and_then(|exe| std::fs::metadata(exe).ok());
let actual = std::fs::metadata(format!("/proc/{}/exe", credentials.pid)).ok();
matches!((expected, actual), (Some(expected), Some(actual)) if expected.dev() == actual.dev() && expected.ino() == actual.ino())
}
pub(super) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseWindowCaptureManagerV1, Global>(1, Global { budget: Arc::default() });
pub(super) fn init_global(
display: &DisplayHandle,
loop_handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>,
) {
use smithay::reexports::calloop::channel;
let (worker, requests) = std::sync::mpsc::sync_channel::<Conversion>(MAX_FRAMES);
let (results, source) = channel::sync_channel::<Conversion>(MAX_FRAMES);
let Ok(token) = loop_handle.insert_source(source, |event, _, state| {
if let channel::Event::Msg(result) = event {
finish_conversion(state, result);
}
}) else {
tracing::warn!("window capture result source could not be registered");
return;
};
if let Err(error) = std::thread::Builder::new()
.name("ferese-capture".into())
.spawn(move || {
while let Ok(mut job) = requests.recv() {
if job.frame.is_alive() {
crate::render::convert_window_pixels(&mut job.snapshot.pixels.pixels);
}
// Permits cap the total number of queued, working and completed
// frames at MAX_FRAMES, so the result queue cannot fill here.
if results.send(job).is_err() {
break;
}
}
})
{
loop_handle.remove(token);
tracing::warn!(%error, "window capture worker could not be started");
return;
}
display.create_global::<Ferese, FereseWindowCaptureManagerV1, Global>(
3,
Global {
budget: Arc::default(),
worker,
},
);
}
fn finish_conversion(state: &Ferese, result: Conversion) {
let Conversion {
snapshot,
frame,
manager,
} = result;
let Some(data) = frame.data::<Arc<FrameData>>() else {
return;
};
if !frame.is_alive()
|| *data
.used
.lock()
.expect("surface rendering state must remain consistent across callbacks")
{
return;
}
if state.session_lock.active()
|| !super::screencopy::capture_allowed()
|| !state.capture_window_allowed(snapshot.id, snapshot.epoch)
{
*data
.used
.lock()
.expect("surface rendering state must remain consistent across callbacks") = true;
frame.failed();
return;
}
let size = (snapshot.pixels.width as u32, snapshot.pixels.height as u32);
let logical_size = snapshot.logical_size;
*data
.snapshot
.lock()
.expect("surface rendering state must remain consistent across callbacks") = Some(snapshot);
if manager.is_alive() {
manager.geometry(&frame, logical_size.0, logical_size.1);
}
frame.buffer(wl_shm::Format::Argb8888, size.0, size.1, size.0 * 4);
}
impl GlobalDispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
fn can_view(client: Client, _: &Global) -> bool {
client.get_data::<crate::state::ClientState>().is_some_and(|state| {
state
.capabilities
.contains(crate::private_client::ClientCapabilities::WINDOW_CAPTURE)
})
client
.get_data::<crate::state::ClientState>()
.is_some_and(|state| {
state
.capabilities
.contains(crate::private_client::ClientCapabilities::WINDOW_CAPTURE)
})
}
fn bind(
@@ -113,20 +259,104 @@ impl GlobalDispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
global: &Global,
data_init: &mut DataInit<'_, Self>,
) {
data_init.init(resource, global.budget.clone());
data_init.init(resource, global.clone());
}
}
impl Dispatch<FereseWindowCaptureManagerV1, Arc<Mutex<Budget>>> for Ferese {
impl Dispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
fn request(
state: &mut Self,
_: &Client,
manager: &FereseWindowCaptureManagerV1,
request: manager::Request,
budget: &Arc<Mutex<Budget>>,
global: &Global,
_: &DisplayHandle,
data_init: &mut DataInit<'_, Self>,
) {
if let manager::Request::CreateWorkspaceSource {
workspace_hi,
workspace_lo,
source,
} = request
{
use smithay::wayland::image_capture_source::{
ImageCaptureSource, ImageCaptureSourceData,
};
let id =
ferese_core::WorkspaceId((u64::from(workspace_hi) << 32) | u64::from(workspace_lo));
let capture = ImageCaptureSource::new();
let view = super::workspace_capture::WorkspaceSource::new(state, id);
if let Some(view) = &view {
capture
.user_data()
.insert_if_missing(|| super::image_capture::Source::Workspace(view.clone()));
}
let resource = data_init.init(
source,
ImageCaptureSourceData {
source: capture.clone(),
},
);
capture.add_instance(&resource);
if let Some(view) = view {
manager.source_geometry(
&resource,
view.geometry.size.w as u32,
view.geometry.size.h as u32,
);
}
return;
}
if let manager::Request::SetSourceActive { source, active } = request {
if let Some(source) =
smithay::wayland::image_capture_source::ImageCaptureSource::from_resource(&source)
{
source
.user_data()
.get_or_insert_threadsafe(super::image_capture::SourcePaused::default)
.0
.store(active != 1, std::sync::atomic::Ordering::Relaxed);
}
return;
}
if let manager::Request::RefreshSource { source } = request {
if let Some(source) =
smithay::wayland::image_capture_source::ImageCaptureSource::from_resource(&source)
{
state.refresh_capture_source(&source);
}
return;
}
if let manager::Request::CreateSource {
window_hi,
window_lo,
source,
} = request
{
use smithay::wayland::image_capture_source::{
ImageCaptureSource, ImageCaptureSourceData,
};
let id = WindowId((u64::from(window_hi) << 32) | u64::from(window_lo));
let capture = ImageCaptureSource::new();
capture
.user_data()
.insert_if_missing(|| super::image_capture::Source::Window(id));
let resource = data_init.init(
source,
ImageCaptureSourceData {
source: capture.clone(),
},
);
capture.add_instance(&resource);
if let Some(info) = state.window_capture_info(id) {
manager.source_geometry(
&resource,
info.geometry.size.w as u32,
info.geometry.size.h as u32,
);
}
return;
}
if let manager::Request::CaptureWindow {
window_hi,
window_lo,
@@ -137,13 +367,12 @@ impl Dispatch<FereseWindowCaptureManagerV1, Arc<Mutex<Budget>>> for Ferese {
let id = WindowId((u64::from(window_hi) << 32) | u64::from(window_lo));
let mut busy = false;
let snapshot = (overlay_cursor <= 1)
.then(|| state.window_snapshot(id, overlay_cursor == 1, budget, &mut busy))
.then(|| state.window_snapshot(id, overlay_cursor == 1, &global.budget, &mut busy))
.flatten();
let size = snapshot
.as_ref()
.map(|snapshot| (snapshot.pixels.width, snapshot.pixels.height))
.unwrap_or((1, 1));
let logical_size = snapshot.as_ref().map(|snapshot| snapshot.logical_size);
let failed = snapshot.is_none();
let resource = data_init.init(
frame,
@@ -152,7 +381,8 @@ impl Dispatch<FereseWindowCaptureManagerV1, Arc<Mutex<Budget>>> for Ferese {
region: Rectangle::from_size(size.into()),
overlay_cursor: false,
used: Mutex::new(failed),
snapshot: Mutex::new(snapshot),
snapshot: Mutex::new(None),
history: None,
}),
);
if failed {
@@ -160,29 +390,41 @@ impl Dispatch<FereseWindowCaptureManagerV1, Arc<Mutex<Budget>>> for Ferese {
manager.busy(&resource);
}
resource.failed();
} else {
let logical_size = logical_size.unwrap();
manager.geometry(&resource, logical_size.0, logical_size.1);
resource.buffer(
wl_shm::Format::Argb8888,
size.0 as u32,
size.1 as u32,
size.0 as u32 * 4,
);
} else if global
.worker
.try_send(Conversion {
snapshot: snapshot
.expect("conversion is queued only after obtaining a capture snapshot"),
frame: resource.clone(),
manager: manager.clone(),
})
.is_err()
{
*resource
.data::<Arc<FrameData>>()
.expect("capture frame resources are initialized with their FrameData")
.used
.lock()
.expect("surface rendering state must remain consistent across callbacks") =
true;
manager.busy(&resource);
resource.failed();
}
}
}
}
pub(crate) struct WindowInfo {
pub window: smithay::desktop::Window,
pub output: smithay::output::Output,
pub geometry: Rectangle<i32, Logical>,
pub scale: f64,
pub bytes: usize,
}
impl Ferese {
fn window_snapshot(
&mut self,
id: WindowId,
overlay_cursor: bool,
budget: &Arc<Mutex<Budget>>,
busy: &mut bool,
) -> Option<Snapshot> {
if self.session_lock.active || !super::screencopy::capture_allowed() {
pub(super) fn window_capture_info(&self, id: WindowId) -> Option<WindowInfo> {
if self.session_lock.active() || !super::screencopy::capture_allowed() {
return None;
}
let window = self
@@ -192,6 +434,9 @@ impl Ferese {
.find(|(_, candidate)| **candidate == id)?
.0
.clone();
if self.capture_protected(&window) {
return None;
}
let workspace = self.workspaces.workspace_for_window(id)?;
let owner = self.output_workspaces.output_for_workspace(workspace)?;
let output = self
@@ -201,8 +446,36 @@ impl Ferese {
.0
.clone();
let geometry = window.geometry();
let scale = output.current_scale().fractional_scale();
let size: smithay::utils::Size<i32, smithay::utils::Physical> = geometry.size.to_physical_precise_round(scale);
let output_scale = output.current_scale().fractional_scale();
// A low-density parent can contain higher-density content. Match the
// rendered surface tree, excluding buffers outside the captured geometry.
let mut scale = output_scale;
if let Some(toplevel) = window.toplevel() {
with_surface_tree_downward(
toplevel.wl_surface(),
Point::<f64, Logical>::default(),
|_, states, location| {
let Some(data) = states.data_map.get::<RendererSurfaceStateUserData>() else {
return TraversalAction::SkipChildren;
};
let data = data
.lock()
.expect("Smithay surface metadata must remain consistent across callbacks");
let Some(view) = data.view() else {
return TraversalAction::SkipChildren;
};
let location = *location + view.offset.to_f64();
if Rectangle::new(location, view.dst.to_f64()).overlaps(geometry.to_f64()) {
scale = capture_scale(scale, data.buffer_scale(), view);
}
TraversalAction::DoChildren(location)
},
|_, _, _| {},
|_, _, _| true,
);
}
let size: smithay::utils::Size<i32, smithay::utils::Physical> =
geometry.size.to_physical_precise_round(scale);
let bytes = usize::try_from(size.w)
.ok()?
.checked_mul(usize::try_from(size.h).ok()?)?
@@ -210,19 +483,31 @@ impl Ferese {
if bytes == 0 || bytes > MAX_BYTES {
return None;
}
let Some(permit) = Permit::reserve(budget, bytes) else {
*busy = true;
return None;
};
let cursor = if overlay_cursor && !self.overview.is_presenting() {
Some(WindowInfo {
window,
output,
geometry,
scale,
bytes,
})
}
pub(super) fn window_capture_cursor(
&self,
id: WindowId,
window: &smithay::desktop::Window,
geometry: Rectangle<i32, Logical>,
) -> Option<Rectangle<i32, Logical>> {
if !self.overview.is_presenting() && !self.capture_cursor_protected() {
self.seat.get_pointer().and_then(|pointer| {
let location = pointer.current_location();
if self.window_under_visual(location).as_ref() != Some(&window) {
if self.window_under_visual(location).as_ref() != Some(window) {
return None;
}
let (x, y) = self.inverse_presented_window_point(id, location.x, location.y)?;
let surface_point = smithay::utils::Point::from((x, y)) + geometry.loc.to_f64();
let (surface, _) = window.surface_under(surface_point, smithay::desktop::WindowSurfaceType::ALL)?;
let (surface, _) = window
.surface_under(surface_point, smithay::desktop::WindowSurfaceType::ALL)?;
if pointer.current_focus().as_ref() != Some(&surface) {
return None;
}
@@ -233,7 +518,30 @@ impl Ferese {
})
} else {
None
}
}
fn window_snapshot(
&mut self,
id: WindowId,
overlay_cursor: bool,
budget: &Arc<Mutex<Budget>>,
busy: &mut bool,
) -> Option<Snapshot> {
let WindowInfo {
window,
output,
geometry,
scale,
bytes,
} = self.window_capture_info(id)?;
let Some(permit) = Permit::reserve(budget, bytes) else {
*busy = true;
return None;
};
let cursor = overlay_cursor
.then(|| self.window_capture_cursor(id, &window, geometry))
.flatten();
let result = if let Some(backend) = self.nested_backend.clone() {
let mut backend = backend.try_borrow_mut().ok()?;
crate::render::capture_window_frame(
@@ -245,7 +553,7 @@ impl Ferese {
)
} else {
let mut backend = self.direct_backend.take()?;
let result = backend.capture_window_frame(self, &window, &output, cursor);
let result = backend.capture_window_frame(self, &window, &output, cursor, scale);
self.direct_backend = Some(backend);
result
};
@@ -264,6 +572,7 @@ impl Ferese {
}
Some(Snapshot {
id,
epoch: self.capture_epoch,
logical_size: (geometry.size.w as u32, geometry.size.h as u32),
pixels,
_permit: permit,
@@ -271,20 +580,36 @@ impl Ferese {
}
}
pub(super) fn copy_snapshot(state: &Ferese, resource: &ZwlrScreencopyFrameV1, data: &FrameData, buffer: &WlBuffer) {
let mut used = data.used.lock().unwrap();
pub(super) fn copy_snapshot(
state: &Ferese,
resource: &ZwlrScreencopyFrameV1,
data: &FrameData,
buffer: &WlBuffer,
) {
let mut used = data
.used
.lock()
.expect("surface rendering state must remain consistent across callbacks");
if *used {
resource.post_error(frame::Error::AlreadyUsed, "window capture frame has already been used");
resource.post_error(
frame::Error::AlreadyUsed,
"window capture frame has already been used",
);
return;
}
*used = true;
let Some(snapshot) = data.snapshot.lock().unwrap().take() else {
let Some(snapshot) = data
.snapshot
.lock()
.expect("surface rendering state must remain consistent across callbacks")
.take()
else {
resource.failed();
return;
};
if state.session_lock.active
if state.session_lock.active()
|| !super::screencopy::capture_allowed()
|| !state.windows.ids().values().any(|id| *id == snapshot.id)
|| !state.capture_window_allowed(snapshot.id, snapshot.epoch)
{
resource.failed();
return;
@@ -307,7 +632,11 @@ pub(super) fn copy_snapshot(state: &Ferese, resource: &ZwlrScreencopyFrameV1, da
// SAFETY: Smithay owns the writable mapping, validated format/stride and
// checked offset cover the complete snapshot; the source is separately owned.
unsafe {
std::ptr::copy_nonoverlapping(snapshot.pixels.pixels.as_ptr(), destination.add(offset), bytes);
std::ptr::copy_nonoverlapping(
snapshot.pixels.pixels.as_ptr(),
destination.add(offset),
bytes,
);
}
true
})
@@ -326,9 +655,165 @@ pub(super) fn copy_snapshot(state: &Ferese, resource: &ZwlrScreencopyFrameV1, da
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
#[test]
fn sharing_preserves_backing_density_at_fractional_display_scales() {
let integer = SurfaceView {
src: Rectangle::from_size((640., 480.).into()),
dst: (640, 480).into(),
offset: (0, 0).into(),
};
let viewport = SurfaceView {
src: Rectangle::from_size((1280., 960.).into()),
..integer
};
for output_scale in [1., 1.25, 1.5, 1.75, 2.] {
assert_eq!(capture_scale(output_scale, 2, integer), 2.);
assert_eq!(capture_scale(output_scale, 1, viewport), 2.);
}
assert_eq!(capture_scale(2.5, 2, integer), 2.5);
let fractional = SurfaceView {
src: Rectangle::from_size((1120., 840.).into()),
..integer
};
assert_eq!(capture_scale(1.25, 1, fractional), 1.75);
assert_eq!(capture_scale(1.75, 1, fractional), 1.75);
}
#[test]
fn sharing_uses_the_viewport_crop_instead_of_the_whole_buffer() {
let cropped = SurfaceView {
src: Rectangle::new((320., 240.).into(), (640., 480.).into()),
dst: (640, 480).into(),
offset: (0, 0).into(),
};
assert_eq!(capture_scale(1.75, 1, cropped), 1.75);
}
#[test]
fn sharing_preserves_independently_rounded_fractional_buffers() {
for (width, height) in [(3819., 2091.), (3819., 2092.)] {
let view = SurfaceView {
src: Rectangle::from_size((width, height).into()),
dst: (2182, 1195).into(),
offset: (0, 0).into(),
};
for output_scale in [1.25, 1.5, 1.75] {
let scale = capture_scale(output_scale, 1, view);
assert!(scale >= output_scale);
assert_eq!((2182. * scale).round(), width);
assert_eq!((1195. * scale).round(), height);
}
}
}
/// Exercise both asynchronous publication boundaries using real resources.
pub(crate) fn check_deferred_snapshot(
state: &Ferese,
window: &smithay::desktop::Window,
epoch: u64,
allowed: bool,
) {
let client = window.toplevel().unwrap().wl_surface().client().unwrap();
let id = state.windows.ids()[window];
let budget = Arc::default();
let snapshot = || Snapshot {
id,
epoch,
logical_size: (64, 48),
pixels: CaptureBuffer {
width: 64,
height: 48,
stride: 256,
pixels: vec![0; 64 * 48 * 4],
},
_permit: Permit::reserve(&budget, 64 * 48 * 4).unwrap(),
};
let frame_data = || {
Arc::new(FrameData {
output: None,
region: Rectangle::from_size((64, 48).into()),
overlay_cursor: false,
used: Mutex::new(false),
snapshot: Mutex::new(None),
history: None,
})
};
let (worker, _requests) = std::sync::mpsc::sync_channel(1);
let manager = client
.create_resource::<FereseWindowCaptureManagerV1, Global, Ferese>(
&state.display_handle,
1,
Global {
budget: budget.clone(),
worker,
},
)
.unwrap();
let data = frame_data();
let frame = client
.create_resource::<ZwlrScreencopyFrameV1, Arc<FrameData>, Ferese>(
&state.display_handle,
3,
data.clone(),
)
.unwrap();
finish_conversion(
state,
Conversion {
snapshot: snapshot(),
frame,
manager,
},
);
assert_eq!(data.snapshot.lock().unwrap().is_some(), allowed);
assert_eq!(*data.used.lock().unwrap(), !allowed);
let data = frame_data();
*data.snapshot.lock().unwrap() = Some(snapshot());
let frame = client
.create_resource::<ZwlrScreencopyFrameV1, Arc<FrameData>, Ferese>(
&state.display_handle,
3,
data.clone(),
)
.unwrap();
let buffer = client
.object_from_protocol_id::<WlBuffer>(&state.display_handle, 6)
.unwrap();
let before = with_buffer_contents_mut(&buffer, |ptr, _, layout| {
// SAFETY: the fixture allocates and validates this 64x48 SHM buffer.
unsafe {
std::slice::from_raw_parts(ptr.add(layout.offset as usize), 64 * 48 * 4).to_vec()
}
})
.unwrap();
copy_snapshot(state, &frame, &data, &buffer);
with_buffer_contents_mut(&buffer, |ptr, _, layout| {
// SAFETY: same fixture-owned buffer as above.
let after =
unsafe { std::slice::from_raw_parts(ptr.add(layout.offset as usize), 64 * 48 * 4) };
if allowed {
assert!(after.iter().all(|byte| *byte == 0));
} else {
assert_eq!(after, before, "rejected snapshot wrote private pixels");
}
// Restore the client's colored buffer for subsequent renderer checks.
// SAFETY: before was copied from this checked SHM layout, so the destination has
// before.len() writable bytes and does not overlap the owned backup.
unsafe {
std::ptr::copy_nonoverlapping(
before.as_ptr(),
ptr.add(layout.offset as usize),
before.len(),
);
}
})
.unwrap();
}
#[test]
fn outstanding_snapshots_release_both_limits_when_dropped() {
let budget = Arc::default();
@@ -0,0 +1,450 @@
//! Workspace capture owns a view, not a second desktop. Targets exist only for
//! subscribed sources; surface commits invalidate only their owning workspace.
use std::time::{Duration, Instant};
use ferese_core::WorkspaceId;
use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture};
use smithay::output::Output;
use smithay::utils::{Buffer, Logical, Rectangle, Size, Transform};
use crate::Ferese;
use crate::backends::direct::capture::CaptureTarget;
#[derive(Clone)]
pub(crate) struct WorkspaceSource {
pub id: WorkspaceId,
pub output: Output,
pub render_output: Output,
native_size: Size<i32, smithay::utils::Physical>,
pub geometry: Rectangle<i32, Logical>,
pub size: Size<i32, Buffer>,
scale: f64,
transform: Transform,
}
impl WorkspaceSource {
pub fn new(state: &Ferese, id: WorkspaceId) -> Option<Self> {
state.workspaces.workspace(id)?;
let owner = state.output_workspaces.output_for_workspace(id)?;
let output = state
.output_ids
.iter()
.find(|(_, id)| **id == owner)?
.0
.clone();
let native_size = output.current_mode()?.size;
let geometry = state.space.output_geometry(&output)?;
if native_size.w <= 0 || native_size.h <= 0 || geometry.size.w <= 0 || geometry.size.h <= 0
{
return None;
}
let scale = output.current_scale().fractional_scale();
let (size, capture_scale) = capture_size(geometry.size, scale);
// A render descriptor only: never registered, mapped, advertised, or
// attached to input/layout. It gives shaders a bounded upright canvas.
let render_output = Output::new(
format!("workspace-capture-{}", id.0),
smithay::output::PhysicalProperties {
size: (0, 0).into(),
subpixel: smithay::output::Subpixel::Unknown,
make: String::new(),
model: String::new(),
serial_number: String::new(),
},
);
render_output.change_current_state(
Some(smithay::output::Mode {
size: (size.w, size.h).into(),
refresh: 60_000,
}),
Some(Transform::Normal),
Some(smithay::output::Scale::Fractional(capture_scale)),
None,
);
render_output
.user_data()
.insert_if_missing(|| CaptureOutputId(owner));
Some(Self {
id,
output: output.clone(),
render_output,
geometry,
size,
native_size,
scale,
transform: output.current_transform(),
})
}
pub fn valid(&self, state: &Ferese) -> bool {
state.workspaces.workspace(self.id).is_some()
&& state.output_id(&self.output).is_some_and(|owner| {
state.output_workspaces.output_for_workspace(self.id) == Some(owner)
})
&& state.space.output_geometry(&self.output) == Some(self.geometry)
&& self
.output
.current_mode()
.is_some_and(|mode| mode.size == self.native_size)
&& self.output.current_scale().fractional_scale() == self.scale
&& self.output.current_transform() == self.transform
}
pub fn cursor_visible(&self, state: &Ferese) -> bool {
!state.overview.is_presenting()
&& !state.input_capture.active()
&& state
.output_id(&self.output)
.is_some_and(|output| state.workspace_capture_visible(self.id, output))
&& state.seat.get_pointer().is_some_and(|pointer| {
self.geometry
.contains(pointer.current_location().to_i32_floor())
})
&& !state.capture_cursor_protected()
}
}
pub(super) struct WorkspaceCapture {
pub source: WorkspaceSource,
pub content: u64,
pub prepared: Option<u64>,
pub cursor: Option<super::image_capture::CursorStamp>,
pub epoch: u64,
pub next_frame: Instant,
pub callbacks_checked: Option<u64>,
pub callbacks_pending: bool,
pub target: Option<CaptureTarget>,
pub resources: crate::render::RenderResources,
pub scene: Option<crate::state::FrameScene>,
pub scene_dirty: bool,
pub last_used: Instant,
trace: bool,
preparations: u64,
compositions: u64,
last_revision: Option<u64>,
}
impl WorkspaceCapture {
pub fn new(source: WorkspaceSource, epoch: u64) -> Self {
Self {
source,
content: 0,
prepared: None,
cursor: None,
epoch,
next_frame: Instant::now(),
callbacks_checked: None,
callbacks_pending: false,
target: None,
resources: Default::default(),
scene: None,
scene_dirty: true,
last_used: Instant::now(),
trace: std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some(),
preparations: 0,
compositions: 0,
last_revision: None,
}
}
pub fn invalidate(&mut self) {
self.content_changed();
self.scene_dirty = true;
}
pub fn release_idle_target(&mut self) {
self.target = None;
self.resources = Default::default();
self.scene = None;
self.prepared = None;
if self.trace {
tracing::info!(target: "ferese::render", workspace = self.source.id.0, "released idle workspace capture target");
}
}
pub fn content_changed(&mut self) {
self.content = self.content.wrapping_add(1);
}
fn callbacks(&self, state: &Ferese, send: bool) -> bool {
let (Some(scene), Some(target)) = (&self.scene, &self.target) else {
return false;
};
let mut pending = false;
for id in scene.windows.keys() {
let Some(window) = state
.windows
.window(*id)
.filter(|window| !state.capture_protected(window))
else {
continue;
};
let mut visible = false;
window.with_surfaces(|surface, _| visible |= target.surface_visible(surface));
if !visible {
continue;
}
// Normal display callbacks may already have drained these. Capture
// never generates presentation feedback or changes scanout ownership.
if send {
window.send_frame(
&self.source.output,
state.start_time.elapsed(),
Some(interval()),
|_, _| None,
);
}
window.with_surfaces(|_, states| {
pending |= !states
.cached_state
.get::<smithay::wayland::compositor::SurfaceAttributes>()
.current()
.frame_callbacks
.is_empty();
});
}
pending
}
pub fn render(
&mut self,
state: &mut Ferese,
renderer: &mut GlesRenderer,
cursor: bool,
) -> Result<(GlesTexture, u64), String> {
if self.epoch != state.capture_epoch {
self.resources = Default::default();
self.target = None;
self.epoch = state.capture_epoch;
self.callbacks_checked = None;
}
if self.scene_dirty || self.scene.is_none() {
let scene = state.sample_workspace_frame(self.source.id, &self.source.output);
let members = state
.workspaces
.workspace(self.source.id)
.into_iter()
.flat_map(|workspace| {
workspace
.layout
.window_ids()
.chain(workspace.floating.iter().copied())
})
.collect();
self.resources.retain_workspace_windows(&members);
self.scene = Some(scene);
self.scene_dirty = false;
}
let scene = self.scene.as_ref().expect("capture scene sampled");
let elements = crate::render::capture_workspace_elements(
state,
renderer,
&self.source,
cursor,
scene,
&mut self.resources,
);
let result = crate::backends::direct::capture::render_capture_with_revision(
renderer,
&mut self.target,
&self.source.render_output,
&elements,
)
.map(|_| ())
.map_err(|error| error.to_string());
if let Err(error) = result {
self.target = None;
self.prepared = None;
return Err(error);
}
self.preparations += 1;
self.prepared = Some(self.content);
let target = self
.target
.as_ref()
.expect("successful capture has a target");
let image = target.image();
if self.last_revision != Some(image.1) {
self.compositions += 1;
}
self.last_revision = Some(image.1);
Ok(image)
}
}
impl Drop for WorkspaceCapture {
fn drop(&mut self) {
if self.trace {
tracing::info!(target: "ferese::render", workspace = self.source.id.0, preparations = self.preparations, compositions = self.compositions, "workspace capture outcomes");
}
}
}
/// The compositor enforces a ceiling even for a client that bypasses PipeWire's
/// pacing. A shared deadline is used for both hidden-client callbacks and frames.
pub(super) fn interval() -> Duration {
static INTERVAL: std::sync::OnceLock<Duration> = std::sync::OnceLock::new();
*INTERVAL.get_or_init(|| {
let fps = std::env::var("FERESE_CAPTURE_MAX_FPS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(60)
.clamp(1, 240);
Duration::from_nanos(1_000_000_000 / fps)
})
}
/// Shader cache identity; this does not grant the descriptor a desktop output.
pub(crate) struct CaptureOutputId(pub ferese_core::OutputId);
fn capture_size(logical: Size<i32, Logical>, native_scale: f64) -> (Size<i32, Buffer>, f64) {
let (width, height) = if logical.w >= logical.h {
(1920.0, 1080.0)
} else {
(1080.0, 1920.0)
};
let scale = native_scale
.min(width / f64::from(logical.w.max(1)))
.min(height / f64::from(logical.h.max(1)));
(
(
(f64::from(logical.w) * scale).round().max(1.0) as i32,
(f64::from(logical.h) * scale).round().max(1.0) as i32,
)
.into(),
scale,
)
}
fn with_renderer<T>(
state: &mut Ferese,
output: &Output,
render: impl FnOnce(&mut Ferese, &mut GlesRenderer) -> Result<T, String>,
) -> Result<T, String> {
if let Some(backend) = state.nested_backend.clone() {
let mut backend = backend
.try_borrow_mut()
.map_err(|error| error.to_string())?;
render(state, backend.renderer())
} else if let Some(mut backend) = state.direct_backend.take() {
let result = backend
.capture_renderer(output)
.ok_or_else(|| "Capture output is unavailable".to_string())
.and_then(|renderer| render(state, renderer));
state.direct_backend = Some(backend);
result
} else {
Err("Capture renderer is unavailable".into())
}
}
pub(super) fn process(
view: &mut WorkspaceCapture,
state: &mut Ferese,
active: &mut super::image_capture::Active,
frame: smithay::wayland::image_copy_capture::Frame,
compositions_left: &mut usize,
) -> Option<Duration> {
use smithay::reexports::wayland_protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_frame_v1::FailureReason;
let now = Instant::now();
view.last_used = now;
let cursor = active.session.draw_cursor() && view.source.cursor_visible(state);
let cursor_stamp = cursor
.then(|| state.capture_cursor_stamp(view.source.geometry))
.flatten();
let changed = view.prepared != Some(view.content)
|| view.epoch != state.capture_epoch
|| view.cursor != cursor_stamp;
let interval = super::workspace_capture::interval();
let mut retry = None;
let image = if changed && (now < view.next_frame || *compositions_left == 0) {
retry = Some(
view.next_frame
.saturating_duration_since(now)
.max(Duration::from_millis(1)),
);
None
} else if changed {
*compositions_left -= 1;
let output = view.source.output.clone();
let result = with_renderer(state, &output, |state, renderer| {
view.render(state, renderer, cursor)
});
match result {
Ok(image) => {
view.cursor = cursor_stamp;
view.next_frame = now + interval;
Some(image)
}
Err(error) => {
tracing::warn!(%error, "workspace capture failed");
frame.fail(FailureReason::Unknown);
return None;
}
}
} else {
view.target.as_ref().map(|target| target.image())
};
if let Some((texture, revision)) =
image.filter(|(_, revision)| active.revision != Some(*revision))
{
crate::backends::direct::capture::touch_cache(state);
let result = with_renderer(state, &view.source.output, |_, renderer| {
crate::render::capture_stream_texture(
renderer,
&texture,
&frame.buffer(),
Transform::Normal,
false,
)
});
active.revision = result.as_ref().ok().map(|_| revision);
state.finish_stream_frame(frame, active.session.as_ref(), result);
} else {
active.pending = Some(frame);
}
let deadline = state
.image_capture
.workspace_callback_deadline(view.source.id, now);
if view.prepared == Some(view.content)
&& (view.callbacks_checked != Some(view.content)
|| (view.callbacks_pending && now >= deadline))
{
view.callbacks_pending = view.callbacks(state, now >= deadline);
view.callbacks_checked = Some(view.content);
if now >= deadline {
state
.image_capture
.set_workspace_callback_deadline(view.source.id, now + interval);
}
}
if view.callbacks_pending {
let delay = state
.image_capture
.workspace_callback_deadline(view.source.id, now)
.saturating_duration_since(now);
retry = Some(retry.map_or(delay, |existing| existing.min(delay)));
}
retry
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_canvas_is_bounded_and_preserves_fractional_and_portrait_geometry() {
for (logical, native, expected) in [
((1920, 1080), 2.0, (1920, 1080)),
((1080, 1920), 2.0, (1080, 1920)),
((800, 600), 1.25, (1000, 750)),
((3440, 1440), 1.0, (1920, 804)),
] {
let (size, scale) = capture_size(logical.into(), native);
assert_eq!(size, expected.into());
assert!(scale <= native);
assert!(size.w as u64 * size.h as u64 <= 1920 * 1080);
}
}
}
@@ -0,0 +1,215 @@
use super::super::workspace_capture::{WorkspaceCapture, WorkspaceSource};
use super::*;
use smithay::output::{Mode, PhysicalProperties, Scale, Subpixel};
fn fixture() -> (
smithay::reexports::calloop::EventLoop<'static, Ferese>,
Ferese,
Output,
) {
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut config = crate::config::Config::default().runtime_config().unwrap();
config.animations_enabled = false;
config.wallpaper.path = None;
let mut state = Ferese::new(
&mut events,
smithay::reexports::wayland_server::Display::new().unwrap(),
config,
)
.unwrap();
let output = Output::new(
"workspace-test".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: String::new(),
model: String::new(),
serial_number: String::new(),
},
);
output.change_current_state(
Some(Mode {
size: (1600, 1200).into(),
refresh: 60_000,
}),
None,
Some(Scale::Fractional(2.0)),
None,
);
state.space.map_output(&output, (0, 0));
state.register_output(&output, "workspace-test".into());
(events, state, output)
}
#[test]
fn workspace_capture_samples_hidden_layout_without_switching_or_configuring_clients() {
if !crate::startup_tests::private_runtime(
"handlers::image_capture::workspace_tests::workspace_capture_samples_hidden_layout_without_switching_or_configuring_clients",
) {
return;
}
let (mut events, mut state, output) = fixture();
let (window, _client) = crate::state::test_window(&mut state, &mut events, 0xffff0000);
let id = state.windows.ids()[&window];
let workspace = state.workspaces.workspace_for_window(id).unwrap();
let source = WorkspaceSource::new(&state, workspace).unwrap();
state.switch_workspace(2);
let (private, _private_client) = crate::state::test_window(&mut state, &mut events, 0xff00ff00);
let private_id = state.windows.ids()[&private];
let focused = state.focused_window;
let active = state.workspaces.active_id();
let geometry = *state.windows.geometry(&id).unwrap();
let sample = state.sample_workspace_frame(workspace, &output);
assert!(sample.windows.contains_key(&id));
assert!(!sample.windows.contains_key(&private_id));
assert!(!sample.overview.is_presenting());
assert!(sample.capture_tabs.is_some());
assert_eq!(state.workspaces.active_id(), active);
assert_eq!(state.focused_window, focused);
assert_eq!(*state.windows.geometry(&id).unwrap(), geometry);
assert!(state.space.element_location(&window).is_none());
assert!(source.valid(&state));
// The descriptor is never part of the desktop or advertised to clients.
assert!(state.output_id(&source.render_output).is_none());
assert!(state.space.output_geometry(&source.render_output).is_none());
// A newly mapped hidden client need not have acquired a display sample.
state.windows.record_mut(id).unwrap().geometry = None;
assert!(
state
.sample_workspace_frame(workspace, &output)
.windows
.contains_key(&id)
);
assert!(state.windows.record(id).unwrap().geometry.is_none());
output.change_current_state(None, None, Some(Scale::Fractional(1.5)), None);
assert!(!source.valid(&state));
}
#[test]
fn workspace_commits_invalidate_only_subscribed_source_and_allocate_no_targets() {
if !crate::startup_tests::private_runtime(
"handlers::image_capture::workspace_tests::workspace_commits_invalidate_only_subscribed_source_and_allocate_no_targets",
) {
return;
}
let (mut events, mut state, _) = fixture();
let (first, _first_client) = crate::state::test_window(&mut state, &mut events, 0xffff0000);
let first_id = state.windows.ids()[&first];
let workspace = state.workspaces.workspace_for_window(first_id).unwrap();
let source = WorkspaceSource::new(&state, workspace).unwrap();
state.switch_workspace(2);
let (second, _second_client) = crate::state::test_window(&mut state, &mut events, 0xff00ff00);
for cursor in [false, true] {
state.image_capture.workspaces.insert(
(workspace, cursor),
WorkspaceCapture::new(source.clone(), state.capture_epoch),
);
}
crate::backends::direct::render_surface(&mut state, second.toplevel().unwrap().wl_surface());
for view in state.image_capture.workspaces.values() {
assert_eq!(view.content, 0);
assert!(view.target.is_none());
}
crate::backends::direct::render_surface(&mut state, first.toplevel().unwrap().wl_surface());
for view in state.image_capture.workspaces.values() {
assert_eq!(view.content, 1);
}
state
.image_capture
.workspace_callbacks
.insert(workspace, Instant::now());
state.refresh_capture_sessions();
assert!(state.image_capture.workspaces.is_empty());
assert!(state.image_capture.workspace_callbacks.is_empty());
assert!(state.image_capture.callback_timer.is_none());
}
#[test]
fn capture_admission_accounts_for_the_pool_and_transfer_targets() {
assert_eq!(stream_bytes((1920, 1080).into()), 8192 * 1080 * 10);
assert!(stream_bytes((3840, 2160).into()) < MAX_STREAM_BYTES);
assert!(stream_bytes((i32::MAX, i32::MAX).into()) > MAX_STREAM_BYTES);
}
#[test]
fn workspace_tab_metadata_invalidates_only_owning_capture() {
if !crate::startup_tests::private_runtime(
"handlers::image_capture::workspace_tests::workspace_tab_metadata_invalidates_only_owning_capture",
) {
return;
}
use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::{XdgShellHandler, XdgToplevelSurfaceData};
for hidden in [false, true] {
for title in [false, true] {
let (mut events, mut state, output) = fixture();
let (first, _first_client) =
crate::state::test_window(&mut state, &mut events, 0xffff0000);
state.toggle_column_tabbed();
let (_second, _second_client) =
crate::state::test_window(&mut state, &mut events, 0xff00ff00);
state.consume_focused_window();
let workspace = state.workspaces.active_id();
let scene = state.sample_workspace_frame(workspace, &output);
assert!(!scene.capture_tabs.as_ref().unwrap().is_empty());
assert!(state.space.element_location(&first).is_none());
state.switch_workspace(2);
let (_other, _other_client) =
crate::state::test_window(&mut state, &mut events, 0xff0000ff);
let other_workspace = state.workspaces.active_id();
if !hidden {
state.switch_workspace(1);
}
for id in [workspace, other_workspace] {
for cursor in [false, true] {
let mut view = WorkspaceCapture::new(
WorkspaceSource::new(&state, id).unwrap(),
state.capture_epoch,
);
view.prepared = Some(view.content);
view.scene = Some(state.sample_workspace_frame(id, &output));
view.scene_dirty = false;
state.image_capture.workspaces.insert((id, cursor), view);
}
}
let epoch = state.capture_epoch;
let top = first.toplevel().unwrap();
with_states(top.wl_surface(), |states| {
let mut metadata = states
.data_map
.get::<XdgToplevelSurfaceData>()
.unwrap()
.lock()
.unwrap();
if title {
metadata.title = Some("Updated tab title".into());
} else {
metadata.title = None;
metadata.app_id = Some("org.test.UpdatedApp".into());
}
});
if title {
state.title_changed(top.clone());
} else {
state.app_id_changed(top.clone());
}
assert_eq!(state.capture_epoch, epoch);
for cursor in [false, true] {
let view = &state.image_capture.workspaces[&(workspace, cursor)];
assert_ne!(
view.prepared,
Some(view.content),
"tab metadata must invalidate its capture (hidden={hidden}, title={title})"
);
assert!(
!view.scene_dirty,
"metadata must not force a layout resample"
);
let other = &state.image_capture.workspaces[&(other_workspace, cursor)];
assert_eq!(other.prepared, Some(other.content));
}
}
}
}
+224 -126
View File
@@ -1,23 +1,25 @@
use smithay::desktop::{
PopupKeyboardGrab, PopupKind, PopupManager, PopupPointerGrab, Space, Window, WindowSurfaceType,
find_popup_root_surface, get_popup_toplevel_coords,
PopupKeyboardGrab, PopupKind, PopupPointerGrab, Window, WindowSurfaceType, find_popup_root_surface,
get_popup_toplevel_coords,
};
use smithay::input::Seat;
use smithay::input::pointer::{Focus, GrabStartData};
use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode;
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::State as ToplevelState;
use smithay::reexports::wayland_server::Resource;
use smithay::reexports::wayland_server::protocol::wl_seat;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, Resource, backend::ClientId};
use smithay::utils::{Logical, Point, Rectangle, Serial};
use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler;
use smithay::wayland::shell::xdg::{
PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState, XdgToplevelSurfaceData,
PopupSurface, PositionerState, SurfaceCachedState, ToplevelSurface, XdgShellHandler, XdgShellState,
XdgShellSurfaceUserData, XdgToplevelSurfaceData,
};
use crate::Ferese;
use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab, handle_resize_commit};
use crate::grabs::ResizeEdge;
impl XdgShellHandler for Ferese {
fn xdg_shell_state(&mut self) -> &mut XdgShellState {
@@ -30,47 +32,87 @@ impl XdgShellHandler for Ferese {
// discoverable, but do not mutate/focus the layout until its initial
// commit supplies the metadata needed to resolve placement rules.
self.space.map_element(window, (0, 0), false);
self.invalidate_window_stacking();
}
fn title_changed(&mut self, surface: ToplevelSurface) {
self.refresh_capture_privacy();
if self.windows.refresh_metadata(surface.wl_surface()) {
if let Some(workspace) = self
.windows
.id_for_surface(surface.wl_surface())
.and_then(|id| self.workspaces.workspace_for_window(id))
{
self.image_capture.workspace_changed(workspace);
}
self.refresh_idle_inhibition();
self.send_shell_snapshots();
// Ordinary titles belong to the shell; only compositor-drawn labels
// need output damage. Privacy changes already invalidate above.
let tab_title = self
.windows
.id_for_surface(surface.wl_surface())
.is_some_and(|id| {
self.workspaces
.workspace_for_window(id)
.is_some_and(|workspace| {
self.visible_tab_bars(workspace)
.any(|bar| bar.group.windows.contains(&id))
})
});
if self.overview.is_presenting() || tab_title {
for output in self.surface_outputs(surface.wl_surface()) {
self.defer_output_redraw(output);
}
}
}
}
fn app_id_changed(&mut self, surface: ToplevelSurface) {
self.refresh_capture_privacy();
if self.windows.refresh_metadata(surface.wl_surface()) {
if let Some(workspace) = self
.windows
.id_for_surface(surface.wl_surface())
.and_then(|id| self.workspaces.workspace_for_window(id))
{
self.image_capture.workspace_changed(workspace);
}
self.refresh_idle_inhibition();
self.send_shell_snapshots();
for output in self.surface_outputs(surface.wl_surface()) {
self.defer_output_redraw(output);
}
}
}
fn parent_changed(&mut self, surface: ToplevelSurface) {
let Some(parent) = surface.parent() else {
return;
};
let parent = self.windows.ids().iter().find_map(|(window, id)| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == &parent)
.then_some(*id)
});
self.refresh_capture_privacy();
self.refresh_idle_inhibition();
let parent = surface
.parent()
.and_then(|parent| self.windows.id_for_surface(&parent));
let child = self
.space
.elements()
.find(|window| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
.windows
.id_for_surface(surface.wl_surface())
.and_then(|id| self.windows.window(id))
.or_else(|| {
self.space.elements().find(|window| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
})
})
.cloned();
if let (Some(parent), Some(child)) = (parent, child) {
self.make_window_transient(&child, parent);
if let Some(child) = child {
self.update_window_parent(&child, parent);
}
}
fn toplevel_destroyed(&mut self, surface: ToplevelSurface) {
// Hidden workspace windows are managed but unmapped from Space.
let Some(window) = self
.windows
.ids()
.keys()
.chain(self.space.elements())
.find(|window| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
})
.cloned()
else {
let Some(window) = self.window_for_surface(surface.wl_surface()) else {
return;
};
@@ -84,7 +126,12 @@ impl XdgShellHandler for Ferese {
}
}
fn reposition_request(&mut self, surface: PopupSurface, positioner: PositionerState, token: u32) {
fn reposition_request(
&mut self,
surface: PopupSurface,
positioner: PositionerState,
token: u32,
) {
surface.with_pending_state(|state| {
state.geometry = positioner.get_geometry();
state.positioner = positioner;
@@ -94,7 +141,7 @@ impl XdgShellHandler for Ferese {
}
fn move_request(&mut self, surface: ToplevelSurface, seat: wl_seat::WlSeat, serial: Serial) {
if self.session_lock.active {
if self.session_lock.active() {
return;
}
let Some(seat) = Seat::from_resource(&seat) else {
@@ -118,26 +165,7 @@ impl XdgShellHandler for Ferese {
else {
return;
};
let Some(initial_rect) = self.visual_rect_for_window(&window) else {
return;
};
if !self.is_floating_window(&window) {
return;
}
pointer.set_grab(
self,
MoveSurfaceGrab {
start_data,
window,
initial_location: initial_rect.loc,
initial_size: initial_rect.size,
finished: false,
snap_x: Default::default(),
snap_y: Default::default(),
},
serial,
Focus::Clear,
);
self.begin_window_interaction(&pointer, window, start_data, serial, None);
}
fn resize_request(
@@ -147,7 +175,7 @@ impl XdgShellHandler for Ferese {
serial: Serial,
edges: smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge,
) {
if self.session_lock.active {
if self.session_lock.active() {
return;
}
let Some(seat) = Seat::from_resource(&seat) else {
@@ -171,27 +199,19 @@ impl XdgShellHandler for Ferese {
else {
return;
};
let Some(rect) = self.visual_rect_for_window(&window) else {
return;
};
if !self.is_floating_window(&window) {
return;
if edges != xdg_toplevel::ResizeEdge::None {
self.begin_window_interaction(
&pointer,
window,
start_data,
serial,
Some(ResizeEdge::from(edges)),
);
}
pointer.set_grab(
self,
ResizeSurfaceGrab::new(start_data, window, ResizeEdge::from(edges), rect),
serial,
Focus::Clear,
);
}
fn maximize_request(&mut self, surface: ToplevelSurface) {
if let Some(id) = self.windows.ids().iter().find_map(|(window, id)| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
.then_some(*id)
}) {
if let Some(id) = self.windows.id_for_surface(surface.wl_surface()) {
self.set_window_maximized(id, true);
} else {
surface.with_pending_state(|state| state.states.set(ToplevelState::Maximized));
@@ -199,12 +219,7 @@ impl XdgShellHandler for Ferese {
}
fn unmaximize_request(&mut self, surface: ToplevelSurface) {
if let Some(id) = self.windows.ids().iter().find_map(|(window, id)| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
.then_some(*id)
}) {
if let Some(id) = self.windows.id_for_surface(surface.wl_surface()) {
self.set_window_maximized(id, false);
} else {
surface.with_pending_state(|state| state.states.unset(ToplevelState::Maximized));
@@ -216,12 +231,7 @@ impl XdgShellHandler for Ferese {
surface: ToplevelSurface,
_output: Option<smithay::reexports::wayland_server::protocol::wl_output::WlOutput>,
) {
if let Some(window) = self.windows.ids().iter().find_map(|(window, id)| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
.then_some(*id)
}) {
if let Some(window) = self.windows.id_for_surface(surface.wl_surface()) {
self.set_window_fullscreen(window, true);
} else {
surface.with_pending_state(|state| state.states.set(ToplevelState::Fullscreen));
@@ -229,12 +239,7 @@ impl XdgShellHandler for Ferese {
}
fn unfullscreen_request(&mut self, surface: ToplevelSurface) {
if let Some(window) = self.windows.ids().iter().find_map(|(window, id)| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
.then_some(*id)
}) {
if let Some(window) = self.windows.id_for_surface(surface.wl_surface()) {
self.set_window_fullscreen(window, false);
} else {
surface.with_pending_state(|state| state.states.unset(ToplevelState::Fullscreen));
@@ -242,7 +247,7 @@ impl XdgShellHandler for Ferese {
}
fn grab(&mut self, surface: PopupSurface, seat: wl_seat::WlSeat, serial: Serial) {
if self.session_lock.active || self.input_capture.active() {
if self.session_lock.active() || self.input_capture.active() {
return;
}
let Some(seat) = Seat::from_resource(&seat) else {
@@ -261,7 +266,12 @@ impl XdgShellHandler for Ferese {
};
if let Some(pointer) = seat.get_pointer() {
pointer.set_grab(self, PopupPointerGrab::new(&popup_grab), serial, Focus::Keep);
pointer.set_grab(
self,
PopupPointerGrab::new(&popup_grab),
serial,
Focus::Keep,
);
}
if let Some(keyboard) = seat.get_keyboard() {
keyboard.set_grab(self, PopupKeyboardGrab::new(&popup_grab), serial);
@@ -273,14 +283,21 @@ impl XdgShellHandler for Ferese {
}
}
fn check_grab(seat: &Seat<Ferese>, surface: &WlSurface, serial: Serial) -> Option<GrabStartData<Ferese>> {
fn check_grab(
seat: &Seat<Ferese>,
surface: &WlSurface,
serial: Serial,
) -> Option<GrabStartData<Ferese>> {
let pointer = seat.get_pointer()?;
if !pointer.has_grab(serial) {
return None;
}
let start_data = pointer.grab_start_data()?;
let (focus, _) = start_data.focus.as_ref()?;
focus.id().same_client_as(&surface.id()).then_some(start_data)
focus
.id()
.same_client_as(&surface.id())
.then_some(start_data)
}
impl XdgDecorationHandler for Ferese {
@@ -319,24 +336,36 @@ pub(crate) fn initial_configure_sent(toplevel: &ToplevelSurface) -> bool {
states
.data_map
.get::<XdgToplevelSurfaceData>()
.unwrap()
.expect("an XDG toplevel must retain its Smithay surface data")
.lock()
.unwrap()
.expect("Smithay surface state must remain consistent across callbacks")
.initial_configure_sent
})
}
pub fn handle_commit(popups: &mut PopupManager, space: &mut Space<Window>, surface: &WlSurface) {
handle_resize_commit(surface);
if let Some(window) = space
.elements()
.find(|window| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface)
})
.cloned()
{
pub(crate) fn validate_size_constraints(toplevel: &ToplevelSurface) -> bool {
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
use smithay::wayland::shell::xdg::SurfaceCachedState;
let valid = with_states(toplevel.wl_surface(), |states| {
let mut cached = states.cached_state.get::<SurfaceCachedState>();
let state = cached.current();
let valid_axis = |min: i32, max: i32| min >= 0 && max >= 0 && (max == 0 || min <= max);
valid_axis(state.min_size.w, state.max_size.w)
&& valid_axis(state.min_size.h, state.max_size.h)
});
if !valid {
toplevel.xdg_toplevel().post_error(
xdg_toplevel::Error::InvalidSize,
"invalid minimum/maximum window size",
);
}
valid
}
pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) {
if let Some(window) = state.window_for_surface(surface) {
let configured = with_states(surface, |states| {
states
.data_map
@@ -354,11 +383,13 @@ pub fn handle_commit(popups: &mut PopupManager, space: &mut Space<Window>, surfa
}
}
popups.commit(surface);
if let Some(PopupKind::Xdg(popup)) = popups.find_popup(surface)
state.popups.commit(surface);
if let Some(PopupKind::Xdg(popup)) = state.popups.find_popup(surface)
&& !popup.is_initial_configure_sent()
{
popup.send_configure().expect("initial popup configure is valid");
popup
.send_configure()
.expect("initial popup configure is valid");
}
}
@@ -391,14 +422,9 @@ pub fn apply_initial_window_rules(state: &mut Ferese, window: &Window) {
})
});
if initial {
let parent = toplevel.parent().and_then(|parent| {
state.windows.ids().iter().find_map(|(candidate, id)| {
candidate
.toplevel()
.is_some_and(|surface| surface.wl_surface() == &parent)
.then_some(*id)
})
});
let parent = toplevel
.parent()
.and_then(|parent| state.windows.id_for_surface(&parent));
state.add_rule_placed_window(window.clone(), app_id.as_deref(), title.as_deref(), parent);
}
state.apply_initial_window_rules(window, app_id.as_deref(), title.as_deref(), transient);
@@ -419,10 +445,11 @@ impl Ferese {
let Ok(root) = find_popup_root_surface(&PopupKind::Xdg(popup.clone())) else {
return;
};
let window = self
.space
.elements()
.find(|window| window.toplevel().is_some_and(|toplevel| toplevel.wl_surface() == &root));
let window = self.space.elements().find(|window| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == &root)
});
let geometry = (|| {
if let Some(window) = window {
let id = *self.windows.ids().get(window)?;
@@ -436,11 +463,14 @@ impl Ferese {
Some((output_geometry, location))
} else {
let layer = self.space.layer_for_surface(&root, WindowSurfaceType::TOPLEVEL)?;
let layer = self
.space
.layer_for_surface(&root, WindowSurfaceType::TOPLEVEL)?;
self.space.outputs().find_map(|output| {
let output_geometry = self.space.output_geometry(output)?;
let layer_geometry = smithay::desktop::layer_map_for_output(output).layer_geometry(&layer)?;
let layer_geometry =
smithay::desktop::layer_map_for_output(output).layer_geometry(&layer)?;
Some((output_geometry, output_geometry.loc + layer_geometry.loc))
})
@@ -470,6 +500,74 @@ fn popup_constraint_target(
output
}
impl Dispatch<xdg_toplevel::XdgToplevel, XdgShellSurfaceUserData> for Ferese {
fn request(
state: &mut Self,
client: &Client,
resource: &xdg_toplevel::XdgToplevel,
request: xdg_toplevel::Request,
data: &XdgShellSurfaceUserData,
dhandle: &DisplayHandle,
data_init: &mut DataInit<'_, Self>,
) {
let unusable = match &request {
xdg_toplevel::Request::SetMinSize { width, height } => {
!size_limits_usable(state, resource, (*width, *height), true)
}
xdg_toplevel::Request::SetMaxSize { width, height } => {
!size_limits_usable(state, resource, (*width, *height), false)
}
_ => false,
};
if unusable {
resource.post_error(xdg_toplevel::Error::InvalidSize, "size limits are unusable");
return;
}
smithay::wayland::Dispatch2::request(
data, state, client, resource, request, dhandle, data_init,
);
}
fn destroyed(
state: &mut Self,
client: ClientId,
resource: &xdg_toplevel::XdgToplevel,
data: &XdgShellSurfaceUserData,
) {
smithay::wayland::Dispatch2::destroyed(data, state, client, resource);
}
}
fn size_limits_usable(
state: &Ferese,
toplevel: &xdg_toplevel::XdgToplevel,
proposed: (i32, i32),
minimum: bool,
) -> bool {
if proposed.0 < 0 || proposed.1 < 0 {
return false;
}
let Some(surface) = state.xdg_shell_state.get_toplevel(toplevel) else {
return true;
};
let (min, max) = with_states(surface.wl_surface(), |states| {
let mut cached = states.cached_state.get::<SurfaceCachedState>();
let pending = cached.pending();
(
(pending.min_size.w, pending.min_size.h),
(pending.max_size.w, pending.max_size.h),
)
});
let (min, max) = if minimum {
(proposed, max)
} else {
(min, proposed)
};
(max.0 == 0 || min.0 <= max.0) && (max.1 == 0 || min.1 <= max.1)
}
#[cfg(test)]
mod tests {
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_positioner;
+293
View File
@@ -0,0 +1,293 @@
//! Automatic policy feeds the same idle notifier as Wayland and portal requests.
pub(crate) mod media;
use std::collections::HashSet;
use ferese_layout::WindowId;
use serde::Deserialize;
use smithay::reexports::wayland_server::Resource;
use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::XdgToplevelSurfaceData;
use crate::Ferese;
#[derive(
Clone, Copy, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct Settings {
pub fullscreen_playback: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
fullscreen_playback: true,
}
}
}
#[derive(
Clone, Copy, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(rename_all = "kebab-case")]
pub enum Mode {
None,
Visible,
Fullscreen,
Playing,
FullscreenPlaying,
}
impl Mode {
fn inhibits(self, visible: bool, fullscreen: bool, playing: bool) -> bool {
visible
&& match self {
Self::None => false,
Self::Visible => true,
Self::Fullscreen => fullscreen,
Self::Playing => playing,
Self::FullscreenPlaying => fullscreen && playing,
}
}
}
#[derive(Clone, Copy)]
struct AppWindow<'a> {
id: WindowId,
pid: Option<u32>,
app_id: &'a str,
}
/// MPRIS reports application playback, not which of its windows owns a video.
/// Prefer process credentials; use DesktopEntry only when no process matches.
/// Multiple matching windows are ambiguous, including hidden windows.
fn player_window(player: &media::Player, windows: &[AppWindow<'_>]) -> Option<WindowId> {
let mut matches = windows
.iter()
.filter(|window| player.pid.is_some() && window.pid == player.pid);
if let Some(window) = matches.next() {
return matches.next().is_none().then_some(window.id);
}
let desktop = player.desktop_entry.as_deref()?;
let mut matches = windows
.iter()
.filter(|window| crate::window_rules::normalize_app_id(window.app_id) == desktop);
let window = matches.next()?;
matches.next().is_none().then_some(window.id)
}
impl Ferese {
pub(crate) fn refresh_idle_inhibition(&mut self) {
let playing = self.media_players.iter().any(|player| player.playing);
let has_rules = self
.window_rules
.iter()
.any(|rule| rule.idle_policy().is_some());
let selected = self
.window_rules
.iter()
.any(|rule| rule.idle_policy().is_some_and(|mode| mode != Mode::None));
let playback_policy = self.idle_inhibit.fullscreen_playback
|| self.window_rules.iter().any(|rule| {
matches!(
rule.idle_policy(),
Some(Mode::Playing | Mode::FullscreenPlaying)
)
});
let automatic = (playback_policy && playing) || selected;
let eligible = if self.idle_inhibitors.is_empty() && !automatic {
Default::default()
} else {
self.callback_outputs()
};
let application = if playback_policy && playing {
self.windows
.records()
.map(|(&id, record)| {
let pid = record
.surface
.as_ref()
.and_then(|surface| surface.client())
.and_then(|client| client.get_credentials(&self.display_handle).ok())
.and_then(|credentials| u32::try_from(credentials.pid).ok());
AppWindow {
id,
pid,
app_id: &record.app_id,
}
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
let playing_windows = self
.media_players
.iter()
.filter(|player| player.playing)
.filter_map(|player| player_window(player, &application))
.collect::<HashSet<_>>();
let automatic = automatic
&& !self.session_lock.active()
&& self.windows.records().any(|(&id, record)| {
let Some(surface) = &record.surface else {
return false;
};
if !eligible.contains_key(&surface.into()) {
return false;
}
let explicit = has_rules
.then(|| {
let transient = with_states(surface, |states| {
states
.data_map
.get::<XdgToplevelSurfaceData>()
.is_some_and(|data| data.lock().expect("Smithay surface metadata must remain consistent across callbacks").parent.is_some())
});
crate::window_rules::resolve(
&self.window_rules,
Some(&record.app_id),
Some(&record.title),
transient,
)
.idle_inhibit
})
.flatten();
let mode = explicit.unwrap_or(if self.idle_inhibit.fullscreen_playback {
Mode::FullscreenPlaying
} else {
Mode::None
});
let fullscreen = self
.workspaces
.workspace_for_window(id)
.and_then(|workspace| self.workspaces.workspace(workspace))
.is_some_and(|workspace| workspace.fullscreen == Some(id));
mode.inhibits(true, fullscreen, playing_windows.contains(&id))
});
let requested = self
.idle_inhibitors
.keys()
.any(|surface| eligible.contains_key(&surface.into()));
self.automatic_idle_inhibited = automatic;
self.idle_notifier_state
.set_is_inhibited(automatic || requested || self.portal_session.idle_inhibited());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn policy_releases_on_pause_hidden_window_and_fullscreen_exit() {
assert!(Mode::FullscreenPlaying.inhibits(true, true, true));
assert!(!Mode::FullscreenPlaying.inhibits(true, true, false));
assert!(!Mode::FullscreenPlaying.inhibits(false, true, true));
assert!(!Mode::FullscreenPlaying.inhibits(true, false, true));
assert!(Mode::Visible.inhibits(true, false, false));
assert!(!Mode::Visible.inhibits(false, true, true));
assert!(Mode::Playing.inhibits(true, false, true));
assert!(!Mode::Playing.inhibits(true, false, false));
assert!(Mode::Fullscreen.inhibits(true, true, false));
assert!(!Mode::None.inhibits(true, true, true));
}
#[test]
fn configuration_validates_modes_and_keeps_idle_rules_out_of_placement() {
let config = crate::config::Config::parse_source(
"idle-inhibit { fullscreen-playback #false; }\nwindow-rule app-id=\"player\" idle-inhibit=\"visible\"\nwindow-rule app-id=\"player\" title=\"Private\" idle-inhibit=\"none\"\n",
).unwrap().runtime_config().unwrap();
assert!(!config.idle_inhibit.fullscreen_playback);
let visible =
crate::window_rules::resolve(&config.window_rules, Some("player"), None, false);
assert_eq!(visible.idle_inhibit, Some(Mode::Visible));
let none = crate::window_rules::resolve(
&config.window_rules,
Some("player"),
Some("Private"),
false,
);
assert_eq!(none.idle_inhibit, Some(Mode::None));
assert_eq!(crate::window_rules::live_result(visible, none, false), None);
for mode in ["playing", "fullscreen", "fullscreen-playing", "none"] {
assert!(
crate::config::Config::parse_source(&format!(
"window-rule app-id=\"player\" idle-inhibit=\"{mode}\"\n"
))
.unwrap()
.runtime_config()
.is_ok()
);
}
assert!(
crate::config::Config::parse_source(
"window-rule app-id=\"player\" idle-inhibit=\"alwayss\"\n"
)
.is_err()
);
assert!(
crate::config::Config::parse_source("idle-inhibit { fullscreen-playback \"yes\"; }\n")
.is_err()
);
}
#[test]
fn player_identity_prefers_pid_and_rejects_ambiguous_or_closed_windows() {
let player = media::Player {
name: "org.mpris.MediaPlayer2.test".into(),
owner: ":1.5".into(),
pid: Some(42),
desktop_entry: Some("player".into()),
playing: true,
..Default::default()
};
let window = AppWindow {
id: WindowId(1),
pid: Some(42),
app_id: "other",
};
let unrelated = AppWindow {
id: WindowId(2),
pid: Some(99),
app_id: "Player.desktop",
};
assert_eq!(
player_window(&player, &[window, unrelated]),
Some(window.id)
);
assert_eq!(player_window(&player, &[unrelated]), Some(unrelated.id));
assert_eq!(player_window(&player, &[]), None);
assert_eq!(
player_window(
&player,
&[
window,
AppWindow {
id: WindowId(3),
..window
}
]
),
None
);
assert_eq!(
player_window(
&player,
&[
unrelated,
AppWindow {
id: WindowId(4),
..unrelated
}
]
),
None
);
}
}
+153
View File
@@ -0,0 +1,153 @@
mod bus;
mod policy;
use std::collections::HashMap;
use std::sync::mpsc;
use std::time::Instant;
use calloop::EventLoop;
use calloop::channel::{Event, sync_channel};
use ferese_ipc::Response;
use ferese_ipc::media::Snapshot;
use serde_json::Value;
use crate::Ferese;
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct Player {
pub name: String,
pub owner: String,
pub pid: Option<u32>,
pub desktop_entry: Option<String>,
pub playing: bool,
pub view: ferese_ipc::media::Player,
pub(super) started: u64,
pub(super) active: u64,
}
pub(crate) struct Command {
args: Value,
sent: Instant,
reply: Option<(u64, mpsc::SyncSender<Response>)>,
}
struct Update {
players: Vec<Player>,
snapshot: Snapshot,
}
fn inhibition_changed(before: &[Player], after: &[Player]) -> bool {
before.len() != after.len()
|| before.iter().zip(after).any(|(a, b)| {
(&a.name, &a.owner, a.pid, &a.desktop_entry, a.playing)
!= (&b.name, &b.owner, b.pid, &b.desktop_entry, b.playing)
})
}
#[derive(Default)]
pub(crate) struct Engine {
snapshot: Snapshot,
value: Value,
waiters: HashMap<u64, (u64, mpsc::SyncSender<Response>)>,
locked: bool,
commands: Option<async_channel::Sender<Command>>,
}
impl Engine {
pub(crate) fn set_locked(&mut self, locked: bool) {
self.locked = locked;
if locked {
for (_, (id, response)) in self.waiters.drain() {
let _ =
response.try_send(Response::error(id, "session_locked", "Session is locked"));
}
}
}
pub(crate) fn value(&self) -> Value {
if self.value.is_null() {
serde_json::to_value(&self.snapshot).expect("serializable media snapshot")
} else {
self.value.clone()
}
}
fn update(&mut self, mut snapshot: Snapshot) {
snapshot.revision = self.snapshot.revision;
if snapshot == self.snapshot {
return;
}
snapshot.revision = snapshot.revision.wrapping_add(1);
self.value = serde_json::to_value(&snapshot).expect("serializable media snapshot");
self.snapshot = snapshot;
for (_, (id, response)) in self.waiters.drain() {
let _ = response.try_send(Response::success(id, self.value.clone()));
}
}
pub(crate) fn watch(
&mut self,
owner: u64,
id: u64,
since: u64,
response: mpsc::SyncSender<Response>,
) {
if self.locked {
let _ = response.try_send(Response::error(id, "session_locked", "Session is locked"));
} else if since != self.snapshot.revision {
let _ = response.try_send(Response::success(id, self.value()));
} else {
self.waiters.insert(owner, (id, response));
}
}
pub(crate) fn remove(&mut self, owner: u64) {
self.waiters.remove(&owner);
}
pub(crate) fn action(
&self,
args: Value,
reply: Option<(u64, mpsc::SyncSender<Response>)>,
) -> Result<(), String> {
let commands = self
.commands
.as_ref()
.ok_or("Media service is unavailable")?;
commands
.try_send(Command {
args,
sent: Instant::now(),
reply,
})
.map_err(|_| "Media command queue is unavailable or full".into())
}
}
pub(crate) fn init(event_loop: &mut EventLoop<Ferese>) -> Result<(), Box<dyn std::error::Error>> {
let (sender, receiver) = sync_channel::<Update>(1);
let (commands, receive_commands) = async_channel::bounded(16);
event_loop
.handle()
.insert_source(receiver, move |event, _, state| {
if let Event::Msg(update) = event {
let changed = inhibition_changed(&state.media_players, &update.players);
state.media_players = update.players;
state.media_engine.update(update.snapshot);
if changed {
state.refresh_idle_inhibition();
}
}
})?;
event_loop
.handle()
.insert_idle(move |state| state.media_engine.commands = Some(commands));
std::thread::Builder::new()
.name("ferese-playback".into())
.spawn(move || bus::run(sender, receive_commands))?;
Ok(())
}
#[cfg(test)]
mod tests;
+542
View File
@@ -0,0 +1,542 @@
use std::collections::HashMap;
use std::time::Duration;
use calloop::channel::SyncSender;
use ferese_ipc::Response;
use ferese_ipc::media::{Playback, now_us};
use futures_lite::{StreamExt, future, stream};
use zbus::zvariant::{Array, OwnedObjectPath, OwnedValue, Value};
use zbus::{Connection, MatchRule, MessageStream};
use super::policy::PlayersState;
use super::{Command, Player, Update};
const PREFIX: &str = "org.mpris.MediaPlayer2";
const PATH: &str = "/org/mpris/MediaPlayer2";
const PLAYER: &str = "org.mpris.MediaPlayer2.Player";
const PROPERTIES: &str = "org.freedesktop.DBus.Properties";
const MAX_PLAYERS: usize = 64;
type Properties = HashMap<String, OwnedValue>;
async fn bus_call<T: serde::de::DeserializeOwned + zbus::zvariant::Type>(
connection: &Connection,
member: &str,
name: &str,
) -> zbus::Result<T> {
connection
.call_method(
Some("org.freedesktop.DBus"),
"/org/freedesktop/DBus",
Some("org.freedesktop.DBus"),
member,
&(name,),
)
.await?
.body()
.deserialize()
}
async fn properties(
connection: &Connection,
owner: &str,
interface: &str,
) -> zbus::Result<Properties> {
connection
.call_method(Some(owner), PATH, Some(PROPERTIES), "GetAll", &(interface,))
.await?
.body()
.deserialize()
}
fn string<'a>(properties: &'a Properties, name: &str) -> Option<&'a str> {
properties
.get(name)
.and_then(|value| <&str>::try_from(value).ok())
}
pub(super) fn clean(value: &str, length: usize) -> String {
value
.chars()
.filter(|character| !character.is_control())
.take(length)
.collect::<String>()
.trim()
.to_owned()
}
fn boolean(properties: &Properties, name: &str) -> bool {
properties
.get(name)
.and_then(|value| bool::try_from(value).ok())
.unwrap_or(false)
}
fn integer(properties: &Properties, name: &str) -> Option<u64> {
properties
.get(name)
.and_then(|value| i64::try_from(value).ok())
.and_then(|value| value.try_into().ok())
}
fn number(properties: &Properties, name: &str) -> Option<f64> {
properties
.get(name)
.and_then(|value| f64::try_from(value).ok())
.filter(|value| value.is_finite())
}
async fn refresh(connection: &Connection, player: &mut Player, identity: bool) -> zbus::Result<()> {
let mut fields = properties(connection, &player.owner, PLAYER).await?;
let view = &mut player.view;
view.status = match string(&fields, "PlaybackStatus") {
Some("Playing") => Playback::Playing,
Some("Paused") => Playback::Paused,
_ => Playback::Stopped,
};
player.playing = view.status == Playback::Playing;
view.position_us = integer(&fields, "Position");
view.sampled_at_us = now_us();
view.rate = number(&fields, "Rate")
.filter(|rate| *rate != 0.0 && rate.abs() <= 100.0)
.unwrap_or(1.0);
view.volume = number(&fields, "Volume").filter(|volume| *volume >= 0.0);
view.can_control = boolean(&fields, "CanControl");
view.can_play = boolean(&fields, "CanPlay");
view.can_pause = boolean(&fields, "CanPause");
view.can_next = boolean(&fields, "CanGoNext");
view.can_previous = boolean(&fields, "CanGoPrevious");
view.can_seek = boolean(&fields, "CanSeek");
let metadata = fields
.remove("Metadata")
.and_then(|value| Properties::try_from(value).ok())
.unwrap_or_default();
view.title = clean(string(&metadata, "xesam:title").unwrap_or_default(), 256);
view.album = clean(string(&metadata, "xesam:album").unwrap_or_default(), 256);
view.artist = metadata
.get("xesam:artist")
.and_then(|value| <&Array>::try_from(value).ok())
.map(|array| {
array
.inner()
.iter()
.take(8)
.filter_map(|value| <&str>::try_from(value).ok())
.map(|artist| clean(artist, 64))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
view.length_us = integer(&metadata, "mpris:length").filter(|length| *length > 0);
view.track_id = metadata
.get("mpris:trackid")
.and_then(|value| <&zbus::zvariant::ObjectPath>::try_from(value).ok())
.map(|path| path.to_string());
view.art_url = string(&metadata, "mpris:artUrl")
.filter(|url| !url.is_empty() && url.len() <= 256 * 1024)
.map(str::to_owned);
if identity || player.pid.is_none() {
player.pid = bus_call(connection, "GetConnectionUnixProcessID", &player.owner)
.await
.ok();
if let Ok(root) = properties(connection, &player.owner, PREFIX).await {
view.identity = clean(string(&root, "Identity").unwrap_or(&player.name), 80);
view.can_raise = boolean(&root, "CanRaise");
player.desktop_entry = string(&root, "DesktopEntry")
.map(crate::window_rules::normalize_app_id)
.filter(|value| !value.is_empty());
}
}
Ok(())
}
async fn discover(connection: &Connection, name: String, owner: String) -> Player {
let mut player = Player {
view: ferese_ipc::media::Player {
name: name.clone(),
owner: owner.clone(),
identity: clean(&name, 80),
..Default::default()
},
name,
owner,
..Default::default()
};
let _ = refresh(connection, &mut player, true).await;
player
}
async fn action(
connection: &Connection,
state: &mut PlayersState,
args: &serde_json::Value,
) -> Result<(), String> {
let kind = args["action"].as_str().ok_or("Missing media action")?;
if kind == "auto" {
state.pinned = None;
return Ok(());
}
let player = match args["player"].as_str() {
Some(name) => state.players.get(name),
None => state.selected(),
}
.cloned()
.ok_or("No active media player")?;
if args["owner"]
.as_str()
.is_some_and(|owner| owner != player.owner)
{
return Err("Media player was replaced".into());
}
match kind {
"pin" => {
state.ignored.remove(&player.name);
state.pinned = Some(player.name);
return Ok(());
}
"ignore" => {
let ignored = args["ignored"].as_bool().ok_or("Missing ignore state")?;
if ignored {
state.ignored.insert(player.name.clone());
} else {
state.ignored.remove(&player.name);
}
if ignored && state.pinned.as_deref() == Some(&player.name) {
state.pinned = None;
}
return Ok(());
}
_ => (),
}
let view = &player.view;
let supported = match kind {
"play-pause" => view.can_toggle(),
"next" => view.can_control && view.can_next,
"previous" => view.can_control && view.can_previous,
"raise" => view.can_raise,
"seek" => view.can_control && view.can_seek && view.track_id.is_some(),
"volume" => view.can_control && view.volume.is_some(),
_ => false,
};
if !supported {
return Err("Media action is unavailable for this player".into());
}
let method = match kind {
"play-pause" => "PlayPause",
"next" => "Next",
"previous" => "Previous",
"raise" => "Raise",
_ => "",
};
let response = match kind {
"seek" => {
if args["track_id"].as_str() != view.track_id.as_deref() {
return Err("Media track changed".into());
}
let position = args["position_us"]
.as_u64()
.ok_or("Invalid media position")?;
let position = view
.length_us
.map_or(position, |length| position.min(length))
.min(i64::MAX as u64) as i64;
let track = OwnedObjectPath::try_from(
view.track_id
.clone()
.expect("media action validation requires an active track"),
)
.map_err(|error| error.to_string())?;
connection
.call_method(
Some(player.owner.as_str()),
PATH,
Some(PLAYER),
"SetPosition",
&(track, position),
)
.await
}
"volume" => {
let delta = args["delta"]
.as_f64()
.filter(|delta| delta.is_finite() && delta.abs() <= 1.0)
.ok_or("Invalid volume change")?;
let volume = (view
.volume
.expect("relative volume validation requires the current volume")
+ delta)
.clamp(0.0, 1.0);
connection
.call_method(
Some(player.owner.as_str()),
PATH,
Some(PROPERTIES),
"Set",
&(PLAYER, "Volume", Value::F64(volume)),
)
.await
}
_ => {
connection
.call_method(
Some(player.owner.as_str()),
PATH,
Some(if kind == "raise" { PREFIX } else { PLAYER }),
method,
&(),
)
.await
}
};
response.map_err(|error| error.to_string())?;
let mut player = player;
if refresh(connection, &mut player, false).await.is_ok() {
state.update(player);
}
Ok(())
}
fn publish(
state: &PlayersState,
previous: &mut Option<Update>,
sender: &SyncSender<Update>,
) -> bool {
let update = state.update_snapshot();
if previous
.as_ref()
.is_some_and(|old| old.players == update.players && old.snapshot == update.snapshot)
{
return true;
}
*previous = Some(Update {
players: update.players.clone(),
snapshot: update.snapshot.clone(),
});
sender.send(update).is_ok()
}
async fn monitor(
connection: Connection,
commands: &async_channel::Receiver<Command>,
sender: &SyncSender<Update>,
) -> zbus::Result<()> {
let properties = MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.path(PATH)?
.interface(PROPERTIES)?
.member("PropertiesChanged")?
.build();
let owners = MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender("org.freedesktop.DBus")?
.interface("org.freedesktop.DBus")?
.member("NameOwnerChanged")?
.arg0ns(PREFIX)?
.build();
let seeks = MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.path(PATH)?
.interface(PLAYER)?
.member("Seeked")?
.build();
let properties = MessageStream::for_match_rule(properties, &connection, Some(128)).await?;
let owners = MessageStream::for_match_rule(owners, &connection, Some(128)).await?;
let seeks = MessageStream::for_match_rule(seeks, &connection, Some(64)).await?;
let mut signals = stream::or(stream::or(properties, owners), seeks);
let mut state = PlayersState::default();
let names: Vec<String> = connection
.call_method(
Some("org.freedesktop.DBus"),
"/org/freedesktop/DBus",
Some("org.freedesktop.DBus"),
"ListNames",
&(),
)
.await?
.body()
.deserialize()?;
let mut names: Vec<_> = names
.into_iter()
.filter(|name| {
name.strip_prefix(PREFIX)
.is_some_and(|suffix| suffix.starts_with('.'))
})
.collect();
names.sort_unstable();
for name in names.into_iter().take(MAX_PLAYERS) {
if let Ok(owner) = bus_call(&connection, "GetNameOwner", &name).await {
let player = discover(&connection, name.clone(), owner).await;
// Existing players have no known start order; bus names break ties.
state.players.insert(name, player);
}
}
let mut previous = None;
if !publish(&state, &mut previous, sender) {
return Ok(());
}
enum Input {
Signal(Option<zbus::Result<zbus::Message>>),
Action(Option<Command>),
}
loop {
let input = match commands.try_recv() {
Ok(command) => Input::Action(Some(command)),
Err(_) => {
future::or(async { Input::Signal(signals.next().await) }, async {
Input::Action(commands.recv().await.ok())
})
.await
}
};
match input {
Input::Action(None) => return Ok(()),
Input::Signal(None) => {
return Err(zbus::Error::Failure("MPRIS signal stream ended".into()));
}
Input::Action(Some(command)) => {
let result = if command.sent.elapsed() > Duration::from_secs(2) {
Err("Media command expired".into())
} else {
action(&connection, &mut state, &command.args).await
};
if let Some((id, response)) = command.reply {
let reply = match result {
Ok(()) => Response::success(id, serde_json::json!({})),
Err(error) => Response::error(id, "media_unavailable", error),
};
let _ = response.try_send(reply);
}
}
Input::Signal(Some(message)) => {
let message = message?;
let header = message.header();
if header
.member()
.is_some_and(|member| member.as_str() == "NameOwnerChanged")
{
let (name, _, owner): (String, String, String) =
message.body().deserialize()?;
if !name
.strip_prefix(PREFIX)
.is_some_and(|suffix| suffix.starts_with('.'))
{
continue;
}
state.remove(&name);
if !publish(&state, &mut previous, sender) {
return Ok(());
}
if !owner.is_empty() && state.players.len() < MAX_PLAYERS {
let player = discover(&connection, name, owner).await;
state.update(player);
}
} else if let Some(owner) = header.sender() {
if header
.member()
.is_some_and(|member| member.as_str() == "Seeked")
{
let names: Vec<_> = state
.players
.values()
.filter(|player| player.owner == owner.as_str())
.map(|player| player.name.clone())
.collect();
for name in names {
let mut player = state.players[&name].clone();
if refresh(&connection, &mut player, false).await.is_ok() {
state.update(player);
}
}
} else {
let Ok((interface, changed, invalidated)) =
message
.body()
.deserialize::<(String, Properties, Vec<String>)>()
else {
continue;
};
let identity = interface == PREFIX
&& ["Identity", "DesktopEntry", "CanRaise"].iter().any(|key| {
changed.contains_key(*key)
|| invalidated.iter().any(|field| field == key)
});
let playback = interface == PLAYER
&& [
"PlaybackStatus",
"Metadata",
"Rate",
"Volume",
"CanGoNext",
"CanGoPrevious",
"CanPlay",
"CanPause",
"CanSeek",
"CanControl",
]
.iter()
.any(|key| {
changed.contains_key(*key)
|| invalidated.iter().any(|field| field == key)
});
if !identity && !playback {
continue;
}
let names: Vec<_> = state
.players
.values()
.filter(|player| player.owner == owner.as_str())
.map(|player| player.name.clone())
.collect();
for name in names {
let mut player = state.players[&name].clone();
// Re-read after a signal so queued startup messages cannot regress state.
if refresh(&connection, &mut player, identity).await.is_ok() {
state.update(player);
} else {
player.playing = false;
player.view.status = Playback::Stopped;
state.update(player);
}
}
}
}
}
}
if !publish(&state, &mut previous, sender) {
return Ok(());
}
}
}
pub(super) fn run(sender: SyncSender<Update>, commands: async_channel::Receiver<Command>) {
loop {
let result = future::block_on(async {
let connection = zbus::connection::Builder::session()?
.method_timeout(Duration::from_millis(500))
.build()
.await?;
monitor(connection, &commands, &sender).await
});
if matches!(result, Ok(())) || commands.is_closed() {
break;
}
if sender
.send(PlayersState::default().update_snapshot())
.is_err()
{
break;
}
tracing::debug!(
?result,
"MPRIS disconnected; clearing playback and retrying"
);
std::thread::sleep(Duration::from_secs(5));
}
}
@@ -0,0 +1,90 @@
use std::collections::{BTreeMap, HashSet};
use ferese_ipc::media::{Choice, Playback, Snapshot};
use super::{Player, Update};
#[derive(Default)]
pub(super) struct PlayersState {
pub players: BTreeMap<String, Player>,
pub pinned: Option<String>,
pub ignored: HashSet<String>,
activity: u64,
}
impl PlayersState {
pub fn update(&mut self, mut player: Player) {
if let Some(old) = self.players.get(&player.name) {
player.started = old.started;
player.active = old.active;
if player.view.status != old.view.status
|| player.view.track_id != old.view.track_id
|| player.view.title != old.view.title
|| player.view.artist != old.view.artist
{
self.activity += 1;
player.active = self.activity;
if player.playing && !old.playing {
player.started = self.activity;
}
}
} else {
self.activity += 1;
player.active = self.activity;
if player.playing {
player.started = self.activity;
}
}
self.players.insert(player.name.clone(), player);
}
pub fn remove(&mut self, name: &str) {
self.players.remove(name);
self.ignored.remove(name);
if self.pinned.as_deref() == Some(name) {
self.pinned = None;
}
}
pub fn selected(&self) -> Option<&Player> {
let eligible = |player: &&Player| {
player.view.status != Playback::Stopped && !self.ignored.contains(&player.name)
};
if let Some(player) = self
.pinned
.as_ref()
.and_then(|name| self.players.get(name))
.filter(eligible)
{
return Some(player);
}
self.players.values().filter(eligible).max_by(|a, b| {
(a.playing, if a.playing { a.started } else { a.active })
.cmp(&(b.playing, if b.playing { b.started } else { b.active }))
.then_with(|| b.name.cmp(&a.name))
})
}
pub fn update_snapshot(&self) -> Update {
Update {
players: self.players.values().cloned().collect(),
snapshot: Snapshot {
revision: 0,
selected: self.selected().map(|player| player.view.clone()),
pinned: self.pinned.clone(),
players: self
.players
.values()
.map(|player| Choice {
name: player.name.clone(),
owner: player.owner.clone(),
identity: player.view.identity.clone(),
status: player.view.status,
ignored: self.ignored.contains(&player.name),
})
.collect(),
},
}
}
}
@@ -0,0 +1,146 @@
use super::*;
use ferese_ipc::media::Playback;
use policy::PlayersState;
fn player(name: &str, status: Playback) -> Player {
Player {
name: name.into(),
owner: format!(":{name}"),
playing: status == Playback::Playing,
view: ferese_ipc::media::Player {
name: name.into(),
status,
..Default::default()
},
..Default::default()
}
}
#[test]
fn selection_tracks_starts_not_metadata_or_volume_changes() {
let mut state = PlayersState::default();
state.update(player("first", Playback::Playing));
state.update(player("second", Playback::Playing));
let mut first = state.players["first"].clone();
first.view.title = "New track".into();
first.view.volume = Some(0.5);
state.update(first);
assert_eq!(state.selected().unwrap().name, "second");
state.update(player("second", Playback::Paused));
assert_eq!(state.selected().unwrap().name, "first");
state.update(player("first", Playback::Paused));
assert_eq!(state.selected().unwrap().name, "first");
state.update(player("second", Playback::Playing));
assert_eq!(state.selected().unwrap().name, "second");
}
#[test]
fn pin_ignore_and_departure_do_not_leave_a_ghost_player() {
let mut state = PlayersState::default();
state.update(player("first", Playback::Paused));
state.update(player("second", Playback::Playing));
state.pinned = Some("first".into());
assert_eq!(state.selected().unwrap().name, "first");
state.ignored.insert("first".into());
assert_eq!(state.selected().unwrap().name, "second");
state.remove("first");
assert!(state.pinned.is_none());
assert!(state.ignored.is_empty());
state.remove("second");
assert!(state.selected().is_none());
}
#[test]
fn initial_ties_are_deterministic_and_stopped_players_are_hidden() {
let mut state = PlayersState::default();
for name in ["z", "a"] {
state
.players
.insert(name.into(), player(name, Playback::Playing));
}
assert_eq!(state.selected().unwrap().name, "a");
state.update(player("a", Playback::Stopped));
state.update(player("z", Playback::Stopped));
assert!(state.selected().is_none());
}
#[test]
fn watch_waits_for_changes_deduplicates_and_drops_disconnected_clients() {
let mut engine = Engine::default();
let (send, receive) = mpsc::sync_channel(1);
engine.watch(1, 42, 0, send);
engine.update(Snapshot::default());
assert!(receive.try_recv().is_err());
let mut state = PlayersState::default();
state.update(player("first", Playback::Playing));
engine.update(state.update_snapshot().snapshot);
let response = receive.try_recv().unwrap();
assert_eq!(response.id, 42);
assert_eq!(response.result.unwrap()["revision"], 1);
let (send, receive) = mpsc::sync_channel(1);
engine.watch(2, 43, 1, send);
engine.remove(2);
state.remove("first");
engine.update(state.update_snapshot().snapshot);
assert!(receive.try_recv().is_err());
}
#[test]
fn metadata_is_bounded_and_control_characters_are_removed() {
assert_eq!(bus::clean(" Track\nname\0 ", 80), "Trackname");
assert_eq!(bus::clean(&"é".repeat(500), 80).chars().count(), 80);
}
#[test]
fn metadata_and_position_updates_do_not_recompute_idle_policy() {
let before = player("first", Playback::Playing);
let mut after = before.clone();
after.view.title = "New track".into();
after.view.position_us = Some(10);
after.view.volume = Some(0.8);
assert!(!inhibition_changed(
std::slice::from_ref(&before),
std::slice::from_ref(&after)
));
after.playing = false;
assert!(inhibition_changed(
std::slice::from_ref(&before),
std::slice::from_ref(&after)
));
after.playing = true;
after.pid = Some(42);
assert!(inhibition_changed(
std::slice::from_ref(&before),
std::slice::from_ref(&after)
));
assert!(inhibition_changed(std::slice::from_ref(&before), &[]));
}
#[test]
fn session_lock_cancels_pending_and_rejects_new_media_watches() {
let mut engine = Engine::default();
let (send, receive) = mpsc::sync_channel(1);
engine.watch(1, 42, 0, send);
engine.set_locked(true);
let reply = receive.recv().unwrap();
assert_eq!(reply.id, 42);
assert!(reply.result.is_none());
assert_eq!(reply.error.unwrap().code, "session_locked");
engine.update(Snapshot {
selected: Some(player("private", Playback::Playing).view),
..Default::default()
});
for since in [0, engine.snapshot.revision] {
let (send, receive) = mpsc::sync_channel(1);
engine.watch(2, 43, since, send);
assert_eq!(
receive.recv().unwrap().error.unwrap().code,
"session_locked"
);
}
engine.set_locked(false);
let (send, receive) = mpsc::sync_channel(1);
engine.watch(3, 44, 0, send);
assert!(receive.recv().unwrap().result.is_some());
}
+794 -202
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 1031 files, more files were not shown because too many files have changed in this diff. Show more