1
0
Fork 0
forked from wry/wry

Compare commits

...
Sign in to create a new pull request.

41 commits

Author SHA1 Message Date
158682757a Skip tiny swap redistribution phases 2026-05-28 17:13:24 +10:00
09305ab026 Bridge interrupted phased retargets 2026-05-28 10:57:36 +10:00
e7f9a5cb09 Add animation style toggle 2026-05-27 22:52:06 +10:00
02222d5189 Coalesce layout animation candidates 2026-05-27 22:24:01 +10:00
502a93a00a Use live content for normal animations 2026-05-27 22:08:09 +10:00
6c133018aa Handle uneven swap lanes and clearance grouping 2026-05-27 13:36:46 +10:00
313323888b Preserve rounded clipping for window body backgrounds 2026-05-27 13:26:15 +10:00
0f6f9f2602 Fix animation retarget and reflow regressions 2026-05-24 14:55:24 +10:00
dfcb2d0fd6 Use configured curve for spawn animations 2026-05-24 14:40:22 +10:00
b211b53528 Handle phased animation retargeting 2026-05-22 16:35:44 +10:00
1a75f47709 Refine animation planner test fixes 2026-05-22 16:26:03 +10:00
d2138b45f6 Constrain mono boundary animations 2026-05-22 09:22:07 +10:00
0fefe814c3 Repair animation integration paths 2026-05-22 09:16:51 +10:00
31c289f628 Document multiphase animation test activation 2026-05-21 23:05:31 +10:00
b1717e2dd8 Add animation manual test plan 2026-05-21 21:45:07 +10:00
a5845fb293 Assert mixed multiphase action boundaries 2026-05-21 21:34:53 +10:00
632873ec5a Allow proven mixed multiphase actions 2026-05-21 21:26:14 +10:00
01d1545c40 Assert multiphase rejection diagnostics 2026-05-21 21:16:54 +10:00
332a7468f6 Enumerate deterministic split-tree plans 2026-05-21 21:13:48 +10:00
511e188d16 Explain multiphase planning decisions 2026-05-21 21:10:27 +10:00
0b6da9d8e0 Order nested scale phases by hierarchy 2026-05-21 20:38:12 +10:00
a770089b88 Exercise planner with generated split trees 2026-05-21 20:21:07 +10:00
cc898590d2 Report multiphase planning diagnostics 2026-05-21 19:59:19 +10:00
90c00bcdf3 Carry hierarchy metadata into multiphase planning 2026-05-21 19:55:16 +10:00
a712786ecf Choose swap lanes by motion direction 2026-05-21 19:48:13 +10:00
b109cdf6f2 Validate multiphase overlap analytically 2026-05-21 19:43:39 +10:00
4ee2c324e1 Fallback layout animations by motion group 2026-05-21 18:46:10 +10:00
a516b2e721 Mirror stack extraction multiphase planning 2026-05-21 18:42:45 +10:00
13722429b4 Run planned multiphase layout animations 2026-05-21 18:37:00 +10:00
b50e8d5683 Batch layout animation candidates 2026-05-21 18:27:01 +10:00
41d2fef177 Add multiphase no-overlap planner groundwork 2026-05-21 18:23:33 +10:00
2115518edf Document animation TOML settings 2026-05-21 17:24:51 +10:00
501c508839 Test retained spawn-out animation frames 2026-05-21 17:22:04 +10:00
cf61c080b6 Add custom animation curve config 2026-05-21 17:19:46 +10:00
fa5c28ca3d Add retained spawn-out animations 2026-05-21 17:09:06 +10:00
d0cc5dc3c7 Animate command-driven floating changes 2026-05-21 16:51:50 +10:00
aeaea3419f Add float tile transition animations 2026-05-21 16:18:44 +10:00
18ffaef64d Add spawn-in window animations 2026-05-21 16:06:33 +10:00
7575f851fe Document deferred retained scaling polish 2026-05-21 15:53:19 +10:00
fba9d65ba1 Retain surface textures for animations 2026-05-21 15:45:32 +10:00
3540cdc4be Add linear tiled window animations 2026-05-21 15:20:46 +10:00
31 changed files with 7763 additions and 114 deletions

View file

@ -0,0 +1,313 @@
# Window Animations Plan
This document is the working plan for Wry's window animation system. It records
the decisions already made, the implementation phases, and the risks that must
be handled deliberately.
## Accepted Decisions
- The first landed slice is plain interpolation only, disabled by default.
- Animation is presentation-only. Logical layout, input hit testing, focus, and
Wayland configure state use final geometry immediately.
- Pointer drag and resize initiated by the mouse or tablet do not animate.
- Plain animations restart only for windows whose destination changes. Other
in-flight windows keep their existing timelines.
- Spawn-in uses scale and position for newly mapped tiled and floating app
windows. Layer-shell, overlay, override-redirect, and fullscreen surfaces do
not use this path. Spawn-out uses retained visual content after the live node
is gone, when a stable retained surface tree can be captured before unmap or
destroy.
- Command-driven tile-to-float and float-to-tile transitions may animate.
Protocol drag/drop paths do not.
- The no-overlap multiphase system is a separate phase after the plain path is
working and testable.
- Content freezing will use retained per-surface texture references, not a full
offscreen snapshot as the default design.
- Retained records should keep using the existing renderer behavior for now,
including clipping and edge stretch/clamp behavior for undersized contents. A
dedicated retained-tree scaling path is deferred to a later polish phase.
- The multiphase animation concept is original to Wry. Hy3 is relevant only as
partial inspiration for tiling style and titlebar/grouping behavior.
- Mono mode should mostly avoid animations. Exceptions are windows entering or
exiting mono mode, where a visual transition can clarify the hierarchy change.
- Multiphase shrink steps should not normally need to reduce a tiled window far
below roughly one quarter of the relevant full size. The implementation may
enforce a conservative sanity minimum, and pathological cases may fall back.
- If the no-overlap planner cannot produce a legal sequence, only the affected
group should fall back to plain animation. This is expected to be rare for
valid tiling layouts.
- When entering mono mode, the active child should animate to the mono geometry.
Inactive siblings may snap invisible. Floats may overlap normally and do not
need the no-overlap planner.
## Texture Freezing Decision
The approved freezing design is to capture a renderable surface tree at animation
start:
- texture references
- source sample rects
- target sizes
- alpha and color metadata
- subsurface offsets and stacking order
- enough synchronization/release state to keep referenced buffers alive safely
This is lighter than rendering every toplevel into a compositor-owned offscreen
texture, and it should handle normal GPU-backed windows without an extra full
window copy. It also gives spawn-out a path: capture the surface tree before the
toplevel is logically destroyed, then animate the retained records after the live
node is gone.
Tradeoffs:
- Retained references can delay buffer release. For dmabuf clients this can
increase memory pressure or throttle clients if many large windows animate.
- SHM buffers still matter for simple clients, fallback paths, some utilities,
and cursor-like surfaces. They are probably not the common case for large app
windows, but the implementation must still treat SHM texture flipping as a
correctness issue.
- The release/sync contract must be explicit. A retained texture must not be
released back to the client while the compositor may still render it.
- True offscreen snapshots remain a possible fallback for cases where retained
references cannot safely preserve the rendered content.
## Phase 1: Linear Presentation Animations
Goal: add the smallest correct animation layer without changing layout semantics.
Implementation shape:
- Add animation state owned by `State`.
- Track per-toplevel animation entries keyed by `NodeId`.
- Store logical target rect, current presentation rect, previous damaged rect,
start time, duration, and curve.
- On command-driven tiled layout geometry changes, animate from current
presentation rect to new final rect.
- On interruption, restart only the affected window from its current
presentation rect.
- Drive frames from the existing output latch/presentation event flow.
- Damage the union of previous presentation rect, current presentation rect, and
final logical rect.
Initial scope:
- Tiled reflow animation.
- Floating command-driven moves and resizes are animated. Pointer and tablet
drag/resize paths still snap directly to the live cursor position.
- Cross-output and cross-scale movements snap for now.
- Linear mode may overlap windows during swaps. That is expected for the classic
interpolation mode; no-overlap is Phase 3.
- Live client buffers are rendered in Phase 1. Retained content freezing is
deferred, but animated windows must still be clipped to their presentation
bounds and must preserve the existing stretch behavior for undersized contents.
- Spawn-out is retained-content-only. If the surface cannot be retained safely
the window snaps out instead of animating an empty frame.
- No multiphase no-overlap planner.
Tests:
- rect interpolation is direction-independent
- interruption restarts only changed windows
- unchanged in-flight windows keep their original timeline
- drag-driven floating movement bypasses animation
- damage includes old, current, and final rects
- command-driven tile-to-float and float-to-tile transitions use linear motion
- command-driven floating moves and resizes animate without affecting pointer
drag/resize behavior
- pointer/header double-click unfloat bypasses the command-animation gate
## Phase 2: Retained Texture Freezing
Goal: freeze visual contents during movement and enable spawn-out.
Initial retained-record implementation status:
- Tiled animation can retain GPU/dmabuf-backed XDG and Xwayland surface trees.
- Spawn-in animation can retain GPU/dmabuf-backed XDG and Xwayland surface trees
for both tiled windows and floating child contents.
- Tile-to-float and float-to-tile transitions retain GPU/dmabuf-backed child
contents while the presentation geometry changes.
- Spawn-out captures retained app-window contents before XDG/Xwayland unmap or
destroy, then renders a detached shrinking presentation record until the
animation completes.
- Retained records hold both `GfxTexture` and `SurfaceBuffer` references so the
existing buffer release/sync path remains authoritative.
- Single-pixel buffers can be retained as color records.
- Retained records render through the same texture and stretch/clamp paths used
by live surfaces. This is the expected Phase 2 behavior.
- Async SHM textures are not retained yet because Wry's per-surface SHM
front/back textures can be reused by later commits while an animation is still
running. Those surfaces fall back to live rendering until an explicit offscreen
copy fallback exists.
Implementation shape:
- Add a retained render-record tree for toplevel surfaces.
- Capture records before movement animations that require freezing.
- Capture records before destroy/unmap for spawn-out.
- Render retained records through the normal renderer primitives where possible.
- Extend event/sync handling so retained buffers remain valid until the animation
is complete.
Deferred/future polish:
- Whether retained records participate in frame callbacks or presentation
feedback. Default assumption: no, because they are compositor animation frames,
not client commits.
- How to fall back when a buffer cannot be safely retained.
- A distinct retained-tree scaling render path for true spawn-in/spawn-out
content scaling. If added, start with retained GPU-backed records only, keep
the animated frame as the clip boundary, and avoid live SHM scaling until there
is an explicit snapshot/copy fallback.
## Phase 3: Multiphase No-Overlap Animations
Goal: implement Wry's staged no-overlap planner while preserving the rule that
windows never overlap.
Manual verification checklist: `docs/window-animations-testing.md`.
Core rules:
- Each phase is a discrete animation using the full curve.
- A phase performs only one action kind per window: move or scale.
- Movement and scaling are split by axis.
- No diagonal motion. Mixed-action phases may combine different per-window
actions, but no single window may move or resize on more than one axis in one
step.
- A window or synchronized group owns its own timeline.
- New layout changes interrupt only windows/groups with changed destinations.
- Current hierarchy and target hierarchy both matter. The planner must know
whether a window is ascending toward a higher-level/toplevel position,
descending into a container, or moving between containers at the same depth.
- If some child windows require fewer phases than their parent/container
context, parent/container-space changes generally happen first so space exists
before the child moves into it. This rule can be overridden only when the
non-overlap invariant still clearly holds.
- Windows that become peers in the target hierarchy may synchronize later
phases even if they were not peers in the source hierarchy.
Important parent/child synchronization issue:
The planner must not let a parent container and child window animate independent
axes at the same time in a way that violates the visual rules. For example, a
parent scaling horizontally while a child scales vertically can accidentally
produce a diagonal or multi-axis motion in screen space.
Preferred approach:
- Plan in terms of leaf toplevel visual rectangles first.
- Treat containers as constraints and grouping boundaries, not as independently
animated visual actors.
- Derive every leaf's per-phase rect from one phase schedule so parent and child
effects cannot compose into forbidden motion.
- Build the planner as pure geometry first. Live integration should collect
eligible leaf `(old, new)` rects across a command-driven layout pass, then
submit planner-produced phases as a batch. Per-node `tl_change_extents` calls
are too incremental to plan safely by themselves.
- Add container-level grouping only after the leaf planner proves correct.
- Include hierarchy-transition metadata in the planner input: source parent,
target parent, source depth, target depth, and whether the window is ascending,
descending, or staying at the same hierarchy level.
- For mono containers, suppress ordinary in-mono focus/tab changes. Animate only
transitions into mono, out of mono, or across the mono boundary.
- When entering mono, the active child animates to the full mono area and
inactive siblings snap invisible. When exiting mono, ordinary tiled geometry
may animate from the mono child where that produces a clear hierarchy
transition.
- If a legal no-overlap sequence cannot be found for a group, fall back to the
linear animator for that group only. Float windows are outside this invariant.
Current pure planner status:
- Two-window same-axis swaps use shrink lanes, move, then grow.
- Swap lane choice follows motion direction, not node identity: right/down
moving windows take the first lane, and left/up moving windows take the second
lane.
- Stack extraction/return patterns are covered in both horizontal and vertical
orientations: peer/container space scales first, the extracted child moves
only after space exists, and orthogonal growth happens in the final phase.
- Same-axis size redistribution is handled as a single scale phase when the
exact validator proves adjacent windows stay non-overlapping.
- Nested size redistribution can use hierarchy metadata to decompose two-axis
resizing into parent-axis then child-axis scale phases, but only when the
source/target ancestor split depths give a deterministic order.
- A phase can carry mixed per-window actions when each window still performs one
classified move/scale on one axis and the exact validator proves the combined
phase is non-overlapping.
- Every produced plan is checked analytically for overlap over the full duration
of each phase before it is accepted. This solves the linear edge inequalities
for each pair of moving rectangles instead of relying on sampled frames.
- Live layout batches are partitioned by overlapping motion bounds, so unrelated
groups can still use multiphase animation when another group falls back to
linear motion.
- Planner requests now carry per-window hierarchy metadata for source/target
parent, depth, sibling index, split axis, nearest ancestor split depth per
axis, mono state, and transition kind. The current planner uses this for
parent-before-child scale ordering, but not yet for full nested move planning.
- Multiphase planning has a diagnostic entry point used by live fallback logs.
It distinguishes request validation errors, missing patterns, shrink-bound
rejections, invalid phase steps, and exact validation failures such as stale
starts or phase overlap.
- Multiphase planning also has an explained-plan entry point. Accepted plans
report the deterministic strategy, phase reasons, participating nodes, and
validation result; rejected plans report every attempted strategy and failure.
- Rejection diagnostics are treated as contractual test output for unsupported
patterns and analytically invalid candidate plans, including attempted strategy
order and exact validation failure.
- Planner tests now include a deterministic split-tree generator. It builds
valid weighted tiling layouts, derives leaf hierarchy metadata, mutates them
through supported transitions, and runs the real planner plus exact validator.
- The generated tests also include a bounded corpus of supported split-tree
transitions across both axes and directions. Each case is planned twice and
compared exactly to catch nondeterministic planner output.
Tests:
- horizontal swaps shrink, move, then grow without overlap
- extraction from a stack creates space before moving the extracted window
- nested size redistribution scales the parent axis before the child axis
- nested containers do not produce simultaneous cross-axis motion
- interruption restarts only affected phase groups
- reversing direction produces equivalent motion in reverse
- accepted and rejected plans expose deterministic strategy explanations
- bounded generated split-tree corpus produces identical plans on repeated runs
- unsupported and invalid candidate plans produce exact expected diagnostics
- mixed-action phases are accepted only under exact continuous validation
- diagonal per-window motion remains a hard rejection even inside mixed phases
- child waits for parent/container-space phases when moving upward into a
toplevel peer position
- mono-mode tab switches do not animate, while entering/exiting mono can animate
## Configuration
Phase 1 should expose a disabled-by-default setting for:
- enabled/disabled
- duration
- style: `plain` or `multiphase`
- curve preset or cubic bezier
Initial TOML shape:
```toml
[animations]
enabled = false
duration-ms = 160
style = "multiphase"
curve = "ease-out"
# or:
curve = [0.25, 0.1, 0.25, 1.0]
```
Bezier curves are analyzed when configuration is applied and stored as a
piecewise curve that is cheap to evaluate during rendering. Custom curves use
CSS cubic-bezier semantics: `(0, 0)` and `(1, 1)` are implicit, while the four
configured numbers are `x1`, `y1`, `x2`, and `y2`. The x control points must be
between `0` and `1`.
## Existing Note
`docs/animation-integration.md` appears to document a prior animation attempt
whose `src/animation/` implementation is not present in this checkout. Treat
this plan as the current source of truth until implementation docs are updated.

View file

@ -0,0 +1,493 @@
# Window Animations Manual Test Plan
This is the manual verification checklist for Wry's animation work. Use it after
building a test compositor and booting into a normal graphical session.
The goal is to catch visual, synchronization, damage, and retained-content
problems that unit tests cannot prove from geometry alone.
## Setup
- Build and install the `codex-anims-next` branch.
- Start with animations enabled and a deliberately slow duration, around
`400-700ms`, so phase ordering and damage artifacts are visible.
- Test at least one normal Wayland/XDG app, one Xwayland app if available, and
one fast-updating app such as a terminal running output, a browser animation,
a video, or a GL/Vulkan demo.
- Use visible gaps, borders, titlebars, and rounding. These make clipping and
damage mistakes much easier to see.
- If available, test on both a single-output setup and a multi-output setup.
- If logging is convenient, run with debug logging and keep any multiphase
fallback messages. A fallback is useful evidence, not automatically a bug.
Relevant internal config hooks:
- `SetAnimationsEnabled`
- `SetAnimationDurationMs`
- `SetAnimationStyle`
- `SetAnimationCurve`
- `SetAnimationCubicBezier`
TOML example:
```toml
[animations]
enabled = true
duration-ms = 600
style = "multiphase"
curve = "ease-out"
```
Set `style = "plain"` to force ordinary one-step movement interpolation while
keeping the configured curve. `curve = "linear"` only changes easing; it does
not select the plain animation style.
Current curve IDs in code:
- `0`: linear
- `1`: CSS `ease`
- `2`: CSS `ease-in`
- `3` or any other unrecognized value: CSS `ease-out`
- `4`: CSS `ease-in-out`
## Enabling Multiphase Tests
To exercise the multiphase planner:
1. Enable animations with `SetAnimationsEnabled`.
2. Set a slow duration with `SetAnimationDurationMs`, around `400-700ms`.
3. Select `style = "multiphase"` in TOML, or call `SetAnimationStyle` with
`AnimationStyle::MULTIPHASE`.
4. Use tiled layout commands that are wired through `State::with_layout_animations`.
5. Use layouts where at least two tiled windows change geometry in the same
container layout batch.
The compositor then attempts multiphase planning automatically when the batched
layout pass completes. If the planner proves a legal no-overlap sequence, that
group uses phased animation. If it cannot prove one, only that motion group falls
back to ordinary plain animation.
Good command families for multiphase testing:
- seat/window move in a tiled direction
- split changes
- tab/group operations
- group-opposite changes
- equalize
- move-tab
- mono enter/exit
- command-driven window resize
These paths should not be used as evidence of multiphase behavior:
- tile-to-float and float-to-tile, which deliberately use plain animation
- command-driven floating move/resize, which may animate but can overlap
- pointer or tablet drag/resize, which should not animate
- spawn-in and spawn-out, which are single-phase and use the configured curve
- cross-output or cross-scale movement, which should snap
- layer-shell, overlay, override-redirect, and fullscreen map/unmap paths
Useful debug signal:
- `falling back to plain layout animation for group ...` means the group entered
the multiphase gate but the planner rejected it. That is acceptable for
unsupported patterns, but unexpected for the supported swap/extraction cases
below.
## Pass Criteria
A test passes when:
- layout, focus, hit testing, and configure behavior use the final logical
geometry immediately
- visible presentation motion is smooth and bounded by the animated frame
- no old pixels, trails, black strips, transparent holes, or stale titlebar
fragments remain after motion
- tiled multiphase movement never overlaps and never moves a single window
diagonally during a phase
- interruption starts changed windows from their current visual rect without
restarting unaffected windows
- drag-driven pointer movement remains direct and does not lag behind the cursor
- cross-output or cross-scale movements snap instead of animating
Record a failure with:
- the layout before and after
- whether the window was tiled, floating, mono, XDG, Xwayland, or layer-shell
- whether the app was GPU/dmabuf-backed or likely SHM, if known
- animation duration and curve
- whether the failure was visual overlap, diagonal motion, debris, clipping,
stale content, a missing retained frame, or an incorrect animation trigger
## Known Current Limits
These are acceptable unless they produce worse behavior than described:
- Spawn-out is retained-content-only. If the surface cannot be retained safely,
it should snap out rather than animate an empty frame.
- Async SHM surfaces are not retained yet. GPU/dmabuf-backed app windows are the
primary retained-content path for this phase.
- A dedicated retained-tree scaling/offscreen fallback is deferred. Retained
records currently render through the normal texture and stretch/clamp paths.
- Floats may overlap. The no-overlap invariant is for tiled multiphase motion.
- Linear fallback may overlap. This should be rare for valid tiled layouts, and
the fallback should be scoped to the affected motion group.
- Cross-output and cross-scale movements should not animate yet.
## 1. Basic Enable/Disable
1. Disable animations.
2. Move, resize, spawn, close, and toggle floating on a few windows.
3. Confirm all affected windows snap with no delayed presentation state.
4. Enable animations at a slow duration.
5. Repeat the same operations and confirm only eligible paths animate.
6. Disable animations while an animation is in flight.
Expected:
- disabling animations clears any in-flight visual state
- no stale damage remains after disabling
- newly enabled animations use the configured duration and curve
## 2. Spawn-In
Test newly mapped windows:
- tiled XDG window
- floating XDG window
- Xwayland window, if available
- fullscreen window
- layer-shell or overlay surface, such as a bar, launcher, menu, notification,
or lock/overlay component, if available
Expected:
- newly mapped tiled and floating app windows animate in
- layer-shell, overlay, override-redirect, and fullscreen surfaces do not use
the app-window spawn-in path
- contents stay clipped to the animated frame
- if contents are smaller than the frame during the animation, no empty strips
are visible
## 3. Spawn-Out
Close windows from these states:
- tiled app window
- floating app window
- Xwayland app window
- fast-updating app window
- a likely SHM/simple app, if available
Expected:
- retained app content shrinks out after the live node is gone
- there is no black, transparent, or unfilled moving rectangle
- if content cannot be retained, the window snaps out cleanly
- neighboring tiled windows reflow without debris left in the old area
Hard failure:
- a destroyed window leaves a moving empty frame
- the last frame shows unrelated newer content
- screen debris remains after the animation completes
## 4. Linear Tiled Reflow
Use a slow duration and a non-linear curve, then repeat with linear.
Cases:
- open two tiled windows and change split ratio by command
- open three tiled windows and resize the active split repeatedly
- move focus and issue command-driven swaps
- interrupt a resize by issuing another resize before the first completes
- create a layout that forces a linear fallback if possible
Expected:
- final layout is usable immediately
- changed windows animate from their current visual rect on interruption
- unaffected windows keep their existing timeline
- linear fallback is visually smooth, even if overlap occurs
- no pointer drag path becomes animated
## 5. Float Movement And Tile/Float Transitions
Cases:
- command-toggle a tiled window to floating
- command-toggle the same window back to tiled
- command-move and command-resize a floating window
- mouse-drag a floating window
- mouse-resize a floating window
- double-click/header pointer path if that is part of the local workflow
Expected:
- command-driven tile-to-float and float-to-tile transitions animate linearly
- command-driven floating move/resize animates
- mouse or tablet drag/resize remains direct and tracks the pointer
- pointer/header paths that are intentionally outside the command-animation gate
do not unexpectedly use delayed animation
- retained child content remains clipped during tile/float transitions
## 6. Multiphase Horizontal And Vertical Swaps
Horizontal:
1. Create two horizontally adjacent tiled windows.
2. Swap their positions.
3. Reverse the swap.
Vertical:
1. Create two vertically adjacent tiled windows.
2. Swap their positions.
3. Reverse the swap.
Expected:
- first phase shrinks into lanes on the orthogonal axis
- second phase moves only horizontally or only vertically
- third phase grows out of lanes
- no phase overlaps windows
- no window moves diagonally
- reverse direction uses the same visual logic in reverse
- titlebars, borders, gaps, and rounded corners remain respected
## 7. Stack Extraction And Return
Build this shape:
```text
[ A | [ B
C ] ]
```
Then move `B` out so the target is:
```text
[ A | B | C ]
```
Reverse the operation by putting `B` back into the stack.
Expected:
- peer/container space opens first
- `B` waits until there is a legal horizontal or vertical lane
- `B` moves on one axis only
- `B` and the affected peer grow together in the final phase when appropriate
- reversing the operation is visually equivalent in reverse
## 8. Nested Parent/Child Synchronization
Create nested split layouts where a parent changes one axis and children change
the other. Use both horizontal-parent/vertical-child and
vertical-parent/horizontal-child variants.
Expected:
- parent/container-space axis changes happen before child-axis changes when the
hierarchy metadata gives a deterministic order
- child windows do not visually compose parent and child transforms into
diagonal motion
- any unsupported group falls back as a group rather than partially violating the
one-axis rule
Hard failure:
- a child visibly changes width and height in the same phase
- a child moves diagonally because parent and child animation compound
- a child clips outside its animated frame
## 9. Mixed-Action Phases
This case is easiest to prove with the planner tests because Wry does not yet
have a confirmed stock command that reliably creates a same-batch move for one
tiled window and an independent resize for another. The canonical geometry is:
```text
start: A = (0,0)-(80,80) B = (200,0)-(280,80)
target: A = (40,0)-(120,80) B = (200,0)-(280,120)
```
`A` moves horizontally while `B` scales vertically. The windows are far enough
apart that the mixed phase is provably non-overlapping.
To exercise the current proof directly, run:
```sh
cargo test animation::multiphase::tests::mixed_single_phase_accepts_move_and_scale_when_proven
```
For visual/manual testing, the target shape is:
```text
before: [ A ] [ B ]
after: [ A ] [ B ]
[ ]
```
`A` must move horizontally without resizing. `B` must resize vertically without
moving. The two motion bounds must remain separate for the whole animation. If a
normal command sequence cannot produce that in one layout batch, treat the unit
test as the authority and record the visual test as not applicable rather than a
failure.
Expected:
- mixed phases are allowed only when each individual window performs one legal
move or scale on one axis
- no individual window moves diagonally
- no overlap occurs at any point during the phase
A fallback here is acceptable if no normal user command can create this geometry;
the planner test above is the authority for the mixed-action rule.
## 10. Mono Mode
Cases:
- enter mono mode with several siblings
- exit mono mode
- switch active tabs/windows inside mono
- move a window into mono
- move a window out of mono
Expected:
- entering/exiting mono may animate where it clarifies hierarchy change
- active child animates to the mono geometry
- inactive siblings snap invisible
- ordinary tab switches inside mono do not animate
- no hidden inactive sibling leaves debris or stale retained content
## 11. Interruption And Retargeting
Use a long duration, then issue commands mid-animation:
- swap, then reverse before completion
- resize, then resize in the other direction before completion
- build `[A | [B | C | D]]`, move `C` left to form `[A | C | [B | D]]`,
then move `C` back into the stack before completion
- start a multiphase group, then change only one window's destination if a
command sequence allows it
Expected:
- affected windows restart from their current visual rect
- unaffected windows do not restart if their destination is unchanged
- a new valid multiphase plan replaces the old plan cleanly
- retained content remains the same frozen content during the retarget
- damage covers old, current, and new visual regions
## 12. Damage And Clipping Stress
Use a high-contrast wallpaper/background and high contrast window contents.
Cases:
- fast repeated swaps
- repeated spawn-in/spawn-out
- rounded corners with large gaps
- titlebar-heavy layouts
- resize while a terminal is rapidly updating
- move/resize over another window's old location
- run on different output scales if available
Expected:
- no trails remain in gaps, borders, or titlebar strips
- rounded corners do not reveal old pixels outside the frame
- contents never draw outside the animated bounds
- final frame exactly matches the steady layout
## 13. Texture Freezing
Use fast-updating contents so freezing is obvious.
Cases:
- tiled GPU/dmabuf-backed app during reflow
- floating GPU/dmabuf-backed app during command move/resize
- tile-to-float and float-to-tile with dynamic content
- spawn-out with dynamic content
- likely SHM/simple app, if available
Expected:
- retained GPU/dmabuf-backed windows freeze visually during animation
- spawn-out uses the last retained content, not a blank or unrelated frame
- undersized contents stretch/clamp to avoid unfilled frame regions
- SHM/unretained surfaces either render live safely or snap where retention is
required
Record separately:
- content continues updating during movement
- content freezes but samples the wrong source region
- edges show empty/black strips while scaling
- spawn-out skips because capture was unavailable
## 14. Cross-Output And Scale Boundaries
Cases:
- move a tiled window to another output
- move a floating window to another output
- move between outputs with different scale factors, if available
- move a workspace between outputs, if supported locally
Expected:
- movement snaps instead of animating
- no retained content is rendered at the wrong scale
- no stale damage remains on the source output
- destination output renders the final layout immediately
## 15. Regression Sweep
After visual tests, return to normal animation duration and curve.
Repeat:
- ordinary tiling navigation
- workspace switching
- fullscreen enter/exit
- focus changes
- app launch/close loops
- suspend/resume or VT switch if convenient
Expected:
- animation state does not survive across unrelated compositor state changes
- no stuck retained frames
- no persistent high CPU/GPU use after animations stop
- no obvious client throttling after many retained-content animations
## Summary Result Template
```text
Commit:
Build:
Outputs/scales:
GPU/session:
Animation config:
Passed:
-
Known-limit observations:
-
Failures:
- case:
app:
layout:
expected:
actual:
reproducible:
logs:
```

View file

@ -1023,6 +1023,26 @@ impl ConfigClient {
self.send(&ClientMessage::SetUiDragThreshold { threshold }); self.send(&ClientMessage::SetUiDragThreshold { threshold });
} }
pub fn set_animations_enabled(&self, enabled: bool) {
self.send(&ClientMessage::SetAnimationsEnabled { enabled });
}
pub fn set_animation_duration_ms(&self, duration_ms: u32) {
self.send(&ClientMessage::SetAnimationDurationMs { duration_ms });
}
pub fn set_animation_curve(&self, curve: u32) {
self.send(&ClientMessage::SetAnimationCurve { curve });
}
pub fn set_animation_style(&self, style: u32) {
self.send(&ClientMessage::SetAnimationStyle { style });
}
pub fn set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) {
self.send(&ClientMessage::SetAnimationCubicBezier { x1, y1, x2, y2 });
}
pub fn set_color_management_enabled(&self, enabled: bool) { pub fn set_color_management_enabled(&self, enabled: bool) {
self.send(&ClientMessage::SetColorManagementEnabled { enabled }); self.send(&ClientMessage::SetColorManagementEnabled { enabled });
} }

View file

@ -545,6 +545,24 @@ pub enum ClientMessage<'a> {
SetUiDragThreshold { SetUiDragThreshold {
threshold: i32, threshold: i32,
}, },
SetAnimationsEnabled {
enabled: bool,
},
SetAnimationDurationMs {
duration_ms: u32,
},
SetAnimationCurve {
curve: u32,
},
SetAnimationStyle {
style: u32,
},
SetAnimationCubicBezier {
x1: f32,
y1: f32,
x2: f32,
y2: f32,
},
SetXScalingMode { SetXScalingMode {
mode: XScalingMode, mode: XScalingMode,
}, },

View file

@ -103,6 +103,27 @@ impl Axis {
} }
} }
/// The curve used for tiled window animations.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct AnimationCurve(pub u32);
impl AnimationCurve {
pub const LINEAR: Self = Self(0);
pub const EASE: Self = Self(1);
pub const EASE_IN: Self = Self(2);
pub const EASE_OUT: Self = Self(3);
pub const EASE_IN_OUT: Self = Self(4);
}
/// The presentation style used for tiled window movement animations.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct AnimationStyle(pub u32);
impl AnimationStyle {
pub const PLAIN: Self = Self(0);
pub const MULTIPHASE: Self = Self(1);
}
/// Exits the compositor. /// Exits the compositor.
pub fn quit() { pub fn quit() {
get!().quit() get!().quit()
@ -287,6 +308,42 @@ pub fn set_ui_drag_threshold(threshold: i32) {
get!().set_ui_drag_threshold(threshold); get!().set_ui_drag_threshold(threshold);
} }
/// Enables or disables tiled window animations.
///
/// The default is `false`.
pub fn set_animations_enabled(enabled: bool) {
get!().set_animations_enabled(enabled);
}
/// Sets the duration of tiled window animations in milliseconds.
///
/// The default is `160`.
pub fn set_animation_duration_ms(duration_ms: u32) {
get!().set_animation_duration_ms(duration_ms);
}
/// Sets the curve used by tiled window animations.
///
/// The default is [`AnimationCurve::EASE_OUT`].
pub fn set_animation_curve(curve: AnimationCurve) {
get!().set_animation_curve(curve.0);
}
/// Sets the presentation style used for tiled window movement animations.
///
/// The default is [`AnimationStyle::MULTIPHASE`].
pub fn set_animation_style(style: AnimationStyle) {
get!().set_animation_style(style.0);
}
/// Sets a custom cubic-bezier curve used by tiled window animations.
///
/// `x1` and `x2` must be between `0.0` and `1.0`. The curve starts at `(0, 0)`
/// and ends at `(1, 1)`.
pub fn set_animation_cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) {
get!().set_animation_cubic_bezier(x1, y1, x2, y2);
}
/// Enables or disables the color-management protocol. /// Enables or disables the color-management protocol.
/// ///
/// The default is `false`. /// The default is `false`.

1233
src/animation.rs Normal file

File diff suppressed because it is too large Load diff

3405
src/animation/multiphase.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -360,6 +360,13 @@ fn start_compositor2(
cpu_worker, cpu_worker,
ui_drag_enabled: Cell::new(true), ui_drag_enabled: Cell::new(true),
ui_drag_threshold_squared: Cell::new(10), ui_drag_threshold_squared: Cell::new(10),
animations: Default::default(),
layout_animations_requested: Default::default(),
layout_animations_active: Default::default(),
layout_animation_curve_override: Default::default(),
layout_animation_style_override: Default::default(),
layout_animation_batch: Default::default(),
suppress_animations_for_next_layout: Default::default(),
toplevels: Default::default(), toplevels: Default::default(),
const_40hz_latch: Default::default(), const_40hz_latch: Default::default(),
tray_item_ids: Default::default(), tray_item_ids: Default::default(),

View file

@ -658,17 +658,23 @@ impl ConfigProxyHandler {
} }
fn handle_seat_move(&self, seat: Seat, direction: Direction) -> Result<(), CphError> { fn handle_seat_move(&self, seat: Seat, direction: Direction) -> Result<(), CphError> {
let seat = self.get_seat(seat)?; self.state.with_layout_animations(|| {
seat.move_focused(direction.into()); let seat = self.get_seat(seat)?;
Ok(()) seat.move_focused(direction.into());
Ok(())
})
} }
fn handle_window_move(&self, window: Window, direction: Direction) -> Result<(), CphError> { fn handle_window_move(&self, window: Window, direction: Direction) -> Result<(), CphError> {
let window = self.get_window(window)?; self.state.with_layout_animations(|| {
if let Some(c) = toplevel_parent_container(&*window) { let window = self.get_window(window)?;
c.move_child(window, direction.into()); if let Some(float) = window.tl_data().float.get() {
} float.move_by_direction(direction.into());
Ok(()) } else if let Some(c) = toplevel_parent_container(&*window) {
c.move_child(window, direction.into());
}
Ok(())
})
} }
fn handle_get_repeat_rate(&self, seat: Seat) -> Result<(), CphError> { fn handle_get_repeat_rate(&self, seat: Seat) -> Result<(), CphError> {
@ -986,6 +992,31 @@ impl ConfigProxyHandler {
self.state.set_ui_drag_threshold(threshold.max(1)); self.state.set_ui_drag_threshold(threshold.max(1));
} }
fn handle_set_animations_enabled(&self, enabled: bool) {
self.state.set_animations_enabled(enabled);
}
fn handle_set_animation_duration_ms(&self, duration_ms: u32) {
self.state
.set_animation_duration_ms(duration_ms.min(10_000));
}
fn handle_set_animation_curve(&self, curve: u32) {
self.state.set_animation_curve(curve);
}
fn handle_set_animation_style(&self, style: u32) {
if !self.state.set_animation_style(style) {
log::warn!("Ignoring invalid animation style");
}
}
fn handle_set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) {
if !self.state.set_animation_cubic_bezier(x1, y1, x2, y2) {
log::warn!("Ignoring invalid animation cubic-bezier curve");
}
}
fn handle_set_direct_scanout_enabled( fn handle_set_direct_scanout_enabled(
&self, &self,
device: Option<DrmDevice>, device: Option<DrmDevice>,
@ -1724,9 +1755,11 @@ impl ConfigProxyHandler {
} }
fn handle_set_seat_mono(&self, seat: Seat, mono: bool) -> Result<(), CphError> { fn handle_set_seat_mono(&self, seat: Seat, mono: bool) -> Result<(), CphError> {
let seat = self.get_seat(seat)?; self.state.with_layout_animations(|| {
seat.set_mono(mono); let seat = self.get_seat(seat)?;
Ok(()) seat.set_mono(mono);
Ok(())
})
} }
fn handle_get_window_mono(&self, window: Window) -> Result<(), CphError> { fn handle_get_window_mono(&self, window: Window) -> Result<(), CphError> {
@ -1740,11 +1773,13 @@ impl ConfigProxyHandler {
} }
fn handle_set_window_mono(&self, window: Window, mono: bool) -> Result<(), CphError> { fn handle_set_window_mono(&self, window: Window, mono: bool) -> Result<(), CphError> {
let window = self.get_window(window)?; self.state.with_layout_animations(|| {
if let Some(c) = toplevel_parent_container(&*window) { let window = self.get_window(window)?;
c.set_mono(mono.then_some(window.as_ref())); if let Some(c) = toplevel_parent_container(&*window) {
} c.set_mono(mono.then_some(window.as_ref()));
Ok(()) }
Ok(())
})
} }
fn handle_get_seat_split(&self, seat: Seat) -> Result<(), CphError> { fn handle_get_seat_split(&self, seat: Seat) -> Result<(), CphError> {
@ -1759,15 +1794,19 @@ impl ConfigProxyHandler {
} }
fn handle_set_seat_split(&self, seat: Seat, axis: Axis) -> Result<(), CphError> { fn handle_set_seat_split(&self, seat: Seat, axis: Axis) -> Result<(), CphError> {
let seat = self.get_seat(seat)?; self.state.with_layout_animations(|| {
seat.set_split(axis.into()); let seat = self.get_seat(seat)?;
Ok(()) seat.set_split(axis.into());
Ok(())
})
} }
fn handle_seat_toggle_tab(&self, seat: Seat) -> Result<(), CphError> { fn handle_seat_toggle_tab(&self, seat: Seat) -> Result<(), CphError> {
let seat = self.get_seat(seat)?; self.state.with_layout_animations(|| {
seat.toggle_tab(); let seat = self.get_seat(seat)?;
Ok(()) seat.toggle_tab();
Ok(())
})
} }
fn handle_seat_make_group( fn handle_seat_make_group(
@ -1776,27 +1815,35 @@ impl ConfigProxyHandler {
axis: Axis, axis: Axis,
ephemeral: bool, ephemeral: bool,
) -> Result<(), CphError> { ) -> Result<(), CphError> {
let seat = self.get_seat(seat)?; self.state.with_layout_animations(|| {
seat.make_group(axis.into(), ephemeral); let seat = self.get_seat(seat)?;
Ok(()) seat.make_group(axis.into(), ephemeral);
Ok(())
})
} }
fn handle_seat_change_group_opposite(&self, seat: Seat) -> Result<(), CphError> { fn handle_seat_change_group_opposite(&self, seat: Seat) -> Result<(), CphError> {
let seat = self.get_seat(seat)?; self.state.with_layout_animations(|| {
seat.change_group_opposite(); let seat = self.get_seat(seat)?;
Ok(()) seat.change_group_opposite();
Ok(())
})
} }
fn handle_seat_equalize(&self, seat: Seat, recursive: bool) -> Result<(), CphError> { fn handle_seat_equalize(&self, seat: Seat, recursive: bool) -> Result<(), CphError> {
let seat = self.get_seat(seat)?; self.state.with_layout_animations(|| {
seat.equalize(recursive); let seat = self.get_seat(seat)?;
Ok(()) seat.equalize(recursive);
Ok(())
})
} }
fn handle_seat_move_tab(&self, seat: Seat, right: bool) -> Result<(), CphError> { fn handle_seat_move_tab(&self, seat: Seat, right: bool) -> Result<(), CphError> {
let seat = self.get_seat(seat)?; self.state.with_layout_animations(|| {
seat.move_tab(right); let seat = self.get_seat(seat)?;
Ok(()) seat.move_tab(right);
Ok(())
})
} }
fn handle_get_window_split(&self, window: Window) -> Result<(), CphError> { fn handle_get_window_split(&self, window: Window) -> Result<(), CphError> {
@ -1811,11 +1858,13 @@ impl ConfigProxyHandler {
} }
fn handle_set_window_split(&self, window: Window, axis: Axis) -> Result<(), CphError> { fn handle_set_window_split(&self, window: Window, axis: Axis) -> Result<(), CphError> {
let window = self.get_window(window)?; self.state.with_layout_animations(|| {
if let Some(c) = toplevel_parent_container(&*window) { let window = self.get_window(window)?;
c.set_split(axis.into()); if let Some(c) = toplevel_parent_container(&*window) {
} c.set_split(axis.into());
Ok(()) }
Ok(())
})
} }
fn handle_add_shortcut( fn handle_add_shortcut(
@ -1955,9 +2004,11 @@ impl ConfigProxyHandler {
} }
fn handle_set_seat_floating(&self, seat: Seat, floating: bool) -> Result<(), CphError> { fn handle_set_seat_floating(&self, seat: Seat, floating: bool) -> Result<(), CphError> {
let seat = self.get_seat(seat)?; self.state.with_linear_layout_animations(|| {
seat.set_floating(floating); let seat = self.get_seat(seat)?;
Ok(()) seat.set_floating(floating);
Ok(())
})
} }
fn handle_get_window_floating(&self, window: Window) -> Result<(), CphError> { fn handle_get_window_floating(&self, window: Window) -> Result<(), CphError> {
@ -1969,9 +2020,11 @@ impl ConfigProxyHandler {
} }
fn handle_set_window_floating(&self, window: Window, floating: bool) -> Result<(), CphError> { fn handle_set_window_floating(&self, window: Window, floating: bool) -> Result<(), CphError> {
let window = self.get_window(window)?; self.state.with_linear_layout_animations(|| {
toplevel_set_floating(&self.state, window, floating); let window = self.get_window(window)?;
Ok(()) toplevel_set_floating(&self.state, window, floating);
Ok(())
})
} }
fn handle_add_pollable(self: &Rc<Self>, fd: i32) -> Result<(), CphError> { fn handle_add_pollable(self: &Rc<Self>, fd: i32) -> Result<(), CphError> {
@ -2721,8 +2774,10 @@ impl ConfigProxyHandler {
dx2: i32, dx2: i32,
dy2: i32, dy2: i32,
) -> Result<(), CphError> { ) -> Result<(), CphError> {
self.get_window(window)?.tl_resize(dx1, dy1, dx2, dy2); self.state.with_layout_animations(|| {
Ok(()) self.get_window(window)?.tl_resize(dx1, dy1, dx2, dy2);
Ok(())
})
} }
fn handle_window_exists(&self, window: Window) { fn handle_window_exists(&self, window: Window) {
@ -3193,6 +3248,17 @@ impl ConfigProxyHandler {
ClientMessage::SetUiDragThreshold { threshold } => { ClientMessage::SetUiDragThreshold { threshold } => {
self.handle_set_ui_drag_threshold(threshold) self.handle_set_ui_drag_threshold(threshold)
} }
ClientMessage::SetAnimationsEnabled { enabled } => {
self.handle_set_animations_enabled(enabled)
}
ClientMessage::SetAnimationDurationMs { duration_ms } => {
self.handle_set_animation_duration_ms(duration_ms)
}
ClientMessage::SetAnimationCurve { curve } => self.handle_set_animation_curve(curve),
ClientMessage::SetAnimationStyle { style } => self.handle_set_animation_style(style),
ClientMessage::SetAnimationCubicBezier { x1, y1, x2, y2 } => {
self.handle_set_animation_cubic_bezier(x1, y1, x2, y2)
}
ClientMessage::SetXScalingMode { mode } => self ClientMessage::SetXScalingMode { mode } => self
.handle_set_x_scaling_mode(mode) .handle_set_x_scaling_mode(mode)
.wrn("set_x_scaling_mode")?, .wrn("set_x_scaling_mode")?,

View file

@ -936,6 +936,9 @@ impl WlSeatGlobal {
{ {
c.move_child(tl, direction); c.move_child(tl, direction);
self.maybe_schedule_warp_mouse_to_focus(); self.maybe_schedule_warp_mouse_to_focus();
} else if let Some(float) = data.float.get() {
float.move_by_direction(direction);
self.maybe_schedule_warp_mouse_to_focus();
} }
} }

View file

@ -628,6 +628,11 @@ fn schedule_async_upload(
{ {
back_tex_opt = None; back_tex_opt = None;
} }
if let Some(back_tex) = &back_tex_opt
&& Rc::strong_count(back_tex) > 1
{
back_tex_opt = None;
}
let damage_full = || { let damage_full = || {
back.damage.clear(); back.damage.clear();
back.damage.damage(slice::from_ref(&buf.rect)); back.damage.damage(slice::from_ref(&buf.rect));

View file

@ -1,7 +1,7 @@
use { use {
crate::{ crate::{
ifs::wl_surface::{ ifs::wl_surface::{
SurfaceExt, WlSurface, WlSurfaceError, PendingState, SurfaceExt, WlSurface, WlSurfaceError,
x_surface::{xwayland_surface_v1::XwaylandSurfaceV1, xwindow::Xwindow}, x_surface::{xwayland_surface_v1::XwaylandSurfaceV1, xwindow::Xwindow},
}, },
leaks::Tracker, leaks::Tracker,
@ -30,6 +30,22 @@ impl SurfaceExt for XSurface {
win.node_layer() win.node_layer()
} }
fn before_apply_commit(
self: Rc<Self>,
pending: &mut PendingState,
) -> Result<(), WlSurfaceError> {
if pending
.buffer
.as_ref()
.is_some_and(|buffer| buffer.is_none())
&& self.surface.buffer.is_some()
&& let Some(xwindow) = self.xwindow.get()
{
xwindow.queue_spawn_out();
}
Ok(())
}
fn after_apply_commit(self: Rc<Self>) { fn after_apply_commit(self: Rc<Self>) {
if let Some(xwindow) = self.xwindow.get() { if let Some(xwindow) = self.xwindow.get() {
xwindow.map_status_changed(); xwindow.map_status_changed();
@ -45,6 +61,7 @@ impl SurfaceExt for XSurface {
} }
self.surface.unset_ext(); self.surface.unset_ext();
if let Some(xwindow) = self.xwindow.take() { if let Some(xwindow) = self.xwindow.take() {
xwindow.queue_spawn_out();
xwindow.tl_destroy(); xwindow.tl_destroy();
xwindow.data.window.set(None); xwindow.data.window.set(None);
xwindow.data.surface_id.set(None); xwindow.data.surface_id.set(None);

View file

@ -1,5 +1,6 @@
use { use {
crate::{ crate::{
animation::RetainedToplevel,
client::Client, client::Client,
cursor::KnownCursor, cursor::KnownCursor,
fixed::Fixed, fixed::Fixed,
@ -252,6 +253,11 @@ impl Xwindow {
self.x.surface.buffer.is_some() && self.data.info.mapped.get() self.x.surface.buffer.is_some() && self.data.info.mapped.get()
} }
pub fn queue_spawn_out(&self) {
self.toplevel_data
.queue_spawn_out(self, self.tl_animation_snapshot());
}
fn map_change(&self) -> Change { fn map_change(&self) -> Change {
match (self.may_be_mapped(), self.is_mapped()) { match (self.may_be_mapped(), self.is_mapped()) {
(true, false) => Change::Map, (true, false) => Change::Map,
@ -274,6 +280,7 @@ impl Xwindow {
match map_change { match map_change {
Change::None => return, Change::None => return,
Change::Unmap => { Change::Unmap => {
self.queue_spawn_out();
self.data self.data
.info .info
.pending_extents .pending_extents
@ -514,6 +521,10 @@ impl ToplevelNodeBase for Xwindow {
Some(self.x.surface.clone()) Some(self.x.surface.clone())
} }
fn tl_animation_snapshot(&self) -> Option<Rc<RetainedToplevel>> {
RetainedToplevel::capture_surface(&self.x.surface, (0, 0))
}
fn tl_admits_children(&self) -> bool { fn tl_admits_children(&self) -> bool {
false false
} }

View file

@ -226,6 +226,10 @@ pub trait XdgSurfaceExt: Debug {
// nothing // nothing
} }
fn prepare_unmap(&self) {
// nothing
}
fn extents_changed(&self) { fn extents_changed(&self) {
// nothing // nothing
} }
@ -664,6 +668,15 @@ impl SurfaceExt for XdgSurface {
if let Some(serial) = pending.serial.take() { if let Some(serial) = pending.serial.take() {
self.applied_serial.set(serial); self.applied_serial.set(serial);
} }
if pending
.buffer
.as_ref()
.is_some_and(|buffer| buffer.is_none())
&& self.surface.buffer.is_some()
&& let Some(ext) = self.ext.get()
{
ext.prepare_unmap();
}
Ok(()) Ok(())
} }

View file

@ -2,6 +2,7 @@ pub mod xdg_dialog_v1;
use { use {
crate::{ crate::{
animation::RetainedToplevel,
bugs, bugs,
bugs::Bugs, bugs::Bugs,
client::{Client, ClientError}, client::{Client, ClientError},
@ -259,6 +260,7 @@ impl XdgToplevelRequestHandler for XdgToplevel {
type Error = XdgToplevelError; type Error = XdgToplevelError;
fn destroy(&self, _req: Destroy, _slf: &Rc<Self>) -> Result<(), Self::Error> { fn destroy(&self, _req: Destroy, _slf: &Rc<Self>) -> Result<(), Self::Error> {
self.queue_spawn_out();
self.tl_destroy(); self.tl_destroy();
self.xdg.unset_ext(); self.xdg.unset_ext();
{ {
@ -398,6 +400,11 @@ impl XdgToplevelRequestHandler for XdgToplevel {
} }
impl XdgToplevel { impl XdgToplevel {
fn queue_spawn_out(&self) {
self.toplevel_data
.queue_spawn_out(self, self.tl_animation_snapshot());
}
fn map( fn map(
self: &Rc<Self>, self: &Rc<Self>,
parent: Option<&XdgToplevel>, parent: Option<&XdgToplevel>,
@ -779,6 +786,11 @@ impl ToplevelNodeBase for XdgToplevel {
Some(self.xdg.surface.clone()) Some(self.xdg.surface.clone())
} }
fn tl_animation_snapshot(&self) -> Option<Rc<RetainedToplevel>> {
let geo = self.xdg.geometry();
RetainedToplevel::capture_surface(&self.xdg.surface, (-geo.x1(), -geo.y1()))
}
fn tl_restack_popups(&self) { fn tl_restack_popups(&self) {
self.xdg.restack_popups(); self.xdg.restack_popups();
} }
@ -818,6 +830,10 @@ impl XdgSurfaceExt for XdgToplevel {
self.after_commit(None); self.after_commit(None);
} }
fn prepare_unmap(&self) {
self.queue_spawn_out();
}
fn extents_changed(&self) { fn extents_changed(&self) {
self.toplevel_data.pos.set(self.xdg.extents.get()); self.toplevel_data.pos.set(self.xdg.extents.get());
self.tl_extents_changed(); self.tl_extents_changed();

View file

@ -48,6 +48,7 @@ mod leaks;
mod tracy; mod tracy;
mod acceptor; mod acceptor;
mod allocator; mod allocator;
mod animation;
mod async_engine; mod async_engine;
mod backend; mod backend;
mod backends; mod backends;

View file

@ -1,7 +1,11 @@
use { use {
crate::{ crate::{
animation::{
RetainedContent, RetainedExitFrame, RetainedExitLayer, RetainedSurface,
RetainedToplevel,
},
cmm::cmm_render_intent::RenderIntent, cmm::cmm_render_intent::RenderIntent,
gfx_api::{AcquireSync, AlphaMode, GfxApiOpt, ReleaseSync, SampleRect}, gfx_api::{AcquireSync, AlphaMode, BufferResv, GfxApiOpt, ReleaseSync, SampleRect},
ifs::wl_surface::{ ifs::wl_surface::{
SurfaceBuffer, WlSurface, SurfaceBuffer, WlSurface,
x_surface::xwindow::Xwindow, x_surface::xwindow::Xwindow,
@ -14,8 +18,8 @@ use {
state::State, state::State,
theme::{Color, CornerRadius}, theme::{Color, CornerRadius},
tree::{ tree::{
ContainerNode, DisplayNode, FloatNode, OutputNode, PlaceholderNode, ToplevelData, ContainerNode, DisplayNode, FloatNode, Node, OutputNode, PlaceholderNode, ToplevelData,
ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar, ToplevelNode, ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar,
}, },
}, },
std::{ops::Deref, rc::Rc, slice}, std::{ops::Deref, rc::Rc, slice},
@ -200,14 +204,22 @@ impl Renderer<'_> {
self.render_workspace(&ws, x, y); self.render_workspace(&ws, x, y);
} }
} }
let now = self.state.now_nsec();
let exit_frames = self.state.animations.exit_frames(now);
self.render_exit_frames(&exit_frames, RetainedExitLayer::Tiled, &opos);
macro_rules! render_stacked { macro_rules! render_stacked {
($stack:expr) => { ($stack:expr) => {
for stacked in $stack.iter() { for stacked in $stack.iter() {
if stacked.node_visible() { if stacked.node_visible() {
self.base.sync(); self.base.sync();
let pos = stacked.node_absolute_position(); let pos = stacked.node_absolute_position();
if pos.intersects(&opos) { let visual = self.state.animations.visual_rect(
let (x, y) = opos.translate(pos.x1(), pos.y1()); stacked.node_id(),
pos,
self.state.now_nsec(),
);
if visual.intersects(&opos) {
let (x, y) = opos.translate(visual.x1(), visual.y1());
stacked.node_render(self, x, y, None); stacked.node_render(self, x, y, None);
} }
} }
@ -215,6 +227,7 @@ impl Renderer<'_> {
}; };
} }
render_stacked!(self.state.root.stacked); render_stacked!(self.state.root.stacked);
self.render_exit_frames(&exit_frames, RetainedExitLayer::Floating, &opos);
// Flush RoundedFillRect ops from container/float borders so they don't // Flush RoundedFillRect ops from container/float borders so they don't
// sort after (and render on top of) layer-shell CopyTexture ops. // sort after (and render on top of) layer-shell CopyTexture ops.
self.base.sync(); self.base.sync();
@ -453,6 +466,265 @@ impl Renderer<'_> {
.fill_boxes2(&rd.border_rects, &c, srgb, perceptual, x, y); .fill_boxes2(&rd.border_rects, &c, srgb, perceptual, x, y);
} }
fn presentation_child_body(
&self,
container: &ContainerNode,
child: &Rc<dyn ToplevelNode>,
body: Rect,
) -> Rect {
let abs = body.move_(container.abs_x1.get(), container.abs_y1.get());
let visual = self
.state
.animations
.visual_rect(child.node_id(), abs, self.state.now_nsec());
visual.move_(-container.abs_x1.get(), -container.abs_y1.get())
}
fn render_child_or_snapshot(
&mut self,
child: &Rc<dyn ToplevelNode>,
x: i32,
y: i32,
bounds: Option<&Rect>,
) {
if let Some(retained) = self
.state
.animations
.retained_snapshot(child.node_id(), self.state.now_nsec())
{
self.render_retained_toplevel(&retained, x, y, bounds);
} else {
child.node_render(self, x, y, bounds);
}
}
fn render_retained_toplevel(
&mut self,
retained: &RetainedToplevel,
x: i32,
y: i32,
bounds: Option<&Rect>,
) {
let (x, y) = self
.base
.scale_point(x + retained.offset.0, y + retained.offset.1);
self.render_retained_surface_scaled(&retained.surface, x, y, None, bounds);
}
fn render_exit_frames(
&mut self,
frames: &[RetainedExitFrame],
layer: RetainedExitLayer,
output_rect: &Rect,
) {
for frame in frames {
if frame.layer != layer || !frame.rect.intersects(output_rect) {
continue;
}
self.render_exit_frame(frame, output_rect);
}
}
fn render_exit_frame(&mut self, frame: &RetainedExitFrame, output_rect: &Rect) {
let (x, y) = output_rect.translate(frame.rect.x1(), frame.rect.y1());
let inset = frame.frame_inset;
if inset > 0 {
let color = if frame.active {
self.state.theme.colors.active_border.get()
} else {
self.state.theme.colors.border.get()
};
self.render_rounded_frame(
Rect::new_sized_saturating(0, 0, frame.rect.width(), frame.rect.height()),
&color,
self.state.theme.corner_radius.get(),
inset,
x,
y,
);
}
let body = Rect::new_sized_saturating(
x + inset,
y + inset,
frame.rect.width() - 2 * inset,
frame.rect.height() - 2 * inset,
);
if body.is_empty() {
return;
}
if inset > 0 && !self.state.theme.corner_radius.get().is_zero() {
let inner_cr = self.scale_corner_radius(
self.state
.theme
.corner_radius
.get()
.expanded_by(-(inset as f32)),
);
self.corner_radius = Some(inner_cr);
}
self.render_window_body_background(body);
let bounds = self.base.scale_rect(body);
self.stretch = if frame.source_body_size != body.size() {
Some(self.base.scale_point(body.width(), body.height()))
} else {
None
};
self.render_retained_toplevel(&frame.retained, body.x1(), body.y1(), Some(&bounds));
self.stretch = None;
self.corner_radius = None;
}
fn render_window_body_background(&mut self, body: Rect) {
if body.is_empty() {
return;
}
let color = self.state.theme.colors.background.get();
let srgb_srgb = self.state.color_manager.srgb_gamma22();
let srgb = &srgb_srgb.linear;
let perceptual = RenderIntent::Perceptual;
self.base.sync();
if let Some(cr) = self.corner_radius
&& !cr.is_zero()
{
self.base
.fill_rounded_rect(body, &color, None, srgb, perceptual, cr, 0.0);
} else {
let bounds = self.base.scale_rect(body);
self.base
.fill_scaled_boxes(slice::from_ref(&bounds), &color, None, srgb, perceptual);
}
}
fn render_retained_surface_scaled(
&mut self,
retained: &RetainedSurface,
x: i32,
y: i32,
pos_rel: Option<(i32, i32)>,
bounds: Option<&Rect>,
) {
let stretch = self.stretch.take();
let corner_radius = self.corner_radius.take();
let mut size = retained.size;
if let Some((x_rel, y_rel)) = pos_rel {
let (x, y) = self.base.scale_point(x_rel, y_rel);
let (w, h) = self.base.scale_point(x_rel + size.0, y_rel + size.1);
size = (w - x, h - y);
} else {
size = self.base.scale_point(size.0, size.1);
}
let mut stretched_source = None;
if let Some(s) = stretch {
if let RetainedContent::Texture { source, .. } = &retained.content {
let mut source = *source;
if size.0 > 0 && size.1 > 0 {
let sx = s.0 as f32 / size.0 as f32;
let sy = s.1 as f32 / size.1 as f32;
source.x2 *= sx;
source.y2 *= sy;
}
stretched_source = Some(source);
}
size = s;
}
for child in &retained.below {
let (x1, y1) = self.base.scale_point(child.offset.0, child.offset.1);
self.render_retained_surface_scaled(child, x + x1, y + y1, Some(child.offset), bounds);
}
self.corner_radius = corner_radius;
self.render_retained_content(retained, stretched_source, x, y, size, bounds);
for child in &retained.above {
let (x1, y1) = self.base.scale_point(child.offset.0, child.offset.1);
self.render_retained_surface_scaled(child, x + x1, y + y1, Some(child.offset), bounds);
}
}
fn render_retained_content(
&mut self,
retained: &RetainedSurface,
stretched_source: Option<SampleRect>,
x: i32,
y: i32,
size: (i32, i32),
bounds: Option<&Rect>,
) {
let corner_radius = self.corner_radius.take();
match &retained.content {
RetainedContent::Texture {
texture,
buffer,
source,
alpha,
color_description,
render_intent,
alpha_mode,
opaque,
} => {
let source = stretched_source.unwrap_or(*source);
if let Some(cr) = corner_radius {
self.base.render_rounded_texture(
texture,
*alpha,
x,
y,
Some(source),
Some(size),
self.base.scale,
bounds,
Some(buffer.clone() as Rc<dyn BufferResv>),
AcquireSync::Unnecessary,
buffer.release_sync,
color_description,
*render_intent,
*alpha_mode,
cr,
);
} else {
self.base.render_texture(
texture,
*alpha,
x,
y,
Some(source),
Some(size),
self.base.scale,
bounds,
Some(buffer.clone() as Rc<dyn BufferResv>),
AcquireSync::Unnecessary,
buffer.release_sync,
*opaque,
color_description,
*render_intent,
*alpha_mode,
);
}
}
RetainedContent::Color {
color,
alpha,
color_description,
render_intent,
} => {
if let Some(rect) = Rect::new_sized(x, y, size.0, size.1) {
let rect = match bounds {
None => rect,
Some(bounds) => rect.intersect(*bounds),
};
if !rect.is_empty() {
self.base.sync();
self.base.fill_scaled_boxes(
&[rect],
color,
*alpha,
&color_description.linear,
*render_intent,
);
}
}
}
}
}
pub fn render_container(&mut self, container: &ContainerNode, x: i32, y: i32) { pub fn render_container(&mut self, container: &ContainerNode, x: i32, y: i32) {
self.render_container_decorations(container, x, y); self.render_container_decorations(container, x, y);
@ -465,6 +737,7 @@ impl Renderer<'_> {
} }
} }
let mb = container.mono_body.get(); let mb = container.mono_body.get();
let visual_mb = self.presentation_child_body(container, &child.node, mb);
if self.state.theme.sizes.gap.get() != 0 { if self.state.theme.sizes.gap.get() != 0 {
let bw = self.state.theme.sizes.border_width.get(); let bw = self.state.theme.sizes.border_width.get();
let border_color = self.state.theme.colors.border.get(); let border_color = self.state.theme.colors.border.get();
@ -476,10 +749,10 @@ impl Renderer<'_> {
}; };
if !child.node.node_is_container() { if !child.node.node_is_container() {
let frame = Rect::new_sized_saturating( let frame = Rect::new_sized_saturating(
mb.x1() - bw, visual_mb.x1() - bw,
mb.y1() - bw, visual_mb.y1() - bw,
mb.width() + 2 * bw, visual_mb.width() + 2 * bw,
mb.height() + 2 * bw, visual_mb.height() + 2 * bw,
); );
self.render_rounded_frame( self.render_rounded_frame(
frame, frame,
@ -491,14 +764,17 @@ impl Renderer<'_> {
); );
} }
} }
let body = mb.move_(x, y); let body = visual_mb.move_(x, y);
let body = self.base.scale_rect(body); let content = container
let content = container.mono_content.get(); .mono_content
self.stretch = if content.width() != mb.width() || content.height() != mb.height() { .get()
Some(self.base.scale_point(mb.width(), mb.height())) .at_point(visual_mb.x1(), visual_mb.y1());
} else { self.stretch =
None if content.width() != visual_mb.width() || content.height() != visual_mb.height() {
}; Some(self.base.scale_point(visual_mb.width(), visual_mb.height()))
} else {
None
};
if self.state.theme.sizes.gap.get() != 0 && !child.node.node_is_container() { if self.state.theme.sizes.gap.get() != 0 && !child.node.node_is_container() {
let cr = self.state.theme.corner_radius.get(); let cr = self.state.theme.corner_radius.get();
if !cr.is_zero() { if !cr.is_zero() {
@ -507,9 +783,16 @@ impl Renderer<'_> {
self.corner_radius = Some(inner_cr); self.corner_radius = Some(inner_cr);
} }
} }
child if !child.node.node_is_container() {
.node self.render_window_body_background(body);
.node_render(self, x + content.x1(), y + content.y1(), Some(&body)); }
let body = self.base.scale_rect(body);
self.render_child_or_snapshot(
&child.node,
x + content.x1(),
y + content.y1(),
Some(&body),
);
self.stretch = None; self.stretch = None;
self.corner_radius = None; self.corner_radius = None;
} else { } else {
@ -524,10 +807,13 @@ impl Renderer<'_> {
}; };
let cr = self.state.theme.corner_radius.get(); let cr = self.state.theme.corner_radius.get();
for child in container.children.iter() { for child in container.children.iter() {
let body = child.body.get(); let layout_body = child.body.get();
if body.x1() >= container.width.get() || body.y1() >= container.height.get() { if layout_body.x1() >= container.width.get()
|| layout_body.y1() >= container.height.get()
{
break; break;
} }
let body = self.presentation_child_body(container, &child.node, layout_body);
if gap != 0 { if gap != 0 {
let c = if child.border_color_is_focused.get() { let c = if child.border_color_is_focused.get() {
&focused_border_color &focused_border_color
@ -544,7 +830,7 @@ impl Renderer<'_> {
self.render_rounded_frame(frame, c, cr, bw, x, y); self.render_rounded_frame(frame, c, cr, bw, x, y);
} }
} }
let content = child.content.get(); let content = child.content.get().at_point(body.x1(), body.y1());
self.stretch = self.stretch =
if content.width() != body.width() || content.height() != body.height() { if content.width() != body.width() || content.height() != body.height() {
Some(self.base.scale_point(body.width(), body.height())) Some(self.base.scale_point(body.width(), body.height()))
@ -556,10 +842,16 @@ impl Renderer<'_> {
self.corner_radius = Some(inner_cr); self.corner_radius = Some(inner_cr);
} }
let body = body.move_(x, y); let body = body.move_(x, y);
if !child.node.node_is_container() {
self.render_window_body_background(body);
}
let body = self.base.scale_rect(body); let body = self.base.scale_rect(body);
child self.render_child_or_snapshot(
.node &child.node,
.node_render(self, x + content.x1(), y + content.y1(), Some(&body)); x + content.x1(),
y + content.y1(),
Some(&body),
);
self.stretch = None; self.stretch = None;
self.corner_radius = None; self.corner_radius = None;
} }
@ -793,6 +1085,10 @@ impl Renderer<'_> {
_ => return, _ => return,
}; };
let pos = floating.position.get(); let pos = floating.position.get();
let visual =
self.state
.animations
.visual_rect(floating.node_id(), pos, self.state.now_nsec());
let theme = &self.state.theme; let theme = &self.state.theme;
let bw = theme.sizes.border_width.get(); let bw = theme.sizes.border_width.get();
let bc = if floating.active.get() { let bc = if floating.active.get() {
@ -801,16 +1097,27 @@ impl Renderer<'_> {
theme.colors.border.get() theme.colors.border.get()
}; };
let cr = theme.corner_radius.get(); let cr = theme.corner_radius.get();
let outer = Rect::new_sized_saturating(0, 0, pos.width(), pos.height()); let outer = Rect::new_sized_saturating(0, 0, visual.width(), visual.height());
self.render_rounded_frame(outer, &bc, cr, bw, x, y); self.render_rounded_frame(outer, &bc, cr, bw, x, y);
let body = let body = Rect::new_sized_saturating(
Rect::new_sized_saturating(x + bw, y + bw, pos.width() - 2 * bw, pos.height() - 2 * bw); x + bw,
y + bw,
visual.width() - 2 * bw,
visual.height() - 2 * bw,
);
let scissor_body = self.base.scale_rect(body); let scissor_body = self.base.scale_rect(body);
self.stretch = if pos.width() != visual.width() || pos.height() != visual.height() {
Some(self.base.scale_point(body.width(), body.height()))
} else {
None
};
if !cr.is_zero() { if !cr.is_zero() {
let inner_cr = self.scale_corner_radius(cr.expanded_by(-(bw as f32))); let inner_cr = self.scale_corner_radius(cr.expanded_by(-(bw as f32)));
self.corner_radius = Some(inner_cr); self.corner_radius = Some(inner_cr);
} }
child.node_render(self, body.x1(), body.y1(), Some(&scissor_body)); self.render_window_body_background(body);
self.render_child_or_snapshot(&child, body.x1(), body.y1(), Some(&scissor_body));
self.stretch = None;
self.corner_radius = None; self.corner_radius = None;
} }

View file

@ -2,6 +2,17 @@ use {
crate::{ crate::{
acceptor::Acceptor, acceptor::Acceptor,
allocator::BufferObject, allocator::BufferObject,
animation::{
AnimationCurve, AnimationState, AnimationStyle, AnimationTick, RetainedExitLayer,
RetainedToplevel,
expand_damage_rect,
multiphase::{
MultiphasePhase, MultiphasePlan, MultiphasePlanFailure, MultiphaseRequest,
MultiphaseWindow, MultiphaseWindowHierarchy,
partition_motion_groups, plan_no_overlap_with_diagnostics, validate_phase_paths,
},
spawn_in_start_rect,
},
async_engine::{AsyncEngine, SpawnedFuture}, async_engine::{AsyncEngine, SpawnedFuture},
backend::{ backend::{
Backend, BackendConnectorState, BackendConnectorStateSerials, BackendDrmDevice, Backend, BackendConnectorState, BackendConnectorStateSerials, BackendDrmDevice,
@ -102,11 +113,10 @@ use {
time::Time, time::Time,
tree::{ tree::{
ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode, ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode,
FoundNode, LatchListener, Node, NodeIds, NodeVisitorBase, OutputNode, PlaceholderNode, FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode,
TearingMode, TileState, ToplevelData, ToplevelIdentifier, ToplevelNode, PlaceholderNode, TearingMode, TileState, ToplevelData, ToplevelIdentifier,
ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder, WorkspaceNode, ToplevelNode, ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder,
WorkspaceNodeId, WorkspaceNode, WorkspaceNodeId, WsMoveConfig, generic_node_visitor, move_ws_to_output,
WsMoveConfig, generic_node_visitor, move_ws_to_output,
}, },
udmabuf::UdmabufHolder, udmabuf::UdmabufHolder,
utils::{ utils::{
@ -154,6 +164,98 @@ use {
uapi::{OwnedFd, c}, uapi::{OwnedFd, c},
}; };
#[derive(Clone)]
pub(crate) struct LayoutAnimationCandidate {
node_id: NodeId,
old: Rect,
new: Rect,
curve: AnimationCurve,
style: AnimationStyle,
hierarchy: MultiphaseWindowHierarchy,
}
fn coalesce_layout_animation_candidates(
candidates: Vec<LayoutAnimationCandidate>,
) -> Vec<LayoutAnimationCandidate> {
let mut merged: Vec<LayoutAnimationCandidate> = vec![];
for candidate in candidates {
if let Some(existing) = merged
.iter_mut()
.find(|existing| existing.node_id == candidate.node_id)
{
existing.new = candidate.new;
existing.curve = candidate.curve;
existing.style = candidate.style;
existing.hierarchy = MultiphaseWindowHierarchy::new(
existing.hierarchy.source,
candidate.hierarchy.target,
);
} else {
merged.push(candidate);
}
}
merged
}
fn layout_animation_group_uses_plain(
candidates: &[LayoutAnimationCandidate],
group: &[usize],
) -> bool {
group
.iter()
.any(|&idx| candidates[idx].style == AnimationStyle::Plain)
}
fn bridged_retarget_plan(
request: &MultiphaseRequest,
candidates: &[LayoutAnimationCandidate],
group: &[usize],
bridge_paths: &[Vec<(Rect, Rect)>],
bridge_phase_count: usize,
follow_phases: &[MultiphasePhase],
) -> Result<MultiphasePlan, MultiphasePlanFailure> {
let mut paths = vec![];
for (group_pos, &idx) in group.iter().enumerate() {
let candidate = &candidates[idx];
let window = request.windows[group_pos];
let Some(bridge_path) = bridge_paths.get(group_pos) else {
return Err(MultiphasePlanFailure::NoPattern);
};
let mut path = bridge_path.clone();
let mut current = path
.last()
.map(|(_, to)| *to)
.unwrap_or(window.from);
while path.len() < bridge_phase_count {
path.push((current, current));
}
if current != candidate.old {
return Err(MultiphasePlanFailure::NoPattern);
}
for phase in follow_phases {
match phase
.steps
.iter()
.find(|step| step.node_id == candidate.node_id)
{
Some(step) => {
if step.from != current {
return Err(MultiphasePlanFailure::NoPattern);
}
path.push((step.from, step.to));
current = step.to;
}
None => path.push((current, current)),
}
}
if current != window.to {
return Err(MultiphasePlanFailure::NoPattern);
}
paths.push(path);
}
validate_phase_paths(request, &paths)
}
pub struct State { pub struct State {
pub pid: c::pid_t, pub pid: c::pid_t,
pub kb_ctx: KbvmContext, pub kb_ctx: KbvmContext,
@ -264,6 +366,13 @@ pub struct State {
pub cpu_worker: Rc<CpuWorker>, pub cpu_worker: Rc<CpuWorker>,
pub ui_drag_enabled: Cell<bool>, pub ui_drag_enabled: Cell<bool>,
pub ui_drag_threshold_squared: Cell<i32>, pub ui_drag_threshold_squared: Cell<i32>,
pub animations: AnimationState,
pub layout_animations_requested: Cell<bool>,
pub layout_animations_active: Cell<bool>,
pub layout_animation_curve_override: Cell<Option<AnimationCurve>>,
pub layout_animation_style_override: Cell<Option<AnimationStyle>>,
pub(crate) layout_animation_batch: RefCell<Option<Vec<LayoutAnimationCandidate>>>,
pub suppress_animations_for_next_layout: Cell<bool>,
pub toplevels: CopyHashMap<ToplevelIdentifier, Weak<dyn ToplevelNode>>, pub toplevels: CopyHashMap<ToplevelIdentifier, Weak<dyn ToplevelNode>>,
pub const_40hz_latch: EventSource<dyn LatchListener>, pub const_40hz_latch: EventSource<dyn LatchListener>,
pub tray_item_ids: TrayItemIds, pub tray_item_ids: TrayItemIds,
@ -812,7 +921,14 @@ impl State {
pub fn map_tiled(self: &Rc<Self>, node: Rc<dyn ToplevelNode>) { pub fn map_tiled(self: &Rc<Self>, node: Rc<dyn ToplevelNode>) {
let seat = self.seat_queue.last(); let seat = self.seat_queue.last();
self.do_map_tiled(seat.as_deref(), node.clone()); let animate_new_app_map = node.tl_data().parent.is_none()
&& node.tl_data().kind.is_app_window()
&& !node.tl_data().visible.get();
if animate_new_app_map {
self.with_layout_animations(|| self.do_map_tiled(seat.as_deref(), node.clone()));
} else {
self.do_map_tiled(seat.as_deref(), node.clone());
}
self.focus_after_map(node, seat.as_deref()); self.focus_after_map(node, seat.as_deref());
} }
@ -847,7 +963,7 @@ impl State {
mut height: i32, mut height: i32,
workspace: &Rc<WorkspaceNode>, workspace: &Rc<WorkspaceNode>,
abs_pos: Option<(i32, i32)>, abs_pos: Option<(i32, i32)>,
) { ) -> Rc<FloatNode> {
width += 2 * self.theme.sizes.border_width.get(); width += 2 * self.theme.sizes.border_width.get();
height += height +=
2 * self.theme.sizes.border_width.get() + self.theme.title_plus_underline_height(); 2 * self.theme.sizes.border_width.get() + self.theme.title_plus_underline_height();
@ -878,8 +994,9 @@ impl State {
} }
Rect::new_sized_saturating(x1, y1, width, height) Rect::new_sized_saturating(x1, y1, width, height)
}; };
FloatNode::new(self, workspace, position, node.clone()); let float = FloatNode::new(self, workspace, position, node.clone());
self.focus_after_map(node, self.seat_queue.last().as_deref()); self.focus_after_map(node, self.seat_queue.last().as_deref());
float
} }
fn focus_after_map(&self, node: Rc<dyn ToplevelNode>, seat: Option<&Rc<WlSeatGlobal>>) { fn focus_after_map(&self, node: Rc<dyn ToplevelNode>, seat: Option<&Rc<WlSeatGlobal>>) {
@ -1115,6 +1232,12 @@ impl State {
self.pending_screencast_reallocs_or_reconfigures.clear(); self.pending_screencast_reallocs_or_reconfigures.clear();
self.pending_placeholder_render_textures.clear(); self.pending_placeholder_render_textures.clear();
self.pending_container_tab_render_textures.clear(); self.pending_container_tab_render_textures.clear();
self.animations.clear();
self.layout_animations_requested.set(false);
self.layout_animations_active.set(false);
self.layout_animation_curve_override.set(None);
self.layout_animation_style_override.set(None);
self.suppress_animations_for_next_layout.set(false);
self.render_ctx_watchers.clear(); self.render_ctx_watchers.clear();
self.workspace_watchers.clear(); self.workspace_watchers.clear();
self.toplevel_lists.clear(); self.toplevel_lists.clear();
@ -1461,6 +1584,532 @@ impl State {
self.eng.now().msec() self.eng.now().msec()
} }
pub fn queue_tiled_animation(
self: &Rc<Self>,
node_id: NodeId,
old: Rect,
new: Rect,
) {
let curve = self
.layout_animation_curve_override
.get()
.unwrap_or_else(|| self.animations.curve.get());
self.queue_layout_animation(
node_id,
old,
new,
curve,
MultiphaseWindowHierarchy::default(),
);
}
pub fn queue_tiled_animation_with_hierarchy(
self: &Rc<Self>,
node_id: NodeId,
old: Rect,
new: Rect,
hierarchy: MultiphaseWindowHierarchy,
) {
let curve = self
.layout_animation_curve_override
.get()
.unwrap_or_else(|| self.animations.curve.get());
self.queue_layout_animation(node_id, old, new, curve, hierarchy);
}
pub fn queue_linear_layout_animation(
self: &Rc<Self>,
node_id: NodeId,
old: Rect,
new: Rect,
) {
self.queue_layout_animation(
node_id,
old,
new,
AnimationCurve::Linear,
MultiphaseWindowHierarchy::default(),
);
}
fn queue_layout_animation(
self: &Rc<Self>,
node_id: NodeId,
old: Rect,
new: Rect,
curve: AnimationCurve,
hierarchy: MultiphaseWindowHierarchy,
) {
if !self.animations.enabled.get()
|| !self.layout_animations_active.get()
|| self.suppress_animations_for_next_layout.get()
{
return;
}
let (old_output, old_scale) = {
let (x, y) = old.center();
let (output, _, _) = self.find_closest_output(x, y);
(output.id, output.global.persistent.scale.get())
};
let (new_output, new_scale) = {
let (x, y) = new.center();
let (output, _, _) = self.find_closest_output(x, y);
(output.id, output.global.persistent.scale.get())
};
if old_output != new_output || old_scale != new_scale {
return;
}
let candidate = LayoutAnimationCandidate {
node_id,
old,
new,
curve,
style: self
.layout_animation_style_override
.get()
.unwrap_or_else(|| self.animations.style.get()),
hierarchy,
};
if let Some(batch) = self.layout_animation_batch.borrow_mut().as_mut() {
batch.push(candidate);
return;
}
self.start_layout_animation_candidate(candidate, self.now_nsec());
}
fn start_layout_animation_candidate(
self: &Rc<Self>,
candidate: LayoutAnimationCandidate,
now_nsec: u64,
) {
let started = self.animations.set_target(
candidate.node_id,
candidate.old,
candidate.new,
None,
now_nsec,
self.animations.duration_ms.get(),
candidate.curve,
);
if started {
self.damage(expand_damage_rect(
candidate.old.union(candidate.new),
self.theme.sizes.border_width.get().max(0),
));
self.ensure_animation_tick();
}
}
pub fn begin_layout_animation_batch(&self) {
self.layout_animation_batch
.borrow_mut()
.get_or_insert_with(Vec::new);
}
pub fn finish_layout_animation_batch(self: &Rc<Self>) {
let Some(candidates) = self.layout_animation_batch.borrow_mut().take() else {
return;
};
let candidates = coalesce_layout_animation_candidates(candidates);
if candidates.is_empty() {
return;
}
let now = self.now_nsec();
let windows: Vec<_> = candidates
.iter()
.map(|candidate| {
MultiphaseWindow::with_hierarchy(
candidate.node_id,
self.animations
.visual_rect(candidate.node_id, candidate.old, now),
candidate.new,
candidate.hierarchy,
)
})
.collect();
for group in partition_motion_groups(&windows, self.layout_animation_clearance()) {
if layout_animation_group_uses_plain(&candidates, &group) {
for idx in group {
self.start_layout_animation_candidate(candidates[idx].clone(), now);
}
continue;
}
if self.start_multiphase_layout_animation(&candidates, &windows, &group, now) {
continue;
}
for idx in group {
self.start_layout_animation_candidate(candidates[idx].clone(), now);
}
}
}
fn layout_animation_clearance(&self) -> i32 {
let border = self.theme.sizes.border_width.get().max(0);
let gap = self.theme.sizes.gap.get().max(0);
if gap == 0 { border } else { gap + 2 * border }
}
fn start_multiphase_layout_animation(
self: &Rc<Self>,
candidates: &[LayoutAnimationCandidate],
windows: &[MultiphaseWindow],
group: &[usize],
now_nsec: u64,
) -> bool {
let request_windows: Vec<_> = group.iter().map(|&idx| windows[idx]).collect();
let Some(first) = request_windows.first() else {
return false;
};
let mut bounds = first.from.union(first.to);
for window in &request_windows[1..] {
bounds = bounds.union(window.from).union(window.to);
}
let request = MultiphaseRequest {
bounds,
windows: request_windows,
clearance: self.layout_animation_clearance(),
};
if self.start_existing_phased_retarget(candidates, windows, group, &request, now_nsec) {
return true;
}
if self.start_bridged_phased_retarget(candidates, windows, group, &request, now_nsec) {
return true;
}
let plan = match plan_no_overlap_with_diagnostics(&request) {
Ok(plan) => plan,
Err(diagnostic) => {
log::debug!(
"falling back to plain layout animation for group {:?}: {:?}",
group,
diagnostic
);
return false;
}
};
self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec)
}
fn start_existing_phased_retarget(
self: &Rc<Self>,
candidates: &[LayoutAnimationCandidate],
windows: &[MultiphaseWindow],
group: &[usize],
request: &MultiphaseRequest,
now_nsec: u64,
) -> bool {
let mut paths = vec![];
for &idx in group {
let candidate = &candidates[idx];
let window = windows[idx];
let Some(path) =
self.animations
.phased_route_to(candidate.node_id, window.to, now_nsec)
else {
return false;
};
paths.push(path);
}
let plan = match validate_phase_paths(request, &paths) {
Ok(plan) => plan,
Err(error) => {
log::debug!(
"existing phased retarget rejected for group {:?}: {:?}",
group,
error
);
return false;
}
};
log::debug!("retargeting active phased animation for group {:?}", group);
self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec)
}
fn start_bridged_phased_retarget(
self: &Rc<Self>,
candidates: &[LayoutAnimationCandidate],
windows: &[MultiphaseWindow],
group: &[usize],
request: &MultiphaseRequest,
now_nsec: u64,
) -> bool {
let mut bridge_paths = vec![];
let mut bridge_phase_count = 0;
let mut has_bridge = false;
for &idx in group {
let candidate = &candidates[idx];
let window = windows[idx];
if window.from == candidate.old {
bridge_paths.push(vec![]);
continue;
}
let Some(path) =
self.animations
.phased_route_to(candidate.node_id, candidate.old, now_nsec)
else {
return false;
};
if !path.is_empty() {
has_bridge = true;
bridge_phase_count = bridge_phase_count.max(path.len());
}
bridge_paths.push(path);
}
if !has_bridge {
return false;
}
let settled_windows: Vec<_> = group
.iter()
.map(|&idx| {
let candidate = &candidates[idx];
MultiphaseWindow::with_hierarchy(
candidate.node_id,
candidate.old,
candidate.new,
candidate.hierarchy,
)
})
.collect();
let Some(first) = settled_windows.first() else {
return false;
};
let mut bounds = first.from.union(first.to);
for window in &settled_windows[1..] {
bounds = bounds.union(window.from).union(window.to);
}
let settled_request = MultiphaseRequest {
bounds,
windows: settled_windows,
clearance: self.layout_animation_clearance(),
};
let follow_plan = match plan_no_overlap_with_diagnostics(&settled_request) {
Ok(plan) => plan,
Err(diagnostic) => {
log::debug!(
"bridged phased retarget follow-up rejected for group {:?}: {:?}",
group,
diagnostic
);
return false;
}
};
let plan = match bridged_retarget_plan(
request,
candidates,
group,
&bridge_paths,
bridge_phase_count,
&follow_plan.phases,
) {
Ok(plan) => plan,
Err(error) => {
log::debug!(
"bridged phased retarget rejected for group {:?}: {:?}",
group,
error
);
return false;
}
};
log::debug!("bridging active phased animation for group {:?}", group);
self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec)
}
fn start_multiphase_plan(
self: &Rc<Self>,
candidates: &[LayoutAnimationCandidate],
windows: &[MultiphaseWindow],
group: &[usize],
plan_phases: &[crate::animation::multiphase::MultiphasePhase],
now_nsec: u64,
) -> bool {
if plan_phases.is_empty() {
return false;
}
let mut entries = vec![];
for &idx in group {
let candidate = &candidates[idx];
let window = windows[idx];
let mut current = window.from;
let mut damage = current.union(window.to);
let mut phases = vec![];
for phase in plan_phases {
match phase
.steps
.iter()
.find(|step| step.node_id == candidate.node_id)
{
Some(step) => {
phases.push((step.from, step.to));
damage = damage.union(step.from).union(step.to);
current = step.to;
}
None => phases.push((current, current)),
}
}
if current != window.to {
return false;
}
entries.push((candidate.clone(), phases, damage));
}
let mut started_any = false;
for (candidate, phases, damage) in entries {
if self.animations.set_phased_target(
candidate.node_id,
phases,
None,
now_nsec,
self.animations.duration_ms.get(),
candidate.curve,
) {
started_any = true;
self.damage(expand_damage_rect(
damage,
self.theme.sizes.border_width.get().max(0),
));
}
}
if started_any {
self.ensure_animation_tick();
}
started_any
}
pub fn queue_spawn_in_animation(
self: &Rc<Self>,
node_id: NodeId,
target: Rect,
) {
if !self.animations.enabled.get() || target.is_empty() {
return;
}
let start = spawn_in_start_rect(target);
let now = self.now_nsec();
let started = self.animations.set_spawn_in(
node_id,
target,
None,
now,
self.animations.duration_ms.get(),
self.animations.curve.get(),
);
if started {
self.damage(expand_damage_rect(
start.union(target),
self.theme.sizes.border_width.get().max(0),
));
self.ensure_animation_tick();
}
}
pub fn queue_spawn_out_animation(
self: &Rc<Self>,
from: Rect,
frame_inset: i32,
retained: Rc<RetainedToplevel>,
active: bool,
layer: RetainedExitLayer,
) {
if !self.animations.enabled.get() || from.is_empty() {
return;
}
let now = self.now_nsec();
let started = self.animations.set_spawn_out(
from,
frame_inset,
retained,
active,
layer,
now,
self.animations.duration_ms.get(),
self.animations.curve.get(),
);
if started {
self.damage(expand_damage_rect(
from,
self.theme.sizes.border_width.get().max(0),
));
self.ensure_animation_tick();
}
}
pub fn set_animations_enabled(&self, enabled: bool) {
if self.animations.enabled.replace(enabled) && !enabled {
self.animations.clear();
self.damage(self.root.extents.get());
}
}
pub fn set_animation_duration_ms(&self, duration_ms: u32) {
self.animations.duration_ms.set(duration_ms);
}
pub fn set_animation_curve(&self, curve: u32) {
self.animations
.curve
.set(AnimationCurve::from_config(curve));
}
pub fn set_animation_style(&self, style: u32) -> bool {
let Some(style) = AnimationStyle::from_config(style) else {
return false;
};
self.animations.style.set(style);
true
}
pub fn set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) -> bool {
let Some(curve) = AnimationCurve::from_cubic_bezier(x1, y1, x2, y2) else {
return false;
};
self.animations.curve.set(curve);
true
}
pub fn with_layout_animations<T>(&self, f: impl FnOnce() -> T) -> T {
let prev_requested = self.layout_animations_requested.replace(true);
let prev_active = self.layout_animations_active.replace(true);
let res = f();
self.layout_animations_requested.set(prev_requested);
self.layout_animations_active.set(prev_active);
res
}
pub fn with_linear_layout_animations<T>(&self, f: impl FnOnce() -> T) -> T {
let prev_requested = self.layout_animations_requested.replace(true);
let prev_active = self.layout_animations_active.replace(true);
let prev_curve = self
.layout_animation_curve_override
.replace(Some(AnimationCurve::Linear));
let prev_style = self
.layout_animation_style_override
.replace(Some(AnimationStyle::Plain));
let res = f();
self.layout_animations_requested.set(prev_requested);
self.layout_animations_active.set(prev_active);
self.layout_animation_curve_override.set(prev_curve);
self.layout_animation_style_override.set(prev_style);
res
}
fn ensure_animation_tick(self: &Rc<Self>) {
if self.animations.tick_is_active() {
return;
}
let outputs: Vec<_> = self.root.outputs.lock().values().cloned().collect();
if outputs.is_empty() {
return;
}
let tick = Rc::new_cyclic(|weak| AnimationTick::new(self, weak));
for output in &outputs {
tick.attach(output);
}
self.animations.set_tick(tick);
for output in &outputs {
self.damage(output.global.pos.get());
}
}
pub fn output_extents_changed(&self) { pub fn output_extents_changed(&self) {
self.root.update_extents(); self.root.update_extents();
for seat in self.globals.seats.lock().values() { for seat in self.globals.seats.lock().values() {
@ -1989,6 +2638,227 @@ impl State {
} }
} }
#[cfg(test)]
mod tests {
use {
super::*,
crate::animation::multiphase::MultiphaseHierarchyPosition,
};
fn rect(x1: i32, y1: i32, x2: i32, y2: i32) -> Rect {
Rect::new_saturating(x1, y1, x2, y2)
}
fn hierarchy(
source: MultiphaseHierarchyPosition,
target: MultiphaseHierarchyPosition,
) -> MultiphaseWindowHierarchy {
MultiphaseWindowHierarchy::new(source, target)
}
fn candidate(node_id: u32, style: AnimationStyle) -> LayoutAnimationCandidate {
candidate_rects(
node_id,
rect(0, 0, 100, 100),
rect(100, 0, 200, 100),
style,
)
}
fn candidate_rects(
node_id: u32,
old: Rect,
new: Rect,
style: AnimationStyle,
) -> LayoutAnimationCandidate {
LayoutAnimationCandidate {
node_id: NodeId(node_id),
old,
new,
curve: AnimationCurve::Linear,
style,
hierarchy: MultiphaseWindowHierarchy::default(),
}
}
#[test]
fn plain_style_candidate_forces_group_plain() {
let candidates = vec![
candidate(1, AnimationStyle::Multiphase),
candidate(2, AnimationStyle::Plain),
];
assert!(!layout_animation_group_uses_plain(&candidates, &[0]));
assert!(layout_animation_group_uses_plain(&candidates, &[0, 1]));
}
#[test]
fn bridged_retarget_handles_second_rotation_interrupt() {
let a_left = rect(0, 0, 100, 100);
let c_mid = rect(100, 0, 200, 100);
let c_left = a_left;
let a_mid = c_mid;
let c_current = rect(150, 50, 250, 100);
let c_mid_lane = rect(100, 50, 200, 100);
let candidates = vec![
candidate_rects(1, a_left, a_mid, AnimationStyle::Multiphase),
candidate_rects(3, c_mid, c_left, AnimationStyle::Multiphase),
];
let request = MultiphaseRequest {
bounds: rect(0, 0, 250, 100),
windows: vec![
MultiphaseWindow::new(NodeId(1), a_left, a_mid),
MultiphaseWindow::new(NodeId(3), c_current, c_left),
],
clearance: 0,
};
let settled_request = MultiphaseRequest {
bounds: rect(0, 0, 200, 100),
windows: vec![
MultiphaseWindow::new(NodeId(1), a_left, a_mid),
MultiphaseWindow::new(NodeId(3), c_mid, c_left),
],
clearance: 0,
};
let follow_plan = plan_no_overlap_with_diagnostics(&settled_request).unwrap();
let bridge_paths = vec![vec![], vec![(c_current, c_mid_lane), (c_mid_lane, c_mid)]];
let plan = bridged_retarget_plan(
&request,
&candidates,
&[0, 1],
&bridge_paths,
2,
&follow_plan.phases,
)
.unwrap();
assert!(plan
.phases
.iter()
.any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(1))));
assert!(plan
.phases
.iter()
.any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(3))));
}
#[test]
fn layout_animation_candidates_coalesce_duplicate_nodes() {
let source = MultiphaseHierarchyPosition {
parent: Some(NodeId(10)),
depth: 2,
sibling_index: Some(1),
..Default::default()
};
let intermediate = MultiphaseHierarchyPosition {
parent: Some(NodeId(11)),
depth: 1,
sibling_index: Some(0),
..Default::default()
};
let target = MultiphaseHierarchyPosition {
parent: Some(NodeId(12)),
depth: 0,
sibling_index: Some(2),
..Default::default()
};
let second_source = MultiphaseHierarchyPosition {
parent: Some(NodeId(20)),
depth: 1,
sibling_index: Some(0),
..Default::default()
};
let second_target = MultiphaseHierarchyPosition {
parent: Some(NodeId(20)),
depth: 1,
sibling_index: Some(1),
..Default::default()
};
let candidates = vec![
LayoutAnimationCandidate {
node_id: NodeId(1),
old: rect(0, 0, 100, 100),
new: rect(0, 0, 80, 100),
curve: AnimationCurve::Linear,
style: AnimationStyle::Multiphase,
hierarchy: hierarchy(source, intermediate),
},
LayoutAnimationCandidate {
node_id: NodeId(2),
old: rect(100, 0, 200, 100),
new: rect(120, 0, 220, 100),
curve: AnimationCurve::Linear,
style: AnimationStyle::Multiphase,
hierarchy: hierarchy(second_source, second_target),
},
LayoutAnimationCandidate {
node_id: NodeId(1),
old: rect(0, 0, 80, 100),
new: rect(0, 0, 60, 100),
curve: AnimationCurve::from_config(4),
style: AnimationStyle::Plain,
hierarchy: hierarchy(intermediate, target),
},
];
let merged = coalesce_layout_animation_candidates(candidates);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].node_id, NodeId(1));
assert_eq!(merged[0].old, rect(0, 0, 100, 100));
assert_eq!(merged[0].new, rect(0, 0, 60, 100));
assert_eq!(merged[0].curve, AnimationCurve::from_config(4));
assert_eq!(merged[0].style, AnimationStyle::Plain);
assert_eq!(merged[0].hierarchy, hierarchy(source, target));
assert_eq!(merged[1].node_id, NodeId(2));
assert_eq!(merged[1].old, rect(100, 0, 200, 100));
assert_eq!(merged[1].new, rect(120, 0, 220, 100));
assert_eq!(merged[1].hierarchy, hierarchy(second_source, second_target));
}
#[test]
fn layout_animation_candidates_keep_coalesced_layout_noops() {
let hierarchy = MultiphaseWindowHierarchy::default();
let candidates = vec![
LayoutAnimationCandidate {
node_id: NodeId(1),
old: rect(0, 0, 100, 100),
new: rect(0, 0, 80, 100),
curve: AnimationCurve::Linear,
style: AnimationStyle::Multiphase,
hierarchy,
},
LayoutAnimationCandidate {
node_id: NodeId(1),
old: rect(0, 0, 80, 100),
new: rect(0, 0, 100, 100),
curve: AnimationCurve::Linear,
style: AnimationStyle::Plain,
hierarchy,
},
LayoutAnimationCandidate {
node_id: NodeId(2),
old: rect(100, 0, 200, 100),
new: rect(120, 0, 220, 100),
curve: AnimationCurve::Linear,
style: AnimationStyle::Multiphase,
hierarchy,
},
];
let merged = coalesce_layout_animation_candidates(candidates);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].node_id, NodeId(1));
assert_eq!(merged[0].old, rect(0, 0, 100, 100));
assert_eq!(merged[0].new, rect(0, 0, 100, 100));
assert_eq!(merged[0].style, AnimationStyle::Plain);
assert_eq!(merged[1].node_id, NodeId(2));
}
}
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ShmScreencopyError { pub enum ShmScreencopyError {
#[error("There is no render context")] #[error("There is no render context")]

View file

@ -131,6 +131,8 @@ pub struct ContainerNode {
pub content_height: Cell<i32>, pub content_height: Cell<i32>,
pub sum_factors: Cell<f64>, pub sum_factors: Cell<f64>,
pub layout_scheduled: Cell<bool>, pub layout_scheduled: Cell<bool>,
animate_next_layout: Cell<bool>,
pub mono_transition_animation_pending: Cell<bool>,
compute_render_positions_scheduled: Cell<bool>, compute_render_positions_scheduled: Cell<bool>,
num_children: NumCell<usize>, num_children: NumCell<usize>,
pub children: LinkedList<ContainerChild>, pub children: LinkedList<ContainerChild>,
@ -238,6 +240,8 @@ impl ContainerNode {
content_height: Cell::new(0), content_height: Cell::new(0),
sum_factors: Cell::new(1.0), sum_factors: Cell::new(1.0),
layout_scheduled: Cell::new(false), layout_scheduled: Cell::new(false),
animate_next_layout: Cell::new(false),
mono_transition_animation_pending: Cell::new(false),
compute_render_positions_scheduled: Cell::new(false), compute_render_positions_scheduled: Cell::new(false),
num_children: NumCell::new(1), num_children: NumCell::new(1),
children, children,
@ -436,6 +440,10 @@ impl ContainerNode {
} }
fn schedule_layout(self: &Rc<Self>) { fn schedule_layout(self: &Rc<Self>) {
if self.state.layout_animations_requested.get() || self.state.layout_animations_active.get()
{
self.animate_next_layout.set(true);
}
if !self.layout_scheduled.replace(true) { if !self.layout_scheduled.replace(true) {
self.state.pending_container_layout.push(self.clone()); self.state.pending_container_layout.push(self.clone());
} }
@ -467,6 +475,7 @@ impl ContainerNode {
fn perform_layout(self: &Rc<Self>) { fn perform_layout(self: &Rc<Self>) {
self.layout_scheduled.set(false); self.layout_scheduled.set(false);
if self.num_children.get() == 0 { if self.num_children.get() == 0 {
self.mono_transition_animation_pending.set(false);
return; return;
} }
if let Some(child) = self.mono_child.get() { if let Some(child) = self.mono_child.get() {
@ -484,6 +493,7 @@ impl ContainerNode {
self.damage(); self.damage();
} }
} }
self.mono_transition_animation_pending.set(false);
} }
fn perform_mono_layout(self: &Rc<Self>, child: &ContainerChild) { fn perform_mono_layout(self: &Rc<Self>, child: &ContainerChild) {
@ -656,6 +666,7 @@ impl ContainerNode {
op.child.factor.set(child_factor); op.child.factor.set(child_factor);
self.sum_factors.set(sum_factors); self.sum_factors.set(sum_factors);
// log::info!("pointer_move"); // log::info!("pointer_move");
self.state.suppress_animations_for_next_layout.set(true);
self.schedule_layout_immediate(); self.schedule_layout_immediate();
} }
} }
@ -816,6 +827,7 @@ impl ContainerNode {
} }
} }
self.mono_child.set(child.clone()); self.mono_child.set(child.clone());
self.mono_transition_animation_pending.set(true);
if child.is_some() { if child.is_some() {
self.rebuild_tab_bar(); self.rebuild_tab_bar();
} else { } else {
@ -1759,10 +1771,42 @@ enum SeatOpKind {
pub async fn container_layout(state: Rc<State>) { pub async fn container_layout(state: Rc<State>) {
loop { loop {
let container = state.pending_container_layout.pop().await; let first = state.pending_container_layout.pop().await;
if container.layout_scheduled.get() { let mut containers = vec![first];
container.perform_layout(); while let Some(container) = state.pending_container_layout.try_pop() {
containers.push(container);
} }
let mut animated = vec![];
let mut immediate = vec![];
for container in containers {
if !container.layout_scheduled.get() {
continue;
}
let animate = container.animate_next_layout.replace(false)
&& !state.suppress_animations_for_next_layout.get();
if animate {
animated.push(container);
} else {
immediate.push(container);
}
}
if !animated.is_empty() {
let prev_active = state.layout_animations_active.replace(true);
state.begin_layout_animation_batch();
for container in animated {
container.perform_layout();
}
state.finish_layout_animation_batch();
state.layout_animations_active.set(prev_active);
}
if !immediate.is_empty() {
let prev_active = state.layout_animations_active.replace(false);
for container in immediate {
container.perform_layout();
}
state.layout_animations_active.set(prev_active);
}
state.suppress_animations_for_next_layout.set(false);
} }
} }
@ -2259,6 +2303,11 @@ impl ContainingNode for ContainerNode {
} }
// log::info!("cnode_remove_child2"); // log::info!("cnode_remove_child2");
self.rebuild_tab_bar(); self.rebuild_tab_bar();
if self.state.animations.enabled.get()
&& !self.state.suppress_animations_for_next_layout.get()
{
self.animate_next_layout.set(true);
}
self.schedule_layout(); self.schedule_layout();
self.cancel_seat_ops(); self.cancel_seat_ops();
self.child_removed.trigger(); self.child_removed.trigger();

View file

@ -31,6 +31,9 @@ use {
}; };
tree_id!(FloatNodeId); tree_id!(FloatNodeId);
const COMMAND_MOVE_DELTA: i32 = 100;
pub struct FloatNode { pub struct FloatNode {
pub id: FloatNodeId, pub id: FloatNodeId,
pub state: Rc<State>, pub state: Rc<State>,
@ -153,6 +156,13 @@ impl FloatNode {
_ => return, _ => return,
}; };
let pos = self.position.get(); let pos = self.position.get();
let spawn_in_pending = {
let data = child.tl_data();
data.spawn_in_pending.get() && data.kind.is_app_window() && !data.is_fullscreen.get()
};
if spawn_in_pending && self.visible.get() {
self.state.queue_spawn_in_animation(self.id.into(), pos);
}
let theme = &self.state.theme; let theme = &self.state.theme;
let bw = theme.sizes.border_width.get(); let bw = theme.sizes.border_width.get();
let cpos = Rect::new_sized_saturating( let cpos = Rect::new_sized_saturating(
@ -363,6 +373,50 @@ impl FloatNode {
y2 += y1 - pos.y1(); y2 += y1 - pos.y1();
} }
let new_pos = Rect::new_saturating(x1, y1, x2, y2); let new_pos = Rect::new_saturating(x1, y1, x2, y2);
self.set_position(new_pos);
}
pub fn move_by_direction(self: &Rc<Self>, direction: Direction) {
let (dx, dy) = match direction {
Direction::Left => (-COMMAND_MOVE_DELTA, 0),
Direction::Down => (0, COMMAND_MOVE_DELTA),
Direction::Up => (0, -COMMAND_MOVE_DELTA),
Direction::Right => (COMMAND_MOVE_DELTA, 0),
Direction::Unspecified => return,
};
self.set_position(self.position.get().move_(dx, dy));
}
fn body_for_outer(&self, outer: Rect) -> Rect {
let bw = self.state.theme.sizes.border_width.get();
Rect::new_sized_saturating(
outer.x1() + bw,
outer.y1() + bw,
outer.width() - 2 * bw,
outer.height() - 2 * bw,
)
}
fn queue_position_animation(&self, old_pos: Rect, new_pos: Rect) {
self.state
.clone()
.queue_tiled_animation(self.id.into(), old_pos, new_pos);
let Some(child) = self.child.get() else {
return;
};
self.state.clone().queue_tiled_animation(
child.node_id(),
self.body_for_outer(old_pos),
self.body_for_outer(new_pos),
);
}
fn set_position(self: &Rc<Self>, new_pos: Rect) {
let pos = self.position.get();
if new_pos == pos {
return;
}
self.queue_position_animation(pos, new_pos);
self.position.set(new_pos); self.position.set(new_pos);
if self.visible.get() { if self.visible.get() {
self.state.damage(pos); self.state.damage(pos);
@ -791,13 +845,7 @@ impl ContainingNode for FloatNode {
let bw = theme.sizes.border_width.get(); let bw = theme.sizes.border_width.get();
let (x, y) = (x - bw, y - bw); let (x, y) = (x - bw, y - bw);
let pos = self.position.get(); let pos = self.position.get();
if pos.position() != (x, y) { self.set_position(pos.at_point(x, y));
let new_pos = pos.at_point(x, y);
self.position.set(new_pos);
self.state.damage(pos);
self.state.damage(new_pos);
self.schedule_layout();
}
} }
fn cnode_resize_child( fn cnode_resize_child(
@ -828,14 +876,7 @@ impl ContainingNode for FloatNode {
y2 = (v + bw).max(y1 + bw + bw); y2 = (v + bw).max(y1 + bw + bw);
} }
let new_pos = Rect::new_saturating(x1, y1, x2, y2); let new_pos = Rect::new_saturating(x1, y1, x2, y2);
if new_pos != pos { self.set_position(new_pos);
self.position.set(new_pos);
if self.visible.get() {
self.state.damage(pos);
self.state.damage(new_pos);
}
self.schedule_layout();
}
} }
fn cnode_pinned(&self) -> bool { fn cnode_pinned(&self) -> bool {

View file

@ -1,5 +1,12 @@
use { use {
crate::{ crate::{
animation::{
RetainedExitLayer, RetainedToplevel,
multiphase::{
MultiphaseHierarchyPosition, MultiphaseHierarchyTransition,
MultiphaseWindowHierarchy, PhaseAxis,
},
},
client::{Client, ClientId}, client::{Client, ClientId},
criteria::{ criteria::{
CritDestroyListener, CritMatcherId, CritDestroyListener, CritMatcherId,
@ -117,6 +124,7 @@ impl<T: ToplevelNodeBase> ToplevelNode for T {
let parent_was_none = data.parent.set(Some(parent.clone())).is_none(); let parent_was_none = data.parent.set(Some(parent.clone())).is_none();
if parent_was_none { if parent_was_none {
data.mapped_during_iteration.set(data.state.eng.iteration()); data.mapped_during_iteration.set(data.state.eng.iteration());
data.spawn_in_pending.set(data.kind.is_app_window());
data.property_changed(TL_CHANGED_NEW); data.property_changed(TL_CHANGED_NEW);
} }
let was_floating = data.parent_is_float.get(); let was_floating = data.parent_is_float.get();
@ -184,6 +192,57 @@ impl<T: ToplevelNodeBase> ToplevelNode for T {
fn tl_change_extents(self: Rc<Self>, rect: &Rect) { fn tl_change_extents(self: Rc<Self>, rect: &Rect) {
let data = self.tl_data(); let data = self.tl_data();
let prev = data.desired_extents.replace(*rect); let prev = data.desired_extents.replace(*rect);
let target_hierarchy = self.tl_multiphase_hierarchy_position();
let hierarchy = MultiphaseWindowHierarchy::new(
data.layout_animation_position.replace(target_hierarchy),
target_hierarchy,
);
let spawn_in_pending = data.spawn_in_pending.get();
let spawn_in_eligible = spawn_in_pending
&& !rect.is_empty()
&& data.visible.get()
&& !data.is_fullscreen.get()
&& data.kind.is_app_window()
&& !self.node_is_container();
let parent_container = data
.parent
.get()
.and_then(|parent| parent.node_into_container());
let parent_is_mono = parent_container
.as_ref()
.is_some_and(|container| container.mono_child.is_some());
let parent_mono_transition = parent_container
.as_ref()
.is_some_and(|container| container.mono_transition_animation_pending.get());
let active_mono_boundary = matches!(
hierarchy.transition,
MultiphaseHierarchyTransition::EnteringMono
| MultiphaseHierarchyTransition::ExitingMono
) && parent_mono_transition
&& (hierarchy.source.mono_active || hierarchy.target.mono_active);
if prev != *rect
&& !prev.is_empty()
&& !rect.is_empty()
&& data.visible.get()
&& !data.parent_is_float.get()
&& !self.node_is_container()
&& (!parent_is_mono || active_mono_boundary)
{
data.state.clone().queue_tiled_animation_with_hierarchy(
data.node_id,
prev,
*rect,
hierarchy,
);
}
if spawn_in_eligible {
data.state
.clone()
.queue_spawn_in_animation(data.node_id, *rect);
}
if spawn_in_eligible {
data.spawn_in_pending.set(false);
}
if prev.size() != rect.size() { if prev.size() != rect.size() {
for sc in data.jay_screencasts.lock().values() { for sc in data.jay_screencasts.lock().values() {
sc.schedule_realloc_or_reconfigure(); sc.schedule_realloc_or_reconfigure();
@ -275,6 +334,35 @@ pub trait ToplevelNodeBase: Node {
true true
} }
fn tl_multiphase_hierarchy_position(&self) -> MultiphaseHierarchyPosition {
let data = self.tl_data();
let Some(parent) = data.parent.get() else {
return Default::default();
};
let mut position = MultiphaseHierarchyPosition {
parent: Some(parent.node_id()),
..Default::default()
};
populate_multiphase_ancestor_splits(&mut position, Some(parent.clone()));
if let Some(container) = parent.node_into_container() {
position.split_axis = Some(match container.split.get() {
ContainerSplit::Horizontal => PhaseAxis::Horizontal,
ContainerSplit::Vertical => PhaseAxis::Vertical,
});
if let Some(mono) = container.mono_child.get() {
position.parent_is_mono = true;
position.mono_active = mono.node.node_id() == data.node_id;
}
for (idx, child) in container.children.iter().enumerate() {
if child.node.node_id() == data.node_id {
position.sibling_index = Some(idx.min(u16::MAX as usize) as u16);
break;
}
}
}
position
}
fn tl_set_active(&self, active: bool) { fn tl_set_active(&self, active: bool) {
let _ = active; let _ = active;
} }
@ -299,6 +387,11 @@ pub trait ToplevelNodeBase: Node {
fn tl_scanout_surface(&self) -> Option<Rc<WlSurface>> { fn tl_scanout_surface(&self) -> Option<Rc<WlSurface>> {
None None
} }
fn tl_animation_snapshot(&self) -> Option<Rc<RetainedToplevel>> {
None
}
fn tl_restack_popups(&self) { fn tl_restack_popups(&self) {
// nothing // nothing
} }
@ -339,6 +432,31 @@ pub trait ToplevelNodeBase: Node {
} }
} }
fn populate_multiphase_ancestor_splits(
position: &mut MultiphaseHierarchyPosition,
mut parent: Option<Rc<dyn ContainingNode>>,
) {
let mut depth = 0u16;
while let Some(node) = parent {
let Some(toplevel) = node.clone().node_into_toplevel() else {
break;
};
depth = depth.saturating_add(1);
if let Some(container) = node.node_into_container() {
match container.split.get() {
ContainerSplit::Horizontal => {
position.nearest_horizontal_split_depth.get_or_insert(depth);
}
ContainerSplit::Vertical => {
position.nearest_vertical_split_depth.get_or_insert(depth);
}
}
}
parent = toplevel.tl_data().parent.get();
}
position.depth = depth;
}
pub struct FullscreenedData { pub struct FullscreenedData {
pub placeholder: Rc<PlaceholderNode>, pub placeholder: Rc<PlaceholderNode>,
pub workspace: Rc<WorkspaceNode>, pub workspace: Rc<WorkspaceNode>,
@ -377,6 +495,13 @@ impl ToplevelType {
ToplevelType::XWindow { .. } => window::X_WINDOW, ToplevelType::XWindow { .. } => window::X_WINDOW,
} }
} }
pub fn is_app_window(&self) -> bool {
matches!(
self,
ToplevelType::XdgToplevel(_) | ToplevelType::XWindow(_)
)
}
} }
pub struct ToplevelData { pub struct ToplevelData {
@ -399,8 +524,10 @@ pub struct ToplevelData {
pub title: RefCell<String>, pub title: RefCell<String>,
pub parent: CloneCell<Option<Rc<dyn ContainingNode>>>, pub parent: CloneCell<Option<Rc<dyn ContainingNode>>>,
pub mapped_during_iteration: Cell<u64>, pub mapped_during_iteration: Cell<u64>,
pub spawn_in_pending: Cell<bool>,
pub pos: Cell<Rect>, pub pos: Cell<Rect>,
pub desired_extents: Cell<Rect>, pub desired_extents: Cell<Rect>,
pub layout_animation_position: Cell<MultiphaseHierarchyPosition>,
pub seat_state: NodeSeatState, pub seat_state: NodeSeatState,
pub wants_attention: Cell<bool>, pub wants_attention: Cell<bool>,
pub requested_attention: Cell<bool>, pub requested_attention: Cell<bool>,
@ -462,8 +589,10 @@ impl ToplevelData {
title: RefCell::new(title), title: RefCell::new(title),
parent: Default::default(), parent: Default::default(),
mapped_during_iteration: Cell::new(0), mapped_during_iteration: Cell::new(0),
spawn_in_pending: Cell::new(false),
pos: Default::default(), pos: Default::default(),
desired_extents: Default::default(), desired_extents: Default::default(),
layout_animation_position: Default::default(),
seat_state: Default::default(), seat_state: Default::default(),
wants_attention: Cell::new(false), wants_attention: Cell::new(false),
requested_attention: Cell::new(false), requested_attention: Cell::new(false),
@ -935,6 +1064,62 @@ impl ToplevelData {
self.mapped_during_iteration.get() == self.state.eng.iteration() self.mapped_during_iteration.get() == self.state.eng.iteration()
} }
pub fn queue_spawn_out(&self, node: &dyn ToplevelNode, retained: Option<Rc<RetainedToplevel>>) {
if !self.kind.is_app_window()
|| !self.visible.get()
|| self.is_fullscreen.get()
|| node.node_is_container()
{
return;
}
let Some(retained) = retained else {
return;
};
let bw = self.state.theme.sizes.border_width.get().max(0);
let now = self.state.now_nsec();
let (outer, frame_inset, layer) = if self.parent_is_float.get() {
let Some(float) = self.float.get() else {
return;
};
(
self.state
.animations
.visual_rect(float.node_id(), float.position.get(), now),
bw,
RetainedExitLayer::Floating,
)
} else {
let body =
self.state
.animations
.visual_rect(self.node_id, node.node_absolute_position(), now);
if body.is_empty() {
return;
}
if self.state.theme.sizes.gap.get() != 0 {
(
Rect::new_sized_saturating(
body.x1() - bw,
body.y1() - bw,
body.width() + 2 * bw,
body.height() + 2 * bw,
),
bw,
RetainedExitLayer::Tiled,
)
} else {
(body, 0, RetainedExitLayer::Tiled)
}
};
self.state.clone().queue_spawn_out_animation(
outer,
frame_inset,
retained,
self.active(),
layer,
);
}
pub fn set_content_type(&self, content_type: Option<ContentType>) { pub fn set_content_type(&self, content_type: Option<ContentType>) {
if self.content_type.replace(content_type) != content_type { if self.content_type.replace(content_type) != content_type {
self.property_changed(TL_CHANGED_CONTENT_TY); self.property_changed(TL_CHANGED_CONTENT_TY);
@ -1043,6 +1228,26 @@ pub fn toplevel_create_split(state: &Rc<State>, tl: Rc<dyn ToplevelNode>, axis:
} }
} }
fn float_outer_for_body(state: &State, body: Rect) -> Rect {
let bw = state.theme.sizes.border_width.get();
Rect::new_sized_saturating(
body.x1() - bw,
body.y1() - bw,
body.width() + 2 * bw,
body.height() + 2 * bw,
)
}
fn float_body_for_outer(state: &State, outer: Rect) -> Rect {
let bw = state.theme.sizes.border_width.get();
Rect::new_sized_saturating(
outer.x1() + bw,
outer.y1() + bw,
outer.width() - 2 * bw,
outer.height() - 2 * bw,
)
}
pub fn toplevel_set_floating(state: &Rc<State>, tl: Rc<dyn ToplevelNode>, floating: bool) { pub fn toplevel_set_floating(state: &Rc<State>, tl: Rc<dyn ToplevelNode>, floating: bool) {
let data = tl.tl_data(); let data = tl.tl_data();
if data.is_fullscreen.get() { if data.is_fullscreen.get() {
@ -1059,9 +1264,19 @@ pub fn toplevel_set_floating(state: &Rc<State>, tl: Rc<dyn ToplevelNode>, floati
parent.cnode_remove_child2(&*tl, true); parent.cnode_remove_child2(&*tl, true);
state.map_tiled(tl); state.map_tiled(tl);
} else if let Some(ws) = data.workspace.get() { } else if let Some(ws) = data.workspace.get() {
let node_id = data.node_id;
let old_body =
state
.animations
.visual_rect(node_id, tl.node_absolute_position(), state.now_nsec());
let old_outer = float_outer_for_body(state, old_body);
parent.cnode_remove_child2(&*tl, true); parent.cnode_remove_child2(&*tl, true);
let (width, height) = data.float_size(&ws); let (width, height) = data.float_size(&ws);
state.map_floating(tl, width, height, &ws, None); let floater = state.map_floating(tl, width, height, &ws, None);
let new_outer = floater.position.get();
let new_body = float_body_for_outer(state, new_outer);
state.queue_linear_layout_animation(floater.node_id(), old_outer, new_outer);
state.queue_linear_layout_animation(node_id, old_body, new_body);
} }
} }

View file

@ -197,10 +197,10 @@ impl WorkspaceNode {
} }
self.pull_child_properties(&**container); self.pull_child_properties(&**container);
let pos = self.position.get(); let pos = self.position.get();
container.clone().tl_change_extents(&pos);
container.tl_set_parent(self.clone()); container.tl_set_parent(self.clone());
container.tl_set_visible(self.container_visible()); container.tl_set_visible(self.container_visible());
self.container.set(Some(container.clone())); self.container.set(Some(container.clone()));
container.clone().tl_change_extents(&pos);
self.state.damage(self.position.get()); self.state.damage(self.position.get());
} }

View file

@ -2034,6 +2034,7 @@ impl Wm {
self.windows_by_surface_serial.remove(&serial); self.windows_by_surface_serial.remove(&serial);
} }
if let Some(window) = data.window.take() { if let Some(window) = data.window.take() {
window.queue_spawn_out();
window.destroy(); window.destroy();
} }
if let Some(parent) = data.parent.take() { if let Some(parent) = data.parent.take() {

View file

@ -266,6 +266,20 @@ pub struct UiDrag {
pub threshold: Option<i32>, pub threshold: Option<i32>,
} }
#[derive(Debug, Clone, Default)]
pub struct Animations {
pub enabled: Option<bool>,
pub duration_ms: Option<u32>,
pub style: Option<String>,
pub curve: Option<AnimationCurveConfig>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AnimationCurveConfig {
Preset(String),
CubicBezier([f32; 4]),
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum OutputMatch { pub enum OutputMatch {
Any(Vec<OutputMatch>), Any(Vec<OutputMatch>),
@ -567,6 +581,7 @@ pub struct Config {
pub tearing: Option<Tearing>, pub tearing: Option<Tearing>,
pub libei: Libei, pub libei: Libei,
pub ui_drag: UiDrag, pub ui_drag: UiDrag,
pub animations: Animations,
pub xwayland: Option<Xwayland>, pub xwayland: Option<Xwayland>,
pub color_management: Option<ColorManagement>, pub color_management: Option<ColorManagement>,
pub float: Option<Float>, pub float: Option<Float>,
@ -651,3 +666,26 @@ fn default_config_parses() {
let input = include_bytes!("default-config.toml"); let input = include_bytes!("default-config.toml");
parse_config(input, &Default::default(), |_| ()).unwrap(); parse_config(input, &Default::default(), |_| ()).unwrap();
} }
#[test]
fn custom_animation_curve_parses() {
let input = b"
[animations]
curve = [0.25, 0.1, 0.25, 1.0]
";
let config = parse_config(input, &Default::default(), |_| ()).unwrap();
assert_eq!(
config.animations.curve,
Some(AnimationCurveConfig::CubicBezier([0.25, 0.1, 0.25, 1.0]))
);
}
#[test]
fn animation_style_parses() {
let input = b"
[animations]
style = \"plain\"
";
let config = parse_config(input, &Default::default(), |_| ()).unwrap();
assert_eq!(config.animations.style.as_deref(), Some("plain"));
}

View file

@ -8,6 +8,7 @@ use {
pub mod action; pub mod action;
mod actions; mod actions;
mod animations;
mod capabilities; mod capabilities;
mod clean_logs_older_than; mod clean_logs_older_than;
mod client_match; mod client_match;

View file

@ -0,0 +1,99 @@
use {
crate::{
config::{
AnimationCurveConfig, Animations,
context::Context,
extractor::{Extractor, ExtractorError, bol, n32, opt, recover, str, val},
parser::{DataType, ParseResult, Parser, UnexpectedDataType},
},
toml::{
toml_span::{DespanExt, Span, Spanned, SpannedExt},
toml_value::Value,
},
},
indexmap::IndexMap,
thiserror::Error,
};
#[derive(Debug, Error)]
pub enum AnimationsParserError {
#[error(transparent)]
Expected(#[from] UnexpectedDataType),
#[error(transparent)]
Extract(#[from] ExtractorError),
#[error("Expected animation curve to be a string or an array")]
CurveType,
#[error("Cubic-bezier animation curves must contain exactly four values")]
CubicBezierLen,
#[error("Cubic-bezier animation curve entries must be finite floats or integers")]
CubicBezierValue,
#[error("Cubic-bezier x control points must be between 0 and 1")]
CubicBezierXRange,
}
pub struct AnimationsParser<'a>(pub &'a Context<'a>);
impl Parser for AnimationsParser<'_> {
type Value = Animations;
type Error = AnimationsParserError;
const EXPECTED: &'static [DataType] = &[DataType::Table];
fn parse_table(
&mut self,
span: Span,
table: &IndexMap<Spanned<String>, Spanned<Value>>,
) -> ParseResult<Self> {
let mut ext = Extractor::new(self.0, span, table);
let (enabled, duration_ms, style, curve) = ext.extract((
recover(opt(bol("enabled"))),
recover(opt(n32("duration-ms"))),
recover(opt(str("style"))),
opt(val("curve")),
))?;
let curve = match curve {
Some(curve) => Some(parse_curve(curve)?),
None => None,
};
Ok(Animations {
enabled: enabled.despan(),
duration_ms: duration_ms.despan(),
style: style.despan().map(|style| style.to_string()),
curve,
})
}
}
fn parse_curve(
curve: Spanned<&Value>,
) -> Result<AnimationCurveConfig, Spanned<AnimationsParserError>> {
match curve.value {
Value::String(s) => Ok(AnimationCurveConfig::Preset(s.clone())),
Value::Array(values) => parse_cubic_bezier(curve.span, values),
_ => Err(AnimationsParserError::CurveType.spanned(curve.span)),
}
}
fn parse_cubic_bezier(
span: Span,
values: &[Spanned<Value>],
) -> Result<AnimationCurveConfig, Spanned<AnimationsParserError>> {
if values.len() != 4 {
return Err(AnimationsParserError::CubicBezierLen.spanned(span));
}
let mut points = [0.0; 4];
for (idx, value) in values.iter().enumerate() {
let f = match value.value {
Value::Float(f) => f,
Value::Integer(i) => i as f64,
_ => return Err(AnimationsParserError::CubicBezierValue.spanned(value.span)),
};
if !f.is_finite() {
return Err(AnimationsParserError::CubicBezierValue.spanned(value.span));
}
points[idx] = f as f32;
}
if !(0.0..=1.0).contains(&points[0]) || !(0.0..=1.0).contains(&points[2]) {
return Err(AnimationsParserError::CubicBezierXRange.spanned(span));
}
Ok(AnimationCurveConfig::CubicBezier(points))
}

View file

@ -1,13 +1,14 @@
use { use {
crate::{ crate::{
config::{ config::{
Action, Config, Libei, Theme, UiDrag, Action, Animations, Config, Libei, Theme, UiDrag,
context::Context, context::Context,
extractor::{Extractor, ExtractorError, arr, bol, int, opt, recover, str, val}, extractor::{Extractor, ExtractorError, arr, bol, int, opt, recover, str, val},
parser::{DataType, ParseResult, Parser, UnexpectedDataType}, parser::{DataType, ParseResult, Parser, UnexpectedDataType},
parsers::{ parsers::{
action::ActionParser, action::ActionParser,
actions::ActionsParser, actions::ActionsParser,
animations::AnimationsParser,
clean_logs_older_than::CleanLogsOlderThanParser, clean_logs_older_than::CleanLogsOlderThanParser,
client_rule::ClientRulesParser, client_rule::ClientRulesParser,
color_management::ColorManagementParser, color_management::ColorManagementParser,
@ -153,6 +154,7 @@ impl Parser for ConfigParser<'_> {
fallback_output_mode_val, fallback_output_mode_val,
clean_logs_older_than_val, clean_logs_older_than_val,
mouse_follows_focus, mouse_follows_focus,
animations_val,
), ),
) = ext.extract(( ) = ext.extract((
( (
@ -213,6 +215,7 @@ impl Parser for ConfigParser<'_> {
opt(val("fallback-output-mode")), opt(val("fallback-output-mode")),
opt(val("clean-logs-older-than")), opt(val("clean-logs-older-than")),
recover(opt(bol("unstable-mouse-follows-focus"))), recover(opt(bol("unstable-mouse-follows-focus"))),
opt(val("animations")),
), ),
))?; ))?;
let mut keymap = None; let mut keymap = None;
@ -429,6 +432,15 @@ impl Parser for ConfigParser<'_> {
} }
} }
} }
let mut animations = Animations::default();
if let Some(value) = animations_val {
match value.parse(&mut AnimationsParser(self.0)) {
Ok(v) => animations = v,
Err(e) => {
log::warn!("Could not parse animations setting: {}", self.0.error(e));
}
}
}
let mut xwayland = None; let mut xwayland = None;
if let Some(value) = xwayland_val { if let Some(value) = xwayland_val {
match value.parse(&mut XwaylandParser(self.0)) { match value.parse(&mut XwaylandParser(self.0)) {
@ -587,6 +599,7 @@ impl Parser for ConfigParser<'_> {
tearing, tearing,
libei, libei,
ui_drag, ui_drag,
animations,
xwayland, xwayland,
color_management, color_management,
float, float,

View file

@ -13,9 +13,9 @@ mod toml;
use { use {
crate::{ crate::{
config::{ config::{
Action, ClientRule, Config, ConfigConnector, ConfigDrmDevice, ConfigKeymap, Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice,
ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, OutputMatch, ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output,
SimpleCommand, Status, Theme, WindowRule, parse_config, OutputMatch, SimpleCommand, Status, Theme, WindowRule, parse_config,
}, },
rules::{MatcherTemp, RuleMapper}, rules::{MatcherTemp, RuleMapper},
shortcuts::ModeState, shortcuts::ModeState,
@ -23,7 +23,7 @@ use {
ahash::{AHashMap, AHashSet}, ahash::{AHashMap, AHashSet},
error_reporter::Report, error_reporter::Report,
jay_config::{ jay_config::{
Axis, AnimationCurve, AnimationStyle, Axis,
client::Client, client::Client,
config, config_dir, config, config_dir,
exec::{Command, set_env, unset_env}, exec::{Command, set_env, unset_env},
@ -37,8 +37,10 @@ use {
is_reload, is_reload,
keyboard::Keymap, keyboard::Keymap,
logging::{clean_logs_older_than, set_log_level}, logging::{clean_logs_older_than, set_log_level},
on_devices_enumerated, on_idle, on_unload, quit, reload, set_autotile, on_devices_enumerated, on_idle, on_unload, quit, reload, set_animation_cubic_bezier,
set_color_management_enabled, set_corner_radius, set_default_workspace_capture, set_animation_curve, set_animation_duration_ms, set_animation_style,
set_animations_enabled, set_autotile, set_color_management_enabled, set_corner_radius,
set_default_workspace_capture,
set_explicit_sync_enabled, set_float_above_fullscreen, set_floating_titles, set_idle, set_explicit_sync_enabled, set_float_above_fullscreen, set_floating_titles, set_idle,
set_idle_grace_period, set_middle_click_paste_enabled, set_show_bar, set_idle_grace_period, set_middle_click_paste_enabled, set_show_bar,
set_show_float_pin_icon, set_show_titles, set_tab_title_align, set_ui_drag_enabled, set_show_float_pin_icon, set_show_titles, set_tab_title_align, set_ui_drag_enabled,
@ -1649,6 +1651,38 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc<Persistent
if let Some(threshold) = config.ui_drag.threshold { if let Some(threshold) = config.ui_drag.threshold {
set_ui_drag_threshold(threshold); set_ui_drag_threshold(threshold);
} }
set_animations_enabled(config.animations.enabled.unwrap_or(false));
set_animation_duration_ms(config.animations.duration_ms.unwrap_or(160));
match config.animations.style.as_deref().unwrap_or("multiphase") {
"plain" => set_animation_style(AnimationStyle::PLAIN),
"multiphase" => set_animation_style(AnimationStyle::MULTIPHASE),
style_name => log::warn!("Unknown animation style: {style_name}"),
}
match config
.animations
.curve
.unwrap_or_else(|| AnimationCurveConfig::Preset("ease-out".to_string()))
{
AnimationCurveConfig::Preset(curve_name) => {
let curve = match curve_name.as_str() {
"linear" => Some(AnimationCurve::LINEAR),
"ease" => Some(AnimationCurve::EASE),
"ease-in" => Some(AnimationCurve::EASE_IN),
"ease-out" => Some(AnimationCurve::EASE_OUT),
"ease-in-out" => Some(AnimationCurve::EASE_IN_OUT),
_ => {
log::warn!("Unknown animation curve: {curve_name}");
None
}
};
if let Some(curve) = curve {
set_animation_curve(curve);
}
}
AnimationCurveConfig::CubicBezier([x1, y1, x2, y2]) => {
set_animation_cubic_bezier(x1, y1, x2, y2);
}
}
if let Some(xwayland) = config.xwayland { if let Some(xwayland) = config.xwayland {
if let Some(enabled) = xwayland.enabled { if let Some(enabled) = xwayland.enabled {
set_x_wayland_enabled(enabled); set_x_wayland_enabled(enabled);

View file

@ -641,6 +641,61 @@
} }
] ]
}, },
"AnimationCurve": {
"description": "Describes a window animation curve.\n",
"anyOf": [
{
"type": "string",
"description": "One of the supported curve presets.\n",
"enum": [
"linear",
"ease",
"ease-in",
"ease-out",
"ease-in-out"
]
},
{
"type": "array",
"description": "A custom CSS-style cubic-bezier curve as four numbers:\n`x1`, `y1`, `x2`, and `y2`.\n\nThe implicit endpoints are `(0, 0)` and `(1, 1)`. `x1` and `x2` must\nbe between `0` and `1`.\n",
"items": {
"type": "number",
"description": ""
}
}
]
},
"AnimationStyle": {
"type": "string",
"description": "Describes a tiled window movement animation style.\n",
"enum": [
"plain",
"multiphase"
]
},
"Animations": {
"description": "Describes window animation settings.\n\n- Example:\n\n ```toml\n [animations]\n enabled = true\n duration-ms = 160\n style = \"multiphase\"\n curve = [0.25, 0.1, 0.25, 1.0]\n ```\n",
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enables or disables window animations.\n\nThe default is `false`.\n"
},
"duration-ms": {
"type": "integer",
"description": "Sets the animation duration in milliseconds.\n\nThe default is `160`.\n"
},
"style": {
"description": "Sets the animation style used for tiled window movement animations.\n\nThe default is `multiphase`.\n",
"$ref": "#/$defs/AnimationStyle"
},
"curve": {
"description": "Sets the animation curve.\n\nThe default is `ease-out`.\n",
"$ref": "#/$defs/AnimationCurve"
}
},
"required": []
},
"BarPosition": { "BarPosition": {
"type": "string", "type": "string",
"description": "The position of the bar.", "description": "The position of the bar.",
@ -1085,6 +1140,10 @@
"description": "Configures the ui-drag settings.\n\n- Example:\n\n ```toml\n ui-drag = { enabled = false, threshold = 20 }\n ```\n", "description": "Configures the ui-drag settings.\n\n- Example:\n\n ```toml\n ui-drag = { enabled = false, threshold = 20 }\n ```\n",
"$ref": "#/$defs/UiDrag" "$ref": "#/$defs/UiDrag"
}, },
"animations": {
"description": "Configures window animations.\n\nAnimations are disabled by default.\n\n- Example:\n\n ```toml\n [animations]\n enabled = true\n duration-ms = 160\n style = \"multiphase\"\n curve = \"ease-out\"\n ```\n",
"$ref": "#/$defs/Animations"
},
"xwayland": { "xwayland": {
"description": "Configures the Xwayland settings.\n\n- Example:\n\n ```toml\n xwayland = { scaling-mode = \"downscaled\" }\n ```\n", "description": "Configures the Xwayland settings.\n\n- Example:\n\n ```toml\n xwayland = { scaling-mode = \"downscaled\" }\n ```\n",
"$ref": "#/$defs/Xwayland" "$ref": "#/$defs/Xwayland"

View file

@ -942,6 +942,125 @@ This table is a tagged union. The variant is determined by the `type` field. It
The numbers should be integers. The numbers should be integers.
<a name="types-AnimationCurve"></a>
### `AnimationCurve`
Describes a window animation curve.
Values of this type should have one of the following forms:
#### A string
One of the supported curve presets.
The string should have one of the following values:
- `linear`:
No easing.
- `ease`:
The CSS `ease` curve.
- `ease-in`:
The CSS `ease-in` curve.
- `ease-out`:
The CSS `ease-out` curve.
- `ease-in-out`:
The CSS `ease-in-out` curve.
#### An array
A custom CSS-style cubic-bezier curve as four numbers:
`x1`, `y1`, `x2`, and `y2`.
The implicit endpoints are `(0, 0)` and `(1, 1)`. `x1` and `x2` must
be between `0` and `1`.
Each element of this array should be a number.
<a name="types-AnimationStyle"></a>
### `AnimationStyle`
Describes a tiled window movement animation style.
Values of this type should be strings.
The string should have one of the following values:
- `plain`:
Uses a single interpolated movement from each window's current visual
rectangle to its destination rectangle.
- `multiphase`:
Uses the no-overlap multiphase planner for tiled window movement when a
supported plan exists.
<a name="types-Animations"></a>
### `Animations`
Describes window animation settings.
- Example:
```toml
[animations]
enabled = true
duration-ms = 160
style = "multiphase"
curve = [0.25, 0.1, 0.25, 1.0]
```
Values of this type should be tables.
The table has the following fields:
- `enabled` (optional):
Enables or disables window animations.
The default is `false`.
The value of this field should be a boolean.
- `duration-ms` (optional):
Sets the animation duration in milliseconds.
The default is `160`.
The value of this field should be a number.
The numbers should be integers.
- `style` (optional):
Sets the animation style used for tiled window movement animations.
The default is `multiphase`.
The value of this field should be a [AnimationStyle](#types-AnimationStyle).
- `curve` (optional):
Sets the animation curve.
The default is `ease-out`.
The value of this field should be a [AnimationCurve](#types-AnimationCurve).
<a name="types-BarPosition"></a> <a name="types-BarPosition"></a>
### `BarPosition` ### `BarPosition`
@ -2169,6 +2288,24 @@ The table has the following fields:
The value of this field should be a [UiDrag](#types-UiDrag). The value of this field should be a [UiDrag](#types-UiDrag).
- `animations` (optional):
Configures window animations.
Animations are disabled by default.
- Example:
```toml
[animations]
enabled = true
duration-ms = 160
style = "multiphase"
curve = "ease-out"
```
The value of this field should be a [Animations](#types-Animations).
- `xwayland` (optional): - `xwayland` (optional):
Configures the Xwayland settings. Configures the Xwayland settings.
@ -5670,4 +5807,3 @@ The table has the following fields:
The value of this field should be a [XScalingMode](#types-XScalingMode). The value of this field should be a [XScalingMode](#types-XScalingMode).

View file

@ -2942,6 +2942,23 @@ Config:
```toml ```toml
ui-drag = { enabled = false, threshold = 20 } ui-drag = { enabled = false, threshold = 20 }
``` ```
animations:
ref: Animations
required: false
description: |
Configures window animations.
Animations are disabled by default.
- Example:
```toml
[animations]
enabled = true
duration-ms = 160
style = "multiphase"
curve = "ease-out"
```
xwayland: xwayland:
ref: Xwayland ref: Xwayland
required: false required: false
@ -3655,6 +3672,97 @@ UiDrag:
The default is `10`. The default is `10`.
Animations:
kind: table
description: |
Describes window animation settings.
- Example:
```toml
[animations]
enabled = true
duration-ms = 160
style = "multiphase"
curve = [0.25, 0.1, 0.25, 1.0]
```
fields:
enabled:
kind: boolean
required: false
description: |
Enables or disables window animations.
The default is `false`.
duration-ms:
kind: number
integer_only: true
required: false
description: |
Sets the animation duration in milliseconds.
The default is `160`.
style:
ref: AnimationStyle
required: false
description: |
Sets the animation style used for tiled window movement animations.
The default is `multiphase`.
curve:
ref: AnimationCurve
required: false
description: |
Sets the animation curve.
The default is `ease-out`.
AnimationStyle:
kind: string
description: |
Describes a tiled window movement animation style.
values:
- value: plain
description: |
Uses a single interpolated movement from each window's current visual
rectangle to its destination rectangle.
- value: multiphase
description: |
Uses the no-overlap multiphase planner for tiled window movement when a
supported plan exists.
AnimationCurve:
kind: variable
description: |
Describes a window animation curve.
variants:
- kind: string
description: |
One of the supported curve presets.
values:
- value: linear
description: No easing.
- value: ease
description: The CSS `ease` curve.
- value: ease-in
description: The CSS `ease-in` curve.
- value: ease-out
description: The CSS `ease-out` curve.
- value: ease-in-out
description: The CSS `ease-in-out` curve.
- kind: array
items:
kind: number
description: |
A custom CSS-style cubic-bezier curve as four numbers:
`x1`, `y1`, `x2`, and `y2`.
The implicit endpoints are `(0, 0)` and `(1, 1)`. `x1` and `x2` must
be between `0` and `1`.
Xwayland: Xwayland:
kind: table kind: table
description: | description: |