detached b6be3a8

feat: global spring animation system (#134)

* feat(config): add animations and animations.slowdown keys

* test(vulkan): cover content clipping when the window rect differs from content

* feat(core): spring velocity, value-type motion with slowdown, retarget keeps velocity

* perf(core): keep the Seq of an unchanged scene and drop it in outputs

* feat(core): one creation and one stop path for animations, off lands instantly

* perf(core): advance only the flipped output's springs and keep the pulse value between its flips

* fix(core): use value-type motions in Monitor.settled after rebase on main

* feat(core): camera transitions for binds, click focus and discrete swipes

* test(core): build the swipe rig's timer mock before core runs

* feat(core): per-window rect transitions with a cached shown layout

* docs: describe animations and their cost

* test(core): guard zero allocations in animate

* test: tighten SameAs, headless and config tests; fix animation doc claims

* fix(core): give an animating screen a fresh scene Seq and keep its fallback timer

An animating screen skips the Scene.SameAs Seq reuse, so the first frame
after an action is flipped instead of waiting for the fallback timer. A
flip of one output keeps the fallback timer while another still animates.
Chained workspace keys keep their spring velocity (recorded in the
snapshot), and rect motions of windows out of the layout are dropped.
screen.stopRects joins the clearing sites; settled/isSettled are renamed.

* test(core): cover fresh Seq, fallback timer, chained velocity and transitions

Adds tests for the animating Seq, the fallback timer under another
output's flips, velocity carry-over, click and discrete-swipe transitions,
a following swipe and framed switches. Folds the pulse transition test
into the slide table, merges the moved-workspace tests, strengthens the
overview test, and removes two duplicates.

* feat(vulkan): export multi-plane DCC render targets

probeModifiers keeps modifiers with 1 to 4 memory planes and records the
counts; exportTarget reads every plane layout and gives each plane its own
file. ExportTargets gains a singlePlane argument (ports.Renderer) that
drops multi-plane modifiers, and logs the chosen modifier and plane count.

* feat(drm): retry single-plane images before linear and close every plane

showImages tries driver images, then single-plane driver images, then
linear. exportImages closes every plane file of every buffer and rejects
buffers with more planes than a framebuffer holds.

* fix(drm): skip the single-plane retry when driver images had one plane

Record the plane count of the last exported images; a refused one-plane
driver image goes straight to linear. TestExportTargetsPlanes now reads
each multi-plane target back, checks plane offsets and skips without a
multi-plane modifier. Clamp the plane count once in exportTarget and log
the exported target at Debug.

* perf(core): presize scene windows and separators, pin publish and moving-window draw allocations

* perf(core): guard publish, overview and partial-redraw allocations, reuse damage scratch

* fix(core): stop the placeholder's animations when the last output is unplugged

The last output's screen stays as a placeholder and kept its rect, camera and
workspace springs, so the fallback timer kept waking core for a screen nothing
draws. Also adds session lock and output unplug mid rect motion tests.

* test(core): animations-on variants of the border and old animations-off tests

Border scene invariants with animations on (weak invariants mid-transition, full
ones once settled), On variants that land every spring on a mock clock for the
workspace, drag, state, unplug, free float and workspace-size tests, and an unmap
mid rect motion test.

* test(core): review fixes for the animations-on variants

Restore the default animations setting of the sized-workspace tests, make the on
variants fail when no spring ever ran, extend the last-output unplug test with a
workspace switch spring and a replug, and share one mock clock between the swipe
and landing rigs.

* perf(vulkan): redraw only the changed rects when a scene's windows move

A scene with a new Seq redrew the whole output. The target now holds the
scene it was drawn from; sceneDelta (zero allocations) gives the union of
the old and new rects of each changed window plus the separators present
in only one scene, and everything else differing forces a full redraw.
The headless frame log gains the redrawn pixel count.

* test(vulkan): pin the sceneDelta-seeded partial redraw at zero allocations

TestRenderSteadyStateAllocations replicates Render's partial sequence for a
new scene Seq with one of two windows moved, asserts the redraw is partial
and that building and clipping its draws allocates nothing.

* fix(vulkan): copy the held scene's slices and split full redraws at the floats start

Output adapters reuse their scene slices in place (DRM overlay frames,
capture), so a target holding them by reference compared a mutated old
scene. hold now copies Windows, Separators, Layers, DropHints and
CaptureIndicators into per-target buffers (no allocation once warm).

sceneDelta also redraws everything when the first window that opens the
floats changes, since the tile lines and the Dim veil are painted there.
Tests cover reused slices, fractional scale, a rotated output, scene and
window Dim, and the Scene field count.

* fix(core): chain rect, camera and switch motions from their exact state at the action time

snapshot(now) samples a copy of every running motion at now for value and
speed, and rectShot keeps the settled rect plus the float offsets, so a
retarget no longer starts from the rounded rect or the last frame's speed.
publish(ctx) wraps publishFrame(ctx, only); c.stepping is gone.

* refactor(core): read the clock once per action, test untouched motions of unmoved windows

* feat(scene): add SceneWindow.Fade, drawn by the vulkan walk and never scanned out

A window's Fade (0 opaque, 1 invisible) scales its body fill, its own
border lines, its dim and every content draw by 1-Fade, premultiplied
(solids re-encoded on HDR outputs like solidDraw); a window at Fade 1
draws nothing but still opens the floats. The DRM scanout refuses a
fading or zoomed fullscreen window (window_fade) and the overlay plane a
fading candidate. sceneDelta covers Fade by struct equality (the draw
extent stays inside Rect); a leaving entry appearing or disappearing
changes the list and redraws everything.

* feat(core): fade, dim and scale rect motions, leaving entries and the appear helper

rectMotion gains fade and dim offsets (sprung to the settled Fade, 0 or
1 for a leaving window, and Dim), a scale flag and, for leaving windows,
the last settled placement. refreshShown applies them to the shown
layout: Fade, an added Dim and a Zoom (settled Preview or 1 times the
drawn/settled width) that publish emits as the scene's Preview while the
settled Preview keeps telling input and popups a card from a window.

A leaving motion keeps drawing a window that closed or hid: refreshShown
appends its last placement, Hidden and Leaving, to both the settled and
the shown layout in ID order, so publish's index alignment holds; input,
focus, popups and configures ignore it (Hidden), the scene shows it
(Hidden false, Fade, Preview). It goes once its springs settle, when the
layout shows the window again, or with stopRects. A leaving float keeps
its own border lines.

c.appear(sc, id, now) fades a window in from invisible and grows it from
90 % of its settled rect; c.leave(sc, p, now) does the reverse. Both are
no-ops with animations off. animate stays allocation-free with fade, dim
and scale components; publish budgets are unchanged (36, overview 70).

* fix(scene): carry an animating window's content scale as Zoom, apart from Preview

publish copied the derived zoom into SceneWindow.Preview, which the
vulkan walk reads as "overview card" (opensFloats): an appearing or
leaving float stopped opening the floats, so the veil went missing and
popped back and the tile lines were painted over the fading float.

SceneWindow.Zoom (15 fields) is the content scale of a window whose frame
animates; the walk uses it over Preview for content only, opensFloats
keeps reading Preview. DRM scanout and the overlay plane refuse Zoom > 0
like Preview. sceneDelta covers it by struct equality (the zoomed content
stays inside Rect). Walk test: a zoomed float with scene.Dim and
separators paints the veil and the tile lines under it exactly as
unzoomed; a Preview card still does not.

* fix(core): order leaving tiles under the floats, reuse the settled buffer, clamp dim, snap the zoom on a re-flow

- A leaving tile is inserted before the first window that opens the
  floats, a leaving float appended; both layouts get the same index, so
  a closing tile never flashes over a float (TestLeavingPaintOrder).
- withLeaving builds the settled layout with its leaving entries into a
  reused screen buffer: TestPublishLeavingAllocations pins 28 allocs per
  frame (two leaving + four moving windows per screen), budget 36.
- Dim offsets are clamped into 0..1 on the shown placement and publish
  never emits a negative Dim.
- A re-flow that retargets a running rect motion clears its scale: the
  content snaps to the settled size (the aspect may change), the fade
  goes on (TestRetargetClearsScaleKeepsFade).
- floatDim counts leaving floats, scaled by 1-Fade, so the veil fades
  with the last float instead of dropping at once.
- A leaving window's content settles at the 90 % zoom through show;
  its placement's Preview stays 0.

* feat(core): slide a dropped tile and re-flow its neighbours on drag release

* test(core): follow the bind order on drop, guard the drop's own spring, check every configure, animate the cross-output drop

* feat(core): animate a workspace moved to another monitor

* fix(core): keep the destination of a moved workspace off the switch slide, cover named pulls and mixed geometry

* feat(core): animate a window's open and re-flow its neighbours

A mapped tiled or floating toplevel that shows on the current workspace fades
in and grows from 90 % of its settled rect; the tiles its column displaces
slide from where they were. The map handling is wrapped with snapshot and
transition, the client is configured once, to the final size. Fullscreen,
below floats, stash peeks, a session lock, the overview and animations off
stay instant. Tests that read the scene right after a map get an animations-off
variant and an on variant that settles.

* fix(core): follow a float's first commit in its entrance, drop it only for a fullscreen that applied

A float maps with no size (it commits after the map), so its entrance was laid
out from the default rect and broke the aspect once WindowResized moved the
settled rect. WindowResized now scales the running entrance's offsets and
springs by the settled size ratio, keeping progress and speed. A fullscreen
request that was ignored (grace after the map) no longer drops the entrance.
The headless collector stops in a cleanup.

* feat(core): animate the overview open, close and navigation

Rect motions now run in the overview: toggling it, moving its selection or
clicking a card slides every card drawn before and after from its shown
rect, content scaled through the derived Zoom (clamped to 1, emitted as 1
for a card at its size). Cards the other rows bring in fade in. The click
path hit-tests the shown layout while the cards move.

* fix(core): review of the overview animation: faded cards, carried fades, min-ratio zoom, filtered snapshots

Cards with Fade >= 0.5 take no click, a running fade carries over an
overview state change, a scale motion's zoom follows the smaller of the
width and height ratios, input snapshots only for events that can step the
overview, a map with the overview open is not an overview action, and the
no-camera-slide check is restored as an internal test.

* feat(core): animate the stash show, hide and navigation

toggle-stash-visible makes the windows it brings on screen appear and
the ones it hides leave (fade and 90 % scale, from their last settled
placement); a navigation that brings a window to a margin or sends a
peek off does the same. The peek veil goes through the dim component,
as an offset from the configured Stash.Dim.

* fix(core): chain stash leave and appear from what was drawn, order leaving entries deterministically

A hide during an entrance or a slide starts from the drawn rect, fade and
veil with their speeds; a show during a hide continues the leaving motion
instead of restarting at 90 %. withLeaving replaces the entries the layout
still lists first, then inserts the absent ones in ID order.

* feat(core): a four-finger swipe down shows or hides the stash

* docs: describe the four-finger swipe down on the stash

* feat(core): animate a window's close with a leaving entry, drawn from its last content

An unmap is wrapped with snapshot/transition so the neighbours re-flow, and
a window that was drawn as a tile or an ordinary float (animations on, no
session lock, overview closed, not fullscreen) leaves from its last settled
placement: fade 0→1 and scale 1→0.9 (leave). Lock, animations off, an
overview toggle, a workspace change and an unplug drop it via stopRects.

The empty content wayland emits right after WindowUnmapped would strip the
renderer of the buffer before the scene listing the leaving window arrives.
Outputs now keep their contents in a surfaces.Table: an empty content of a
window the current scene draws keeps the previous one with Acquire zeroed
(the first draw waited on it, wayland closes it on release); the entry goes
once an accepted scene no longer lists the window. The renderer reuses its
existing dmabuf import or shm copy: no vkCmdCopyImage, no extra pass.

Tests: core lifecycle (tiled, float, mid-motion, off, exceptions, lock);
TestUnmapMidRectMotion gains an on variant; surfaces table unit tests
(kept/pruned/Acquire nil/zero allocs); drm and headless Run intake.

* fix(core): a closed window leaves from where it was drawn, ordered by the two-pass rule

unmapWindow hands leaveFrom the snapshot's rectShot of the closed window,
so one closed mid-motion (its entrance, a slide) exits from its drawn rect
and fade with their speeds instead of jumping to its settled rect. Absent
leaving tiles take withLeaving's two-pass order (C6); a tile closed while
the stash hides is drawn before the leaving stash floats, the same on every
publish. configures.prune forgets a destroyed window once it left the
scene: no configure reaches it during or after the fade (tested).

* fix(drm): never put a closed window's kept buffer on a plane; forget its configures

GO-001: wayland releases a closed window's buffer once the output reports
its empty content as seen, so the client may already draw into it while
the leaving entry is composed. The first leaving scene carries Fade 0 and
Zoom 0, so the plane paths need the output's own knowledge: Output.kept
(surfaces.Table.Kept) is passed to scanoutCandidate and overlayCandidate,
which refuse a kept window with reason "leaving". shownBy and headless
flipInfo skip kept IDs (GO-005): nothing is presented to the client.

GO-002: configures.keep held the destroyed window's last configure; xdg
reuses window IDs, so a remap during the fade would withhold the new
toplevel's unchanged tile configure. unmapWindow forgets the ID's sent and
answer state up front. The remap chains the entrance over the leaving
motion (appearFrom), lists the window once and configures it.

GO-003: the kept content's Children are copied with their Acquire fences
zeroed (one allocation per unmap); the package doc states the invariant
(composed only; window capture ends before the leaving scene).

Tests: drm overlay + single leaving tile composed with reason leaving,
direct scanout of a lone leaving fullscreen window refused, shownBy skips
kept; headless flipInfo skips kept; surfaces children copy; core remap
during the fade; leaving dropped by a real lock, unplug, overview toggle
and workspace switch (GO-004).

* docs(core): say an unmapped toplevel keeps its ID when it maps again

* docs: describe the animations, rect redraws and compressed render targets

* perf(core): make the frame fallback timer once and reset it per frame

armFrame built a time.Timer and two closures on every animation frame
(about 4 allocations). Core keeps one ports.Timer, made on the first arm
and Stop+Reset afterwards; frameC is nil while it is not armed. Mock
clocks in tests accept Reset; the multi-output timer test counts resets
instead of comparing channels.

* test(core): measure publish allocations on the flip path and the opening overview

The frame budget drops to 32 (timer no longer allocated) and is measured
on both step paths (fallback timer and a page flip while another screen
animates), with fade, dim and scale motions running. The overview test
toggles through snapshot/transition so its cards' motions are in flight
(70). The leaving budget is its measured value, 24.

* perf(vulkan): compare separator lists index by index before counting

sceneDelta counted every separator in both lists (O(S²) per frame). Equal
lists are one slices.Equal; same-length lists (core emits them in layout
order) only count the pairs that differ by index; the full multiset count
remains for lists of different lengths. Same damage as before. The alloc
test runs with 80 lines, and with a dropped line.

* test(core): benchmark refreshShown with 26 rect motions on 24 windows

Records the per-frame cost of matching motions to the layout (about 5 µs,
33 allocations, all from the layout rebuild) before any change to it.

* test(core): deterministic mid-swipe overview test, shared transition helpers, quitting headless scripts

TestOverviewOpenedMidSwipeDropsIt receives the lift's own scene and then
settles on the stepped clock instead of polling real time. settledOf,
settleShown, configuresOf and drainConfigures live in one file
(settledPlacement was a copy of settledOf). The headless click-focus
variants end their script with the quit bind, so the run stops at ~2.6 s
instead of the 5 s timeout.

* perf(core): settle pixel springs once they stay within half a pixel

A rect, view or workspace spring ran to its 1e-4 epsilon although the
drawn rect is rounded to whole logical pixels: its tail redrew nothing
but still submitted a frame. A spring with Snap settles as soon as the
critically damped solution is bounded within Snap of its target
(|x|(1+1/e) + |v|/(βe) < Snap), so the last frame drawn already equals
the settled one: no jump, exact settle, chaining unchanged. Rect and view
springs snap at 0.5 logical px; the workspace slide at 0.5 px of the
output height; fades and veils (levelSpring) keep the fine epsilon.
Headless V6: empty-delta frames 11.2k -> 9.5-9.9k; per motion 100 px at
165 Hz: 54 -> 47 frames.

* perf(core): drop the veil at once when a float closes or hides

The veil under a leaving float faded with it, which changed Scene.Dim on
every frame of the fade and forced a full redraw of the output for about
300 ms, where the float's own fade redraws only its rect. floatDim now
ignores leaving floats: the veil goes when the float starts to leave.
Deliberate spec change (C0 pinned the fading veil): the veil vanishing at
once is acceptable visually and restores partial redraws during float
close and stash hide. publish keeps a leaving window's configure only
when its workspace still holds it (a hidden stash window), as the comment
always said; a closed one stays forgotten.

* fix(core): a window closed while its stash hide fades keeps that fade

unmapWindow dropped the window's rect motion before starting a fresh exit;
when the motion was already a leaving one (a stash hide still fading) the
fade restarted. It is kept and runs on from where it was, drawn from the
content the renderer kept.

* refactor(core): remove the appear, leave and Monitor.overviewAt wrappers

Only tests called them; they call appearAt, leaveFrom and overviewIn
directly.

* docs: content zooms only on open, close and overview cards; present-state perf numbers

Transitions: moves and resizes clip or pad the content, never scale it;
the veil under a closing float goes at once; pixel springs settle at half
a pixel; the frame timer is reset, not rebuilt. Redraw bullet: separator
comparison and the pinned publish budgets (32 / 24 / 70).

* test(core): drop an unused index in the refreshShown benchmark

* perf(core): build per-frame layouts in reused owner buffers

publish rebuilt every screen's layout from fresh slices each frame
(Monitor.Layout, Workspace.Layout, the column, row and stash rects, the
overview rows and zoom tiles): 33 allocs per refreshShown, 32 per
published frame.

Add layoutInto(dst) to Monitor and Workspace and use it only on the
per-frame owner paths (refreshShown into screen.layoutBuf, publish's real
layouts and state() into Core.realBuf). Layout() keeps returning a fresh
slice for every other caller, so retained layouts (snapshots, captures,
drag, transitions) never alias a buffer; Scenes copy placements and never
alias one either. Per-workspace scratch (column, row, tile and zoom
buffers) and the slide's neighbor buffer back the helpers, which are dead
once the placements are built; no cache, nothing to invalidate.

BenchmarkRefreshShown: 33 -> 0 allocs/op, 4.7 -> 3.3 us/op.
Pinned budgets: publish 32 -> 6, leaving 24 -> 4, overview 70 -> 18.
Tests: reused-buffer results equal fresh ones, retained Layout() results
survive later layouts, sent scenes are never mutated (run under -race).

* test(core): pin the layout buffer reuse contracts

Review follow-ups for the layout buffers:
- an empty settledLayout stays nil, so shownLayout falls back to the live
  layout until the next publish;
- the overview phase of the sent-scenes test also checks each scene against
  a fresh Layout() taken before the step (IDs, hidden, and rects once
  settled), so the overview's real layouts sharing a screen buffer fail it;
- layoutInto must build in the caller's buffer, and a result kept across the
  calls that use the workspace scratch stays unchanged;
- the layout variants assert they hold the feature they are named for (the
  stash variant had put its window on a hidden workspace);
- maps.Keys replaces the hand-written key iterator.

* fix(drm): offer the primary plane's XRGB modifiers for output images

The renderer chose its own render-target modifier and KMS refused it:
on RDNA2 RADV exports pipe-aligned DCC (0x...56bb03, 2 planes) while
amdgpu lists only non-pipe-aligned DCC (0x...563b03), so ADDFB2 returned
EINVAL because no plane advertises that format and modifier, then the
single-plane fallback was used. Pass the plane's IN_FORMATS modifiers for
the image format in SDR as in HDR, so the renderer picks the best common
one first time; document that DCC is not scanned out on this hardware.

* refactor(drm): share the image format and stop re-exporting refused linear images

Add Output.imageFormat for setupImages and exportImages. When KMS refuses
images that were already linear, return that error instead of exporting
the identical linear images again for the later kinds. Document that a
plane without IN_FORMATS gets linear images first, and pin both with tests.

* feat(core): hide the shown stash on a four-finger swipe up

* docs: describe the four-finger swipe up hiding the stash

* feat(config): turn animations off by default and mark them experimental

* test(vulkan): benchmark a full compose into each exported target modifier

* perf(vulkan): never export targets with the AMD DCC retile modifier

RADV renders a DCC_RETILE image into pipe-aligned DCC and runs a compute pass to rewrite the displayable copy: an extra full-surface GPU pass per frame. Drop those modifiers from the probed target list; plain DCC stays.

* feat(core): keep three-finger swipes still when animations are off

* perf(vulkan): hide the root only under a child's opaque region

A child that is not wholly opaque but whose opaque region stops a pixel
short of its edges (Firefox's page) no longer leaves the shm root below it
drawn in full: the root draws the strips around the region only.

* feat(config): replace animations.slowdown with animations.speed and make normal faster

* test(core): sync no-scene and command checks on a flip barrier instead of timeouts

* refactor(core): always snapshot overview keys and scrolls and release unplugged shots

* perf(vulkan): bound the strips a root quad splits into around opaque covers

* refactor(vulkan): dup exported target planes with dupFile

* docs(drm): align compressed render target notes with the plane-accepted modifiers

* fix(vulkan): draw a kept shm window from its GPU copy when its pool is closed
commit
b6be3a859652ca727179546a3cd662d5355b97b5
author
bnema · · 2026-10-05 10:04 UTC
committer
GitHub · · 2026-10-05 10:04 UTC
parent
ae51138
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.4.0
122 files · +11107 -810
M Makefile +1 -1
@@ -60,7 +60,7 @@ arch:
6060 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
6161 # Fast, mandatory guards for tiled content publications and SHM copies.
6262 perf-check:
63- CGO_ENABLED=0 go test ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
63+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
6464 check: vet test arch fakes-check log-check perf-check
6565
6666 # On-demand SDR/HDR colour check with examples/testpattern on a headless
M README.md +2 -2
@@ -18,7 +18,7 @@
1818
1919 I wanted a compositor I would never have to think about. It takes the newest Wayland protocols and kernel features as they land, gives games the whole GPU, and otherwise stays out of sight.
2020
21-It is small on purpose, and it will stay small. Drawing is kept to simple primitives: no blur, no shadows, no rounded corners, no themes, no animated transitions or effects. There is no built-in bar or wallpaper either; for those, bring your own: any layer-shell client works. The only motion is a touchpad swipe that follows your fingers, and a brief pulse on the window you just focused, which you can turn off. Anything else that costs a frame is left out. About 40–80 MB of RAM with two 4K monitors, and almost no CPU while the screen does not change.
21+It is small on purpose, and it will stay small. Drawing is kept to simple primitives: no blur, no shadows, no rounded corners, no themes. There is no built-in bar or wallpaper either; for those, bring your own: any layer-shell client works. Animations are experimental and off by default (`animations = on` turns them on); off, every change is instant and touchpad swipes switch on release. With them, three-finger swipes follow the fingers, windows and the view move with short springs (about 180 ms) when you scroll, switch workspaces, move columns or windows, resize them, drop a dragged tile or send a workspace to another monitor; windows fade in and out when they open, close or leave with the stash, the overview zooms its cards, and a brief pulse marks the window you just focused. They stay cheap: nothing runs while the screen is still, frames follow the display, only the region that moves is redrawn, apps are resized once, to their final size, and do not redraw during a transition, and nothing is copied or blurred. About 40–80 MB of RAM with two 4K monitors, and almost no CPU while the screen does not change.
2222
2323 ## OK, but why Go?
2424
@@ -65,7 +65,7 @@ A full build takes seconds, and tests, race detection, profiling and formatting
6565 - **HDR.** HDR10 output on capable displays. HDR clients (games, browsers, video players) are shown at full range; SDR content is shown at a configured brightness.
6666 - **Column tiling.** Columns scroll to the right, as in PaperWM and Niri, or stay on screen and split, as in Sway. Per output or per workspace.
6767 - **Overview.** `cmd+o` by default (or a four-finger swipe up) shows the workspaces of a monitor as live, scaled previews. In `fixed` mode, windows hidden behind a maximized column show as a stack of cards behind it, so nothing is lost out of sight. Pick a window with the keyboard, the touchpad or a click.
68-- **Stash.** Set aside a window you do not need right now. Each workspace has its own stash, with no limit on the number of windows. By default, `cmd+s` shows it as a floating strip over your tiles, or hides it again; `cmd+shift+s` sends a window into the stash or back to its column.
68+- **Stash.** Set aside a window you do not need right now. Each workspace has its own stash, with no limit on the number of windows. By default, `cmd+s` (or a four-finger swipe down) shows it as a floating strip over your tiles, or hides it again (a four-finger swipe up hides it too, while it is shown); `cmd+shift+s` sends a window into the stash or back to its column.
6969 - **Workspaces.** Numbered workspaces are created and removed as needed. Named workspaces declare their columns (width and command); NeferWL starts the commands and places each window.
7070 - **Multi-monitor.** Each output has its own workspaces. When an output is unplugged its workspaces move to another one, and return when it is plugged back. Fractional scale per output.
7171 - **External clients.** No built-in bar, launcher or notifications. Waybar, fuzzel, mako, nefercap, cliphist, swayidle, wlr-randr and input methods such as fcitx5 work through their standard protocols. See [Desktop integration](docs/desktop.md).
M docs/config.md +8 -4
@@ -44,8 +44,10 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
4444 | `mouse.accel-profile` | `adaptive` | `adaptive`: faster moves go further; `flat`: constant speed |
4545 | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons |
4646 | `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |
47+| `animations` | `off` | **Experimental.** `on` animates changes; `off` makes every change instant and the three-finger swipe no longer follows the fingers: swipe landings, scrolling, workspace switches, window moves and resizes, drag drops, workspaces sent to another monitor, the overview, windows opening and closing, the stash showing and hiding, the focus pulse |
48+| `animations.speed` | `normal` | Speed of every spring (not the focus pulse): `slow`, `normal` or `fast` (a view move lands in about 260, 180 or 130 ms), or a number relative to `normal`, from `0.1` (10× slower) to `10` (10× faster) |
4749 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
48-| `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone on screen (e.g. a maximized column), the overview and a window focused as it opens |
50+| `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone on screen (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` |
4951 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |
5052 | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` |
5153 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back |
@@ -134,7 +136,7 @@ bind.Cmd+p = workspace presentation
134136
135137 ## Touchpad
136138
137-A three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace:
139+With `animations = on`, a three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace:
138140
139141 - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen.
140142 - Up or down slides to the next numbered workspace.
@@ -143,6 +145,8 @@ The view sticks a little near each step and catches up between them. Past the ne
143145
144146 With `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed.
145147
148+With `animations = off`, nothing moves during the swipe: when the fingers lift, a quick or long enough swipe runs `focus-column-left/right` or `focus-workspace-up/down`.
149+
146150 Where the view cannot scroll (`fixed` overflow, the stash, a floating or fullscreen window, a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift.
147151
148152 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.
@@ -218,7 +222,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
218222
219223 Each workspace has a stash: a horizontal strip of windows set aside with `toggle-window-stash`, in the order they arrived. The selected window is centred over the tiles, `stash.width` of the usable width (80% by default) and 80% of its height. Its left and right neighbors sit `stash.gap` beside it and show up to the screen edges, dimmed (`stash.dim`); a click on one selects it. The others wait off screen.
220224
221-While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible` to get back to the tiles. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).
225+While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible`, or with a four-finger swipe down or up, to get back to the tiles; a swipe down shows it again. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).
222226
223227 Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive, except for the fullscreen window's own dialogs. A dialog belongs to a window through `xdg_toplevel.set_parent`, or through `zxdg_importer_v2` when another program opens it, as the desktop portal does.
224228
@@ -255,7 +259,7 @@ The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows`
255259
256260 Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the front card. A successful up/down move through the stack leaves the stash and selects the new front card. `return` or a click on a card closes the overview with the stash shown on that window.
257261
258-A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll` for two-finger scrolling and `mouse.natural-scroll` for the wheel. A two-finger scroll sideways moves it to the next column after a short distance, then one column per longer distance, and stays on the columns until the fingers lift; up or down it moves one step per scroll, so a card in a stack is easy to pick. The wheel moves one step per notch. A three-finger swipe moves it one step when the fingers lift.
262+A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll` for two-finger scrolling and `mouse.natural-scroll` for the wheel. A two-finger scroll sideways moves it to the next column after a short distance, then one column per longer distance, and stays on the columns until the fingers lift; up or down it moves one step per scroll, so a card in a stack is easy to pick. The wheel moves one step per notch. A three-finger swipe moves it one step when the fingers lift.
259263
260264 Return or a click on a front tile or peeking card commits it; card changes remain provisional until then. `close-window` targets the selected preview. Move-to-workspace binds (`move-column-to-workspace*`, `move-window-to-workspace*`) move the selected preview at once and keep the overview open; with `focus.follow-move`, the selection follows it. Escape does not undo a move, and the row that received the window keeps it focused. A selected stash card, or a row with no preview, does not move. Other window mutation binds (moving within the row, resizing, maximizing, fullscreen and stash toggles) and moving a workspace to another monitor are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active.
261265
M docs/performance.md +4 -1
@@ -8,6 +8,9 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
88 - **Allocation-free steady-state presentation.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.
99 - **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.
1010 - **Game frames keep a short gap after each VRR flip.** On amdgpu, a flip committed right after the previous flip event can miss the early refresh and wait for the panel's slowest rate (~21 ms at 48 Hz); the next frame then lands at the same point, and the screen stays at 48 Hz while the game renders at 150 fps. Game frames under VRR therefore commit at least `render.vrr-flip-gap` (default 1 ms) after the previous flip. `internal/adapters/drm/vrr_flip_gap.go` has the measurements.
11+- **Transitions** (experimental, `animations = on`; off by default). Swipe landings, scrolling, workspace switches, moving columns and windows, consume and expel, column width and window height, maximize, fullscreen, the floating toggle, drag drops and workspaces sent to another monitor each move by one critically damped spring per moving value, advanced on the flips of the output that shows them. Windows that open, close or leave with the stash fade and scale from 90 %, and the overview zooms its cards; both reuse the per-window alpha and preview zoom of the compose pass, so no transition adds a GPU pass, a blur or a texture copy. A closing window is drawn from its last content, which the renderer already imported; the veil under a closing float goes at once, so the close redraws only the float's rect. A window that fades or zooms is never scanned out directly. Client-driven resizes, panels and hotplug never animate. Clients get one configure, to the final size. During a move or a resize the window frame moves and the content is not scaled, only clipped or padded; the content zooms with the frame only while a window opens, closes or is an overview card. A spring that moves pixels settles as soon as it can no longer leave half a pixel of its target, so its last rendered frame is already the settled one and the sub-pixel tail (about a third of a 100 px move's frames) is not drawn; fades keep their fine epsilon. The frame fallback timer is made once and reset per frame.
12+- **Redraw only what moved.** An output whose scene is unchanged keeps its scene `Seq` and is not recomposed; a content-only change redraws only the damaged region. When only windows move, the renderer compares the new scene with the one each render target holds and redraws the union of the old and new rects of the windows that changed; anything else (layers, scale, transform, dim, capture) redraws the whole output. Resizing one of three columns at 2560×1600 redraws about 0.2 M pixels per frame instead of 4.1 M. Advancing the springs, the scene comparison (separator lists of the same length are compared index by index, as core emits them in layout order) and the draw list of a moving window cost 0 allocations; publishing the scenes of two outputs with six moving windows (position, fade, dim and zoom motions) costs a pinned 6 on both the page-flip and the fallback-timer paths, 4 with closing windows and 18 while the overview opens. All are guarded by `make perf-check`. Measured cost of a full composed frame at 2560×1600: ~0.16 ms on an RX 9070 XT, ~1.4 ms on a Raphael iGPU, ~3 ms on a Radeon 680M, against an 11.1 ms frame at 90 Hz.
13+- **Compressed render targets.** Output images take their modifiers from the primary plane's `IN_FORMATS` for XRGB8888 (XRGB2101010 in HDR); KMS refuses any other (`ADDFB2: EINVAL`). A multi-plane (AMD DCC) modifier is used only when the plane lists the exact variant the GPU exports; otherwise NeferWL uses a single-plane tiled modifier, then linear. AMD DCC retile modifiers (`DCC_RETILE`, bit 14 of the modifier, e.g. `…567b03`) are never exported: such an image is rendered into pipe-aligned DCC and a compute pass rewrites its displayable copy, a full-surface GPU pass on top of the composition. On the measured Radeon 680M laptop (RDNA2, current RADV and amdgpu) the pick the plane accepts is single-plane tiled `0x200000010401b03`. Plain DCC costs no retile pass. As a renderer-only benchmark on a Raphael iGPU, a full composed frame at 2560×1600 takes about 1.63 ms into pipe-aligned DCC (`…56bb03`), 1.85 ms into single-plane tiled (`…401b03`) and 3.0 ms into linear (`go test ./internal/adapters/vulkan -bench ComposeFullTarget`, `NEFERWL_VK_DEVICE` picks the GPU; it exports one target per modifier the device offers, directly and not through the plane's `IN_FORMATS`, and times one frame with the GPU wait included). The DCC figure is thus not what the DRM output gets unless the plane lists that modifier. The chosen modifier and its plane count are logged as `zero-copy output`.
1114 - **Bounded capture.** Protocol requests and sync-file handles still need small allocations; pixel storage and worker batches are reused. At most 32 protocol requests are outstanding globally, with up to eight per output frame, one SHM-copy worker and two reusable GPU staging slots per output. Saturation fails requests instead of waiting or growing a queue. Two 4K slots use about 63 MiB of host-visible memory, capped at 128 MiB per output; HDR adds one GPU-local 8-bit conversion image (about 32 MiB at 4K, up to 64 MiB), outside the host-memory cap. Display capture resources are allocated on first capture and retained until the output stops. Hidden workspace capture uses a separate, demand-driven child renderer and pipeline with its own worker and two staging slots (up to 128 MiB host-visible memory), plus child render targets; each child image is capped at 256 MiB and 16384 pixels per side. The child retires once unused and idle, and is recreated for session or size changes. Copies cost GPU time and bandwidth. Capture of the displayed frame forces composition, and so does the capture indicator (red border or pill, shown while a session lives and for one second after each captured frame; see `docs/headless.md`), which also costs a second composition per capture frame because no capture holds it (a request waits at most 250 ms for the scene that shows its indicator, without drawing anything while it waits); hidden workspace capture can keep display scanout. Offscreen capture currently serves only a whole-workspace target and requests matching its exact physical dimensions, not crops. There is no per-client reservation of the shared request limit.
1215 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
1316
@@ -17,7 +20,7 @@ With the capability, the dynamic loader ignores `LD_PRELOAD` and `LD_LIBRARY_PAT
1720
1821 ## Guards
1922
20-`make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, frame lifecycle transitions, scanout/overlay frame decision, DRM frame commit and plane colour pipeline decision, pacer bookkeeping, output message routing, surface update recycling and input-region traversal. Run it with `make check`.
23+`make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: spring advance, scene publication (pinned budgets, with the overview open and with closing windows), scene comparison for partial redraws, tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, frame lifecycle transitions, scanout/overlay frame decision, DRM frame commit and plane colour pipeline decision, pacer bookkeeping, output message routing, surface update recycling and input-region traversal. Run it with `make check`.
2124
2225 ## Measuring
2326
M examples/config +9 -0
@@ -72,6 +72,15 @@ cursor.hide-after = 5s
7272
7373 # on: moving a column or window to another workspace shows that workspace.
7474 focus.follow-move = off
75+# Experimental, off by default.
76+# on: windows and the view move with springs, windows fade in and out as
77+# they open, close or leave with the stash, the overview zooms its cards;
78+# off: every change is instant and three-finger swipes act when the
79+# fingers lift.
80+# speed: slow, normal, fast, or a number from 0.1 (10x slower) to 10
81+# (10x faster).
82+animations = off
83+animations.speed = normal
7584 # Focus indicator on the newly focused window: an animation (pulse or off)
7685 # of an effect (screen: a slight brightening), peaking at strength
7786 # (0.01 to 0.2; 0.04: black reaches about 10/255).
M internal/adapters/config/config.go +28 -0
@@ -169,6 +169,9 @@ func Defaults() ports.Config {
169169 c.Touchpad.ScrollFactor = 1
170170 c.Mouse.AccelProfile = ports.AccelAdaptive
171171 c.Cursor.HideAfter = 5 * time.Second
172+ // Animations are experimental: off unless the config turns them on.
173+ c.Animations.On = false
174+ c.Animations.Slowdown = normalSlowdown
172175 c.Focus.Animation = ports.FocusAnimationPulse
173176 c.Focus.Effect = ports.FocusEffectScreen
174177 c.Focus.Strength = 0.04
@@ -749,6 +752,23 @@ func set(c *ports.Config, key, v string) error {
749752 return fmt.Errorf("must be off or a duration between 100ms and %s, e.g. 5s", maxCursorHideAfter)
750753 }
751754 c.Cursor.HideAfter = d
755+ case "animations":
756+ b, err := onOff(v)
757+ if err != nil {
758+ return err
759+ }
760+ c.Animations.On = b
761+ case "animations.speed":
762+ f, ok := animationSpeeds[v]
763+ if !ok {
764+ var err error
765+ f, err = strconv.ParseFloat(v, 64)
766+ if err != nil || !(f >= 0.1 && f <= 10) {
767+ return fmt.Errorf("must be slow, normal, fast or a number between 0.1 and 10")
768+ }
769+ }
770+ // Core stretches springs by a duration factor: the inverse.
771+ c.Animations.Slowdown = normalSlowdown / f
752772 case "focus.follow-move":
753773 b, err := onOff(v)
754774 if err != nil {
@@ -1007,6 +1027,14 @@ func positive(dst *int, v string, max int) error {
10071027 return nil
10081028 }
10091029
1030+// normalSlowdown is the spring duration factor of animations.speed =
1031+// normal (1): a view move lands in about 180 ms.
1032+const normalSlowdown = 0.7
1033+
1034+// animationSpeeds are the named animations.speed values, relative to
1035+// normal: a view move lands in about 260 ms at slow and 130 ms at fast.
1036+var animationSpeeds = map[string]float64{"slow": 0.7, "normal": 1, "fast": 1.4}
1037+
10101038 func onOff(v string) (bool, error) {
10111039 switch v {
10121040 case "on":
M internal/adapters/config/config_test.go +31 -0
@@ -2,6 +2,7 @@ package config
22
33 import (
44 "fmt"
5+ "math"
56 "os"
67 "path/filepath"
78 "reflect"
@@ -78,6 +79,36 @@ func TestFollowMove(t *testing.T) {
7879 }
7980 }
8081
82+func TestAnimations(t *testing.T) {
83+ d := Defaults().Animations
84+ if d.On || d.Slowdown != normalSlowdown {
85+ t.Fatal("animations defaults", d)
86+ }
87+ c, w := parseString(t, "animations = on\nanimations.speed = 0.5\n")
88+ if !c.Animations.On || c.Animations.Slowdown != 2*normalSlowdown || len(w) != 0 {
89+ t.Fatal(c.Animations, w)
90+ }
91+ for v, slowdown := range map[string]float64{"slow": 1, "normal": 0.7, "fast": 0.5, "10": 0.07, "0.1": 7} {
92+ c, w = parseString(t, "animations.speed = "+v+"\n")
93+ if math.Abs(c.Animations.Slowdown-slowdown) > 1e-9 || len(w) != 0 {
94+ t.Fatal(v, c.Animations, w)
95+ }
96+ }
97+ c, w = parseString(t, "animations = off\n")
98+ if c.Animations.On || len(w) != 0 {
99+ t.Fatal(c.Animations, w)
100+ }
101+ for _, line := range []string{"animations = maybe", "animations.speed = 0", "animations.speed = 11", "animations.speed = turbo", "animations.slowdown = 2"} {
102+ c, w = parseString(t, line+"\n")
103+ if c.Animations != d || len(w) != 1 {
104+ t.Fatal(line, c.Animations, w)
105+ }
106+ if strings.Contains(line, "speed") && !strings.Contains(w[0].Msg, "must be slow, normal, fast or a number between 0.1 and 10") {
107+ t.Fatal(line, w)
108+ }
109+ }
110+}
111+
81112 func TestFocusIndicator(t *testing.T) {
82113 d := Defaults().Focus
83114 if d.Animation != ports.FocusAnimationPulse || d.Effect != ports.FocusEffectScreen || d.Strength != 0.04 {
M internal/adapters/drm/amdgpu_rules_test.go +1 -1
@@ -420,7 +420,7 @@ func TestRunProtectedActivationRetriesWithoutStoppingOrEarlyProof(t *testing.T)
420420 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
421421 }
422422 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
423- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
423+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
424424 r.EXPECT().UseTarget(mock.Anything).Return()
425425 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
426426 r.EXPECT().Close().Return().Once()
M internal/adapters/drm/capture_test.go +3 -3
@@ -339,7 +339,7 @@ func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) {
339339 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
340340 }
341341 display.EXPECT().SetHDR(float64(0)).Return().Maybe()
342- display.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
342+ display.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
343343 display.EXPECT().UseTarget(mock.Anything).Return()
344344 display.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
345345 display.EXPECT().Close().Return().Once()
@@ -447,7 +447,7 @@ func TestRunCaptureWaitsForItsIndicatorScene(t *testing.T) {
447447 }
448448 r := portsmocks.NewMockRenderer(t)
449449 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
450- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
450+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
451451 r.EXPECT().UseTarget(mock.Anything).Return()
452452 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
453453 switch {
@@ -543,7 +543,7 @@ func TestRunCaptureWithoutIndicatorFailsAfterTheBoundedWait(t *testing.T) {
543543 }
544544 r := portsmocks.NewMockRenderer(t)
545545 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
546- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
546+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
547547 r.EXPECT().UseTarget(mock.Anything).Return()
548548 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
549549 r.EXPECT().Close().Return().Once()
M internal/adapters/drm/commit_test.go +231 -10
@@ -2,6 +2,7 @@ package drm
22
33 import (
44 "context"
5+ "errors"
56 "os"
67 "testing"
78 "time"
@@ -111,21 +112,40 @@ func TestCursorRefusedDoesNotBlameScanoutBuffer(t *testing.T) {
111112 }
112113 }
113114
114-// showImages validates the images with a TEST_ONLY modeset and falls back
115-// to linear images when KMS refuses the driver's.
115+// tiledMod stands for a non-linear driver modifier in the image tests.
116+const tiledMod = 0x200000000401b03
117+
118+// showImages validates the images with a TEST_ONLY modeset, retries with
119+// single-plane driver images, then falls back to linear images when KMS
120+// refuses both.
116121 func TestShowImagesTestsBeforeModeset(t *testing.T) {
117- o, k, commits := testOutput(t, unix.EINVAL)
122+ o, k, commits := testOutput(t, unix.EINVAL, unix.EINVAL)
118123 o.cursor = nil
119124 r := portsmocks.NewMockRenderer(t)
120125 buf := func() ports.DMABuf {
126+ var b ports.DMABuf
127+ for range 2 {
128+ f, w, _ := os.Pipe()
129+ w.Close()
130+ b.Planes = append(b.Planes, ports.DMABufPlane{File: f})
131+ }
132+ b.Modifier = tiledMod
133+ return b
134+ }
135+ linear := func() ports.DMABuf {
121136 f, w, _ := os.Pipe()
122137 w.Close()
123138 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
124139 }
140+ tiled1 := func() ports.DMABuf {
141+ b := linear()
142+ b.Modifier = tiledMod
143+ return b
144+ }
125145 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
126- r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
127- r.EXPECT().SetHDR(float64(0)).Return().Maybe()
128- r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
146+ r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
147+ r.EXPECT().ExportTargets(2, []uint64(nil), true).Return([]ports.DMABuf{tiled1(), tiled1()}, nil).Once()
148+ r.EXPECT().ExportTargets(2, []uint64{0}, false).Return([]ports.DMABuf{linear(), linear()}, nil).Once()
129149 r.EXPECT().UseTarget(mock.Anything).Return()
130150 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
131151 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
@@ -135,7 +155,7 @@ func TestShowImagesTestsBeforeModeset(t *testing.T) {
135155 if err := o.showImages(r, imagesDriver, nil); err != nil {
136156 t.Fatal(err)
137157 }
138- want := []uint32{atomicTestOnly | atomicAllowModes, atomicTestOnly | atomicAllowModes, atomicAllowModes}
158+ want := []uint32{atomicTestOnly | atomicAllowModes, atomicTestOnly | atomicAllowModes, atomicTestOnly | atomicAllowModes, atomicAllowModes}
139159 if len(*commits) != len(want) {
140160 t.Fatalf("%d commits", len(*commits))
141161 }
@@ -149,6 +169,207 @@ func TestShowImagesTestsBeforeModeset(t *testing.T) {
149169 }
150170 }
151171
172+// A refused multi-plane (DCC) driver image is retried with single-plane
173+// driver images before linear; every plane file of every buffer is closed
174+// once the framebuffers exist.
175+func TestShowImagesRetriesSinglePlaneBeforeLinear(t *testing.T) {
176+ o, k, _ := testOutput(t, unix.EINVAL)
177+ o.cursor = nil
178+ var files []*os.File
179+ buf := func(planes int) ports.DMABuf {
180+ b := ports.DMABuf{Modifier: tiledMod}
181+ for range planes {
182+ f, w, _ := os.Pipe()
183+ w.Close()
184+ files = append(files, f)
185+ b.Planes = append(b.Planes, ports.DMABufPlane{File: f})
186+ }
187+ return b
188+ }
189+ r := portsmocks.NewMockRenderer(t)
190+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
191+ r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{buf(3), buf(3)}, nil).Once()
192+ r.EXPECT().ExportTargets(2, []uint64(nil), true).Return([]ports.DMABuf{buf(1), buf(1)}, nil).Once()
193+ r.EXPECT().UseTarget(mock.Anything).Return()
194+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
195+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
196+ k.EXPECT().rmFB(mock.Anything).Return(nil)
197+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
198+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
199+ if err := o.showImages(r, imagesDriver, nil); err != nil {
200+ t.Fatal(err)
201+ }
202+ if o.kind != imagesSinglePlane {
203+ t.Fatalf("kind %d", o.kind)
204+ }
205+ if len(files) != 8 {
206+ t.Fatalf("%d plane files", len(files))
207+ }
208+ for i, f := range files {
209+ if err := f.Close(); !errors.Is(err, os.ErrClosed) {
210+ t.Fatalf("plane file %d left open (close: %v)", i, err)
211+ }
212+ }
213+}
214+
215+// SDR images are asked for the modifiers the primary plane lists for
216+// XRGB8888, in IN_FORMATS order, never the renderer's own choice: RADV's
217+// preferred DCC variant (pipe-aligned) is not one amdgpu lists, and KMS
218+// refuses a framebuffer with a modifier the plane does not advertise
219+// (ADDFB2: EINVAL). Other formats' modifiers are not offered.
220+func TestShowImagesOffersThePrimaryPlanesXRGBModifiers(t *testing.T) {
221+ const dccListed, tiled, other = 0x200000000563b03, 0x200000000401b03, 0x200000000401903
222+ o, k, _ := testOutput(t)
223+ o.cursor = nil
224+ o.primary.formats = []ports.DMABufFormat{
225+ {Format: fourccXRGB, Modifier: dccListed}, {Format: fourccXR30, Modifier: other},
226+ {Format: fourccXRGB, Modifier: tiled}, {Format: fourccXRGB, Modifier: 0},
227+ }
228+ buf := func() ports.DMABuf {
229+ f, w, _ := os.Pipe()
230+ w.Close()
231+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
232+ }
233+ r := portsmocks.NewMockRenderer(t)
234+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
235+ r.EXPECT().ExportTargets(2, []uint64{dccListed, tiled, 0}, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
236+ r.EXPECT().UseTarget(mock.Anything).Return()
237+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
238+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
239+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
240+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
241+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
242+ if err := o.showImages(r, imagesDriver, nil); err != nil {
243+ t.Fatal(err)
244+ }
245+ if o.kind != imagesDriver {
246+ t.Fatalf("kind %d", o.kind)
247+ }
248+}
249+
250+// When the renderer has no modifier in common with the plane, the images
251+// fall back to single-plane ones with the same list, then to linear.
252+func TestShowImagesNoCommonModifierFallsBackWithThePlanesList(t *testing.T) {
253+ const listed = 0x200000000563b03
254+ o, k, _ := testOutput(t)
255+ o.cursor = nil
256+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB, Modifier: listed}, {Format: fourccXRGB, Modifier: 0}}
257+ buf := func() ports.DMABuf {
258+ f, w, _ := os.Pipe()
259+ w.Close()
260+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
261+ }
262+ r := portsmocks.NewMockRenderer(t)
263+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
264+ none := errors.New("no XRGB8888 modifier both the device and the display accept")
265+ r.EXPECT().ExportTargets(2, []uint64{listed, 0}, false).Return(nil, none).Once()
266+ r.EXPECT().ExportTargets(2, []uint64{listed, 0}, true).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
267+ r.EXPECT().UseTarget(mock.Anything).Return()
268+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
269+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
270+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
271+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
272+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
273+ if err := o.showImages(r, imagesDriver, nil); err != nil {
274+ t.Fatal(err)
275+ }
276+ if o.kind != imagesSinglePlane {
277+ t.Fatalf("kind %d", o.kind)
278+ }
279+}
280+
281+// A plane without IN_FORMATS lists every format with modifier 0 only
282+// (readPlanes): the images are linear straight away, with no driver or
283+// single-plane attempt KMS would refuse.
284+func TestShowImagesLinearOnlyPlaneExportsLinearFirst(t *testing.T) {
285+ o, k, commits := testOutput(t)
286+ o.cursor = nil
287+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB, Modifier: 0}}
288+ r := portsmocks.NewMockRenderer(t)
289+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
290+ // No ExportTargets(..., true) and no second call: the mock fails on them.
291+ r.EXPECT().ExportTargets(2, []uint64{0}, false).RunAndReturn(func(int, []uint64, bool) ([]ports.DMABuf, error) {
292+ return []ports.DMABuf{linearBuf(), linearBuf()}, nil
293+ }).Once()
294+ r.EXPECT().UseTarget(mock.Anything).Return()
295+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
296+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
297+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
298+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
299+ if err := o.showImages(r, imagesDriver, nil); err != nil {
300+ t.Fatal(err)
301+ }
302+ if o.kind != imagesDriver || o.imageMod != 0 || len(*commits) != 2 {
303+ t.Fatalf("kind %d, modifier %#x, %d commits", o.kind, o.imageMod, len(*commits))
304+ }
305+}
306+
307+// KMS refusing images that are already linear ends the attempt with that
308+// error: the single-plane and linear kinds would export the same images.
309+func TestShowImagesRefusedLinearImagesAreNotExportedAgain(t *testing.T) {
310+ o, k, commits := testOutput(t, unix.EINVAL)
311+ o.cursor = nil
312+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB, Modifier: 0}}
313+ r := portsmocks.NewMockRenderer(t)
314+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
315+ r.EXPECT().ExportTargets(2, []uint64{0}, false).RunAndReturn(func(int, []uint64, bool) ([]ports.DMABuf, error) {
316+ return []ports.DMABuf{linearBuf(), linearBuf()}, nil
317+ }).Once()
318+ r.EXPECT().UseTarget(mock.Anything).Return()
319+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
320+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
321+ k.EXPECT().rmFB(mock.Anything).Return(nil)
322+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
323+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
324+ err := o.showImages(r, imagesDriver, nil)
325+ if !errors.Is(err, unix.EINVAL) {
326+ t.Fatalf("error %v, want the KMS refusal", err)
327+ }
328+ if len(*commits) != 1 || o.fbs != [2]uint32{} {
329+ t.Fatalf("%d commits, fbs %v", len(*commits), o.fbs)
330+ }
331+}
332+
333+// linearBuf is a one-plane linear dmabuf over a closed pipe.
334+func linearBuf() ports.DMABuf {
335+ f, w, _ := os.Pipe()
336+ w.Close()
337+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
338+}
339+
340+// Refused driver images that already had one plane are not retried as
341+// single-plane images: the next export is linear.
342+func TestShowImagesSkipsSinglePlaneWhenDriverImagesHadOnePlane(t *testing.T) {
343+ o, k, commits := testOutput(t, unix.EINVAL)
344+ o.cursor = nil
345+ buf := func() ports.DMABuf {
346+ f, w, _ := os.Pipe()
347+ w.Close()
348+ return ports.DMABuf{Modifier: tiledMod, Planes: []ports.DMABufPlane{{File: f}}}
349+ }
350+ linear := func() ports.DMABuf {
351+ f, w, _ := os.Pipe()
352+ w.Close()
353+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
354+ }
355+ r := portsmocks.NewMockRenderer(t)
356+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
357+ r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
358+ r.EXPECT().ExportTargets(2, []uint64{0}, false).Return([]ports.DMABuf{linear(), linear()}, nil).Once()
359+ r.EXPECT().UseTarget(mock.Anything).Return()
360+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
361+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
362+ k.EXPECT().rmFB(mock.Anything).Return(nil)
363+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
364+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
365+ if err := o.showImages(r, imagesDriver, nil); err != nil {
366+ t.Fatal(err)
367+ }
368+ if o.kind != imagesLinear || len(*commits) != 3 {
369+ t.Fatalf("kind %d, %d commits", o.kind, len(*commits))
370+ }
371+}
372+
152373 // Enabling an output whose images were not made yet (started switched
153374 // away) tests them and modesets once.
154375 func TestEnableAfterStartAwayModesetsOnce(t *testing.T) {
@@ -161,7 +382,7 @@ func TestEnableAfterStartAwayModesetsOnce(t *testing.T) {
161382 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
162383 }
163384 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
164- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
385+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
165386 r.EXPECT().UseTarget(mock.Anything).Return()
166387 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
167388 r.EXPECT().Close().Return().Once()
@@ -262,7 +483,7 @@ func TestRunReportsSeenAfterCursorCommit(t *testing.T) {
262483 r.EXPECT().CursorBuffers(64).Return([2]ports.DMABuf{buf(), buf()}, nil).Once()
263484 r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).Return(nil)
264485 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
265- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
486+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
266487 r.EXPECT().UseTarget(mock.Anything).Return()
267488 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
268489 r.EXPECT().Close().Return().Once()
@@ -355,7 +576,7 @@ func TestRunClosesFrameFences(t *testing.T) {
355576 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
356577 }
357578 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
358- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
579+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
359580 r.EXPECT().UseTarget(mock.Anything).Return()
360581 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
361582 f, w, _ := os.Pipe()
M internal/adapters/drm/drop_hint_test.go +3 -3
@@ -9,15 +9,15 @@ import (
99 func TestDropHintsBlockPlanes(t *testing.T) {
1010 s, c := overlayScene()
1111 s.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}
12- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "drop_hint" {
12+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "drop_hint" {
1313 t.Fatalf("overlay reason %q", reason)
1414 }
1515 fs, fc := fullscreenScene()
16- if _, reason := scanoutCandidate(fs, fc, 200, 100); reason == "drop_hint" {
16+ if _, reason := scanoutCandidate(fs, fc, 200, 100, nil); reason == "drop_hint" {
1717 t.Fatal("no hints, still refused")
1818 }
1919 fs.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}
20- if _, reason := scanoutCandidate(fs, fc, 200, 100); reason != "drop_hint" {
20+ if _, reason := scanoutCandidate(fs, fc, 200, 100, nil); reason != "drop_hint" {
2121 t.Fatalf("scanout reason %q", reason)
2222 }
2323 }
M internal/adapters/drm/hdr_test.go +14 -14
@@ -73,7 +73,7 @@ func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
7373 }
7474
7575 func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) {
76- o, k, commits := testOutput(t, unix.EINVAL, unix.EINVAL)
76+ o, k, commits := testOutput(t, unix.EINVAL)
7777 o.cursor = nil
7878 o.hdr.cap = hdrCapability{Capable: true}
7979 o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: 203}
@@ -88,9 +88,9 @@ func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) {
8888 var calls []string
8989 r.EXPECT().SetHDR(float64(203)).Run(func(float64) { calls = append(calls, "hdr") }).Return().Once()
9090 r.EXPECT().SetHDR(float64(0)).Run(func(float64) { calls = append(calls, "sdr") }).Return().Once()
91- r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Twice()
92- r.EXPECT().ExportTargets(0, []uint64(nil)).Run(func(int, []uint64) { calls = append(calls, "drop") }).Return(nil, nil).Once()
93- r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
91+ r.EXPECT().ExportTargets(2, []uint64{0}, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
92+ r.EXPECT().ExportTargets(0, []uint64(nil), false).Run(func(int, []uint64, bool) { calls = append(calls, "drop") }).Return(nil, nil).Once()
93+ r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
9494 r.EXPECT().UseTarget(mock.Anything).Return()
9595 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
9696 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil)
@@ -108,16 +108,16 @@ func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) {
108108 if o.hdr.on || !o.hdr.failed {
109109 t.Fatalf("HDR on %t failed %t", o.hdr.on, o.hdr.failed)
110110 }
111- if len(*commits) != 4 {
111+ // The HDR images were already linear: refusing them ends HDR at once,
112+ // there is no second identical linear export.
113+ if len(*commits) != 3 {
112114 t.Fatalf("commits: %d", len(*commits))
113115 }
114- for i := range 2 {
115- if (*commits)[i].flags != atomicTestOnly|atomicAllowModes {
116- t.Fatalf("HDR test %d flags: %#x", i, (*commits)[i].flags)
117- }
116+ if (*commits)[0].flags != atomicTestOnly|atomicAllowModes {
117+ t.Fatalf("HDR test flags: %#x", (*commits)[0].flags)
118118 }
119- if (*commits)[2].flags != atomicTestOnly|atomicAllowModes || (*commits)[3].flags != atomicAllowModes {
120- t.Fatalf("SDR commits: %#x %#x", (*commits)[2].flags, (*commits)[3].flags)
119+ if (*commits)[1].flags != atomicTestOnly|atomicAllowModes || (*commits)[2].flags != atomicAllowModes {
120+ t.Fatalf("SDR commits: %#x %#x", (*commits)[1].flags, (*commits)[2].flags)
121121 }
122122 if v, _ := (*commits)[0].req.value(o.conn.id, 100); v != 321 {
123123 t.Fatalf("HDR test metadata: %d", v)
@@ -160,7 +160,7 @@ func TestHDRSuccessfulModeset(t *testing.T) {
160160 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
161161 }
162162 r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Once()
163- r.EXPECT().ExportTargets(2, []uint64{19}).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
163+ r.EXPECT().ExportTargets(2, []uint64{19}, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
164164 r.EXPECT().UseTarget(mock.Anything).Return()
165165 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
166166 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil).Twice()
@@ -201,7 +201,7 @@ func TestHDRBlobReusedAcrossImageSetup(t *testing.T) {
201201 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
202202 }
203203 r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Twice()
204- r.EXPECT().ExportTargets(2, []uint64{19}).RunAndReturn(func(int, []uint64) ([]ports.DMABuf, error) { return []ports.DMABuf{buf(), buf()}, nil }).Twice()
204+ r.EXPECT().ExportTargets(2, []uint64{19}, false).RunAndReturn(func(int, []uint64, bool) ([]ports.DMABuf, error) { return []ports.DMABuf{buf(), buf()}, nil }).Twice()
205205 r.EXPECT().UseTarget(mock.Anything).Return()
206206 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
207207 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil)
@@ -251,7 +251,7 @@ func TestHDRBlobReplacementWaitsForCommit(t *testing.T) {
251251 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
252252 }
253253 r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Twice()
254- r.EXPECT().ExportTargets(2, []uint64{19}).RunAndReturn(func(int, []uint64) ([]ports.DMABuf, error) { return []ports.DMABuf{buf(), buf()}, nil }).Twice()
254+ r.EXPECT().ExportTargets(2, []uint64{19}, false).RunAndReturn(func(int, []uint64, bool) ([]ports.DMABuf, error) { return []ports.DMABuf{buf(), buf()}, nil }).Twice()
255255 r.EXPECT().UseTarget(mock.Anything).Return()
256256 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
257257 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil)
M internal/adapters/drm/images.go +73 -20
@@ -17,10 +17,14 @@ import (
1717 type imageKind int
1818
1919 const (
20- imagesDriver imageKind = iota // exported, modifier chosen by the driver
21- imagesLinear // exported, linear
20+ imagesDriver imageKind = iota // exported, modifier chosen by the driver (DCC planes allowed)
21+ imagesSinglePlane // exported, driver modifier with one memory plane
22+ imagesLinear // exported, linear
2223 )
2324
25+// maxFBPlanes is the number of planes a KMS framebuffer holds (fbCmd2).
26+const maxFBPlanes = 4
27+
2428 // showImages sets up images from kind on and modesets them. KMS may refuse
2529 // an image only in a modeset: a TEST_ONLY modeset checks it first, and the
2630 // next kind is tried on refusal. cause is why the
@@ -57,7 +61,7 @@ func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error
5761 o.hdr.on = false
5862 o.freeImages()
5963 r.SetHDR(0)
60- _, _ = r.ExportTargets(0, nil)
64+ _, _ = r.ExportTargets(0, nil, false)
6165 // A failed test commit can leave the old HDR mode on screen;
6266 // retain its blob until the SDR modeset succeeds or Close restores it.
6367 } else {
@@ -97,9 +101,20 @@ func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) er
97101 o.freeImages()
98102 return err
99103 }
104+ if o.imageMod == 0 {
105+ // The refused images were already linear: the later kinds
106+ // would export the same ones again.
107+ o.freeImages()
108+ return err
109+ }
100110 o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Msg("modeset refused the output images")
101111 o.freeImages()
102112 kind, cause = got+1, err
113+ if kind == imagesSinglePlane && o.planes == 1 {
114+ // The refused images already had one plane: single-plane
115+ // images would be the same ones.
116+ kind = imagesLinear
117+ }
103118 }
104119 }
105120
@@ -112,13 +127,21 @@ func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (ima
112127 if o.runContext != nil && o.runContext.Err() != nil {
113128 return kind, o.runContext.Err()
114129 }
115- mods := []uint64(nil)
130+ // The renderer is asked for the modifiers the primary plane lists
131+ // for the image format: DRM core refuses (ADDFB2: EINVAL) a
132+ // framebuffer whose format and modifier no plane advertises, and
133+ // the renderer's own preference may be such a one (RADV's
134+ // pipe-aligned DCC, which amdgpu does not list for scanout). A
135+ // plane without IN_FORMATS lists every format with modifier 0
136+ // (readPlanes), so it gets linear images straight away rather than
137+ // two refused driver attempts. AMD DCC retile modifiers are never
138+ // exported, whatever the plane lists: they cost an extra
139+ // full-surface GPU pass per frame (vulkan retilesDCC). On a Radeon
140+ // 680M the plane-accepted pick is therefore single-plane tiled
141+ // (0x200000010401b03). Only a plane listing no modifier at all for
142+ // the format (SDR) leaves the choice to the renderer.
143+ mods := o.primaryModifiers(o.imageFormat())
116144 if o.hdr.on {
117- for _, f := range o.primary.formats {
118- if f.Format == fourccXR30 {
119- mods = append(mods, f.Modifier)
120- }
121- }
122145 if len(mods) == 0 {
123146 return kind, fmt.Errorf("primary plane has no XRGB2101010 modifiers")
124147 }
@@ -131,7 +154,12 @@ func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (ima
131154 } else if kind == imagesLinear {
132155 mods = []uint64{0}
133156 }
134- if err = o.exportImages(r, mods); err == nil {
157+ if kind == imagesSinglePlane && len(mods) == 1 && mods[0] == 0 {
158+ // The display only lists linear, already one plane: the driver
159+ // attempt was the single-plane one.
160+ continue
161+ }
162+ if err = o.exportImages(r, mods, kind == imagesSinglePlane); err == nil {
135163 return kind, nil
136164 }
137165 o.log.Info().Err(err).Str("connector", o.conn.name).Int("kind", int(kind)).Msg("output image export")
@@ -139,21 +167,45 @@ func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (ima
139167 return imagesLinear, fmt.Errorf("%s: GPU cannot export scanout images (ADR 014): %w", o.conn.name, err)
140168 }
141169
170+// imageFormat is the framebuffer format of the output images.
171+func (o *Output) imageFormat() uint32 {
172+ if o.hdr.on {
173+ return fourccXR30
174+ }
175+ return fourccXRGB
176+}
177+
178+// primaryModifiers lists the modifiers the primary plane accepts for
179+// format, in IN_FORMATS order.
180+func (o *Output) primaryModifiers(format uint32) []uint64 {
181+ var mods []uint64
182+ for _, f := range o.primary.formats {
183+ if f.Format == format {
184+ mods = append(mods, f.Modifier)
185+ }
186+ }
187+ return mods
188+}
189+
142190 // exportImages makes the renderer's exported targets the output images.
143-func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
144- bufs, err := r.ExportTargets(len(o.fbs), mods)
191+func (o *Output) exportImages(r ports.Renderer, mods []uint64, singlePlane bool) error {
192+ bufs, err := r.ExportTargets(len(o.fbs), mods, singlePlane)
145193 if err != nil {
146194 return err
147195 }
196+ o.planes, o.imageMod = 0, 0
148197 for i := range bufs {
198+ if len(bufs[i].Planes) == 0 || len(bufs[i].Planes) > maxFBPlanes {
199+ err = errors.Join(err, fmt.Errorf("output image has %d planes", len(bufs[i].Planes)))
200+ }
149201 if err == nil {
150- format := uint32(fourccXRGB)
151- if o.hdr.on {
152- format = fourccXR30
153- }
154- o.fbs[i], err = o.k.addFB(&bufs[i], format)
202+ o.fbs[i], err = o.k.addFB(&bufs[i], o.imageFormat())
203+ }
204+ // Every plane owns its file; the framebuffer keeps its own
205+ // reference to the buffer.
206+ for _, p := range bufs[i].Planes {
207+ p.File.Close()
155208 }
156- bufs[i].Planes[0].File.Close()
157209 }
158210 // GPU memory starts undefined (old VRAM contents): clear both images
159211 // before the modeset shows one.
@@ -175,10 +227,11 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
175227 }
176228 if err != nil {
177229 o.freeImages()
178- _, _ = r.ExportTargets(0, nil)
230+ _, _ = r.ExportTargets(0, nil, false)
179231 return err
180232 }
181- o.log.Info().Str("connector", o.conn.name).Uint64("modifier", bufs[0].Modifier).Msg("zero-copy output")
233+ o.planes, o.imageMod = len(bufs[0].Planes), bufs[0].Modifier
234+ o.log.Info().Str("connector", o.conn.name).Uint64("modifier", bufs[0].Modifier).Int("planes", len(bufs[0].Planes)).Msg("zero-copy output")
182235 return nil
183236 }
184237
A internal/adapters/drm/leaving_test.go +84 -0
@@ -0,0 +1,84 @@
1+package drm
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/adapters/surfaces"
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// A window drawn from a content its client withdrew (it closed and fades
11+// out) never goes on a plane: wayland has released its buffer, so the
12+// client may already draw into it. The first leaving scene carries Fade 0
13+// and Zoom 0 (the fade starts at opaque), so the kept flag alone refuses it.
14+func TestLeavingWindowNeverOnAPlane(t *testing.T) {
15+ t.Run("overlay plane, a single leaving tile", func(t *testing.T) {
16+ o, _, _ := overlayOutput(t)
17+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
18+ table := surfaces.New()
19+ o.kept = table.Kept
20+ s, c := overlayScene()
21+ table.Update(c[2], s)
22+ // Window 2 unmaps: its empty content keeps the last one, Acquire
23+ // dropped, while the scene lists it.
24+ table.Update(ports.SurfaceContent{ID: 2}, s)
25+ if !table.Kept(2) {
26+ t.Fatal("setup: content not kept")
27+ }
28+ if _, _, _, reason := overlayCandidate(s, table.Map(), false, nil, o.kept); reason != "leaving" {
29+ t.Fatalf("overlay reason %q, want leaving", reason)
30+ }
31+ ov, rest := o.overlayFrame(s, table.Map())
32+ if ov.fb != 0 || len(rest.Windows) != 2 || o.overlayReason != "leaving" {
33+ t.Fatalf("leaving window put on the overlay: %+v rest %d reason %q", ov, len(rest.Windows), o.overlayReason)
34+ }
35+ // Without the kept flag the same scene goes on the plane: the
36+ // refusal is the flag's.
37+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" {
38+ t.Fatalf("control: reason %q", reason)
39+ }
40+ // A new content of a remapped window frees it again.
41+ table.Update(c[2], s)
42+ if _, _, _, reason := overlayCandidate(s, table.Map(), false, nil, o.kept); reason != "" {
43+ t.Fatalf("remapped window refused: %q", reason)
44+ }
45+ })
46+ t.Run("direct scanout of a lone leaving fullscreen window", func(t *testing.T) {
47+ s, c := fullscreenScene()
48+ table := surfaces.New()
49+ for _, cc := range c {
50+ table.Update(cc, s)
51+ }
52+ id := s.Windows[0].ID
53+ if _, reason := scanoutCandidate(s, table.Map(), 200, 100, table.Kept); reason != "" {
54+ t.Fatalf("control: reason %q", reason)
55+ }
56+ table.Update(ports.SurfaceContent{ID: id}, s)
57+ if _, reason := scanoutCandidate(s, table.Map(), 200, 100, table.Kept); reason != "leaving" {
58+ t.Fatalf("scanout reason %q, want leaving", reason)
59+ }
60+ // Through the output: composed, reason logged.
61+ o, _, _ := testOutput(t)
62+ o.kept = table.Kept
63+ o.scanout = true
64+ if fb, _, _ := o.scanoutFrame(s, table.Map()); fb != 0 || o.reason != "leaving" {
65+ t.Fatalf("direct scanout of a leaving window: fb %d reason %q", fb, o.reason)
66+ }
67+ })
68+ t.Run("not reported shown", func(t *testing.T) {
69+ o, _, _ := testOutput(t)
70+ table := surfaces.New()
71+ o.kept = table.Kept
72+ s, c := overlayScene()
73+ table.Update(c[2], s)
74+ seen := map[ports.WindowID]uint64{2: 3}
75+ if shows := o.shownBy(s, seen); shows[2] != 3 {
76+ t.Fatalf("control: shows %v", shows)
77+ }
78+ table.Update(ports.SurfaceContent{ID: 2, Seq: 4}, s)
79+ seen[2] = 4
80+ if shows := o.shownBy(s, seen); len(shows) != 0 {
81+ t.Fatalf("a leaving window is reported shown: %v", shows)
82+ }
83+ })
84+}
M internal/adapters/drm/output.go +20 -7
@@ -12,6 +12,7 @@ import (
1212 "github.com/bnema/neferwl/internal/adapters/capture"
1313 "github.com/bnema/neferwl/internal/adapters/clock"
1414 "github.com/bnema/neferwl/internal/adapters/presented"
15+ "github.com/bnema/neferwl/internal/adapters/surfaces"
1516
1617 "github.com/bnema/neferwl/internal/ports"
1718 "github.com/bnema/zerowrap"
@@ -35,6 +36,10 @@ type Output struct {
3536 securityInvalid bool
3637 inactiveOnClose bool // affirmative terminal KMS result, owned by Close
3738 runContext context.Context // bounds compositor clear waits during Run startup
39+ // kept reports a window drawn from a content its client withdrew (it
40+ // closed and fades out; surfaces.Table.Kept): composed only, never on
41+ // a plane, never reported shown. Set by Run; nil before.
42+ kept func(ports.WindowID) bool
3843
3944 k kms
4045 flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents
@@ -176,6 +181,10 @@ type Output struct {
176181 protectNotBefore time.Time
177182 // kind is how the images were made.
178183 kind imageKind
184+ // planes is the memory plane count of the last exported images.
185+ planes int
186+ // imageMod is the modifier of the exported images (0: linear).
187+ imageMod uint64
179188 // formats receives the direct scanout formats after each modeset;
180189 // sampled and device are what they are built from (Want).
181190 formats chan<- ports.OutputFormats
@@ -1026,7 +1035,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10261035 o.probeAsync(r)
10271036 }
10281037 }
1029- surfaces := make(map[ports.WindowID]ports.SurfaceContent)
1038+ table := surfaces.New()
1039+ drawn := table.Map()
1040+ o.kept = table.Kept
10301041 var scene ports.Scene
10311042 haveScene, dirty := false, false
10321043 var want ports.CursorChange
@@ -1334,6 +1345,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
13341345 if !o.sceneCurrent(s) {
13351346 continue
13361347 }
1348+ // The core keeps the Seq of a scene that draws the same: nothing to do.
1349+ if haveScene && s.Seq != 0 && s.Seq == scene.Seq && s.Security == scene.Security {
1350+ continue
1351+ }
13371352 if o.protected {
13381353 s.CaptureScene = nil
13391354 if o.wantOff != s.Off {
@@ -1347,6 +1362,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
13471362 }
13481363 scene, haveScene, dirty = s, true, true
13491364 o.wantOff = s.Off
1365+ // A closed window's last content goes once no scene draws it.
1366+ table.Prune(scene)
13501367 case c := <-cursor:
13511368 o.observeSecurity()
13521369 if o.protected {
@@ -1359,11 +1376,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
13591376 stateDirty = true
13601377 }
13611378 case c := <-contents:
1362- if c.Empty() {
1363- delete(surfaces, c.ID)
1364- } else {
1365- surfaces[c.ID] = c
1366- }
1379+ table.Update(c, scene)
13671380 if c.Seq > seen[c.ID] {
13681381 seen[c.ID] = c.Seq
13691382 reportDirty = true
@@ -1463,7 +1476,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14631476 continue
14641477 }
14651478 start := time.Now()
1466- direct, err := o.submitFrame(ctx, r, scene, surfaces, seen, requests, pipeline)
1479+ direct, err := o.submitFrame(ctx, r, scene, drawn, seen, requests, pipeline)
14671480 var fatal renderError
14681481 if errors.As(err, &fatal) || errors.Is(err, errSecurityScene) {
14691482 // A failed render or epoch rejection did not hand these requests to the worker.
M internal/adapters/drm/output_test.go +114 -5
@@ -5,6 +5,7 @@ import (
55 "context"
66 "errors"
77 "image"
8+ "maps"
89 "os"
910 "strings"
1011 "sync"
@@ -565,7 +566,7 @@ func TestRunReportsSeenAfterFlip(t *testing.T) {
565566 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
566567 }
567568 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
568- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
569+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
569570 r.EXPECT().UseTarget(mock.Anything).Return()
570571 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
571572 f, w, _ := os.Pipe()
@@ -651,7 +652,7 @@ func TestRunComposedFullscreenKeepsVRR(t *testing.T) {
651652 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
652653 }
653654 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
654- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
655+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
655656 r.EXPECT().UseTarget(mock.Anything).Return()
656657 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
657658 r.EXPECT().Close().Return().Once()
@@ -790,7 +791,7 @@ func TestCaptureRenderFailureClosesPendingRequest(t *testing.T) {
790791 t.Cleanup(func() { _ = f.Close() })
791792 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
792793 }
793- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{makeBuf(), makeBuf()}, nil).Once()
794+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{makeBuf(), makeBuf()}, nil).Once()
794795 r.EXPECT().Render(ports.Scene{Background: "#000000"}, mock.Anything).Return(nil, nil).Twice()
795796 r.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Background != "#000000" }), mock.Anything).Return(nil, boom).Once()
796797 r.EXPECT().Close().Return().Once()
@@ -852,7 +853,7 @@ func TestCaptureForcesDRMComposition(t *testing.T) {
852853 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
853854 }
854855 r.EXPECT().SetHDR(float64(0)).Return().Maybe() // SDR output
855- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{makeBuf(), makeBuf()}, nil).Once()
856+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{makeBuf(), makeBuf()}, nil).Once()
856857 r.EXPECT().UseTarget(mock.Anything).Return()
857858 rendered := make(chan ports.Scene, 3)
858859 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
@@ -968,7 +969,7 @@ func startStampRun(t *testing.T) *stampRun {
968969 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
969970 }
970971 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
971- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
972+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
972973 r.EXPECT().UseTarget(mock.Anything).Return()
973974 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
974975 r.EXPECT().Close().Return().Once()
@@ -1052,3 +1053,111 @@ func TestRunSecurityResetClearsWantedAt(t *testing.T) {
10521053 t.Fatalf("stamp kept across the security reset: %s", sr.o.wantedAt)
10531054 }
10541055 }
1056+
1057+// A scene whose Seq the output already holds draws nothing new: no frame,
1058+// until a scene with a new Seq arrives.
1059+func TestOutputSameSeqRendersOnce(t *testing.T) {
1060+ sr := startStampRun(t)
1061+ scene := ports.Scene{Seq: 5, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}}
1062+ sr.scenes <- scene
1063+ waitFor(t, func() bool { return sr.frameCommit() == 1 })
1064+ sr.flips <- flipEvent{crtc: tCrtc, user: sr.lastCommit().user, when: time.Second, seq: 1}
1065+ sr.scenes <- scene
1066+ // Unshown content only sends Run round its loop: the flip and the held
1067+ // scene are handled by the time the second one is taken.
1068+ sr.contents <- ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}}
1069+ sr.contents <- ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}}
1070+ if n := sr.frameCommit(); n != 1 {
1071+ t.Fatalf("%d frames after a scene with the held Seq", n)
1072+ }
1073+ scene.Seq = 6
1074+ sr.scenes <- scene
1075+ waitFor(t, func() bool { return sr.frameCommit() == 2 })
1076+ sr.stop()
1077+}
1078+
1079+// Run: the empty content of a window that unmapped while the scene still
1080+// draws it (its close animation) keeps the last content for the renderer,
1081+// without its Acquire fence; a scene that no longer lists the window drops
1082+// it.
1083+func TestRunKeepsLastContentOfLeavingWindow(t *testing.T) {
1084+ o, k, _ := testOutput(t)
1085+ o.cursor, o.tearing = nil, false
1086+ r := portsmocks.NewMockRenderer(t)
1087+ pipeBuf := func() ports.DMABuf {
1088+ f, w, _ := os.Pipe()
1089+ w.Close()
1090+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
1091+ }
1092+ var mu sync.Mutex
1093+ var last map[ports.WindowID]ports.SurfaceContent
1094+ var lastSeq uint64
1095+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
1096+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
1097+ r.EXPECT().UseTarget(mock.Anything).Return()
1098+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
1099+ mu.Lock()
1100+ last, lastSeq = maps.Clone(c), s.Seq
1101+ mu.Unlock()
1102+ f, w, _ := os.Pipe()
1103+ _, _ = w.Write([]byte{1})
1104+ w.Close()
1105+ return f, nil
1106+ })
1107+ r.EXPECT().Close().Return().Once()
1108+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
1109+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
1110+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
1111+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
1112+ // drawn waits for a frame of scene seq and returns what it drew for
1113+ // window 1.
1114+ drawn := func(seq uint64, cond func(ports.SurfaceContent, bool) bool) (ports.SurfaceContent, bool) {
1115+ t.Helper()
1116+ var c ports.SurfaceContent
1117+ var ok bool
1118+ waitFor(t, func() bool {
1119+ mu.Lock()
1120+ defer mu.Unlock()
1121+ c, ok = last[1]
1122+ return lastSeq == seq && cond(c, ok)
1123+ })
1124+ return c, ok
1125+ }
1126+ scenes := make(chan ports.Scene, 1)
1127+ contents := make(chan ports.SurfaceContent, 1)
1128+ ctx, cancel := context.WithCancel(context.Background())
1129+ done := make(chan error, 1)
1130+ go func() {
1131+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents, nil, make(chan ports.OutputPresented, 64), nil, nil)
1132+ }()
1133+ acq, acqW, _ := os.Pipe()
1134+ defer acq.Close()
1135+ defer acqW.Close()
1136+ win := ports.SceneWindow{ID: 1, Rect: ports.Rect{W: 10, H: 10}}
1137+ scenes <- ports.Scene{Seq: 1, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{win}}
1138+ full := ports.SurfaceContent{ID: 1, Seq: 1, Width: 10, Height: 10, DMABuf: &ports.DMABuf{ID: 5, Planes: []ports.DMABufPlane{{}}}, Acquire: acq}
1139+ contents <- full
1140+ drawn(1, func(c ports.SurfaceContent, ok bool) bool { return ok && c.Acquire == acq })
1141+ // The window unmaps: its empty content arrives before the scene that
1142+ // draws it leaving (fading). The last content stays, fence dropped.
1143+ contents <- ports.SurfaceContent{ID: 1, Seq: 2}
1144+ kept, _ := drawn(1, func(c ports.SurfaceContent, ok bool) bool { return !ok || c.Acquire == nil })
1145+ if kept.DMABuf != full.DMABuf || kept.Seq != 1 {
1146+ t.Fatalf("frame after the empty content drew %+v, want the last content", kept)
1147+ }
1148+ // A leaving frame: still drawn from the kept content.
1149+ win.Fade = 0.5
1150+ scenes <- ports.Scene{Seq: 2, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{win}}
1151+ if c, ok := drawn(2, func(ports.SurfaceContent, bool) bool { return true }); !ok || c.DMABuf != full.DMABuf {
1152+ t.Fatalf("the leaving frame drew %+v (ok %t), want the kept content", c, ok)
1153+ }
1154+ // The scene no longer lists it: gone.
1155+ scenes <- ports.Scene{Seq: 3, OutputWidth: 200, OutputHeight: 100}
1156+ if c, ok := drawn(3, func(ports.SurfaceContent, bool) bool { return true }); ok {
1157+ t.Fatalf("the kept content %+v survived a scene without the window", c)
1158+ }
1159+ cancel()
1160+ if err := <-done; err != nil {
1161+ t.Fatal(err)
1162+ }
1163+}
M internal/adapters/drm/overlay.go +17 -5
@@ -59,7 +59,10 @@ type overlayWin struct {
5959 // overlayCandidate finds the one window the overlay can show: an opaque
6060 // dmabuf at integer physical coordinates, drawn 1:1, with no other window
6161 // or layer above it, and the plane colour mode it needs. reason is why none.
62-func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) {
62+//
63+// kept is scanoutCandidate's: a window drawn from a withdrawn content is
64+// composed, never put on the plane.
65+func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) {
6366 if s.Transform != 0 {
6467 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"
6568 }
@@ -82,10 +85,10 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
8285 // A viewport crop is composed: the plane would show the whole
8386 // buffer, and so is a colour the plane cannot show (raw PQ values
8487 // on an SDR output, SDR on HDR without a pipeline). A dimmed
85- // window (a peeking stashed one) needs the veil drawn over it, and
86- // one with the focus effect needs its lift; an overview preview is
87- // drawn smaller than its buffer.
88- if w.Dim <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) {
88+ // window (a peeking stashed one) needs the veil drawn over it, a
89+ // fading one its translucency, and one with the focus effect its
90+ // lift; an overview preview is drawn smaller than its buffer.
91+ if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) {
8992 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
9093 pick, mode = w, m
9194 continue
@@ -97,6 +100,15 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
97100 }
98101 }
99102 if pick == nil {
103+ if kept != nil && len(s.Windows) > 0 {
104+ // The one window that could have gone on the plane fades out
105+ // after its close: composed from its kept content.
106+ for i := range s.Windows {
107+ if w := &s.Windows[i]; !w.Hidden && w.Rect.W > 0 && w.Rect.H > 0 && kept(w.ID) {
108+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "leaving"
109+ }
110+ }
111+ }
100112 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "no_candidate"
101113 }
102114 if s.Dim > 0 {
M internal/adapters/drm/overlay_test.go +30 -18
@@ -43,7 +43,7 @@ func overlayScene() (ports.Scene, map[ports.WindowID]ports.SurfaceContent) {
4343 func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) {
4444 s, contents := fullscreenScene()
4545 s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}
46- if _, reason := scanoutCandidate(s, contents, 200, 100); reason != "workspace_clip" {
46+ if _, reason := scanoutCandidate(s, contents, 200, 100, nil); reason != "workspace_clip" {
4747 t.Fatalf("fullscreen bypassed clip: %q", reason)
4848 }
4949 if fullscreenShown(&s) {
@@ -54,28 +54,28 @@ func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) {
5454 func TestOverlayWorkspaceClip(t *testing.T) {
5555 s, contents := overlayScene()
5656 s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}
57- if _, _, _, reason := overlayCandidate(s, contents, false, nil); reason != "workspace_clip" {
57+ if _, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "workspace_clip" {
5858 t.Fatalf("overflowing workspace overlay: %q", reason)
5959 }
6060 s.WorkspaceClip = ports.Rect{X: 100, W: 100, H: 100}
61- if win, _, _, reason := overlayCandidate(s, contents, false, nil); reason != "" || win.ID != 2 {
61+ if win, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "" || win.ID != 2 {
6262 t.Fatalf("contained workspace overlay: %v %q", win.ID, reason)
6363 }
6464 }
6565
6666 func TestOverlayCandidate(t *testing.T) {
6767 s, c := overlayScene()
68- if w, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" || w.ID != 2 {
68+ if w, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" || w.ID != 2 {
6969 t.Fatalf("candidate %v %q", w.ID, reason)
7070 }
7171 // A rotated output composes: planes are not rotated.
7272 rs, rc := overlayScene()
7373 rs.Transform = 1
74- if _, _, _, reason := overlayCandidate(rs, rc, false, nil); reason != "output_transform" {
74+ if _, _, _, reason := overlayCandidate(rs, rc, false, nil, nil); reason != "output_transform" {
7575 t.Fatalf("rotated output reason %q", reason)
7676 }
7777 c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}}
78- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
78+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
7979 t.Fatalf("YUV overlay reason %q", reason)
8080 }
8181 // A transformed buffer is composed: the plane would show it unrotated.
@@ -83,46 +83,58 @@ func TestOverlayCandidate(t *testing.T) {
8383 rotated := c[2]
8484 rotated.Transform = 1
8585 c[2] = rotated
86- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
86+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
8787 t.Fatalf("transformed overlay reason %q", reason)
8888 }
8989 _, c = overlayScene()
9090 s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}})
91- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "window_above" {
91+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "window_above" {
9292 t.Fatalf("reason %q", reason)
9393 }
9494 s, _ = overlayScene()
9595 s.Scale = 1.5
96- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "scaled" {
96+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "scaled" {
9797 t.Fatalf("reason %q", reason)
9898 }
9999 s, _ = overlayScene()
100100 s.Layers = []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 5, H: 5}}}
101- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "layer_above" {
101+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "layer_above" {
102102 t.Fatalf("reason %q", reason)
103103 }
104104 // The dim veil is composed under the float: nothing leaves for the plane.
105105 s, _ = overlayScene()
106106 s.Dim = 0.3
107- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "dim" {
107+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "dim" {
108108 t.Fatalf("dim reason %q", reason)
109109 }
110110 // A dimmed window (a peeking stashed one) needs its veil composed.
111111 s, _ = overlayScene()
112112 s.Windows[len(s.Windows)-1].Dim = 0.5
113- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
113+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
114114 t.Fatalf("dimmed window reason %q", reason)
115115 }
116+ // A fading window needs its translucency composed.
117+ s, _ = overlayScene()
118+ s.Windows[len(s.Windows)-1].Fade = 0.5
119+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
120+ t.Fatalf("fading window reason %q", reason)
121+ }
122+ // A zoomed (animating) window is drawn smaller than its buffer.
123+ s, _ = overlayScene()
124+ s.Windows[len(s.Windows)-1].Zoom = 0.9
125+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
126+ t.Fatalf("zoomed window reason %q", reason)
127+ }
116128 // A window with the focus effect needs it composed.
117129 s, _ = overlayScene()
118130 s.Windows[len(s.Windows)-1].FocusEffect = 0.05
119- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
131+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
120132 t.Fatalf("pulsing window reason %q", reason)
121133 }
122134 // An overview preview is drawn smaller than its buffer: composed.
123135 s, _ = overlayScene()
124136 s.Windows[len(s.Windows)-1].Preview = 0.5
125- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
137+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
126138 t.Fatalf("preview reason %q", reason)
127139 }
128140 // A viewport crop is composed: the plane would show the whole buffer.
@@ -130,13 +142,13 @@ func TestOverlayCandidate(t *testing.T) {
130142 cropped := c[2]
131143 cropped.Source = [4]float32{10, 10, 50, 50}
132144 c[2] = cropped
133- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
145+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
134146 t.Fatalf("cropped overlay reason %q", reason)
135147 }
136148 // A full-buffer source is no crop.
137149 cropped.Source = [4]float32{0, 0, 100, 100}
138150 c[2] = cropped
139- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" {
151+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" {
140152 t.Fatalf("full source reason %q", reason)
141153 }
142154 }
@@ -212,12 +224,12 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) {
212224 func TestOverlayPopupAndBorder(t *testing.T) {
213225 s, c := overlayScene()
214226 s.Windows = append([]ports.SceneWindow{{ID: 7, Popup: true, Rect: ports.Rect{W: 5, H: 5}}}, s.Windows...)
215- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "window_above" {
227+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "window_above" {
216228 t.Fatalf("popup: %q", reason)
217229 }
218230 s, _ = overlayScene()
219231 s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}}
220- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "border" {
232+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "border" {
221233 t.Fatalf("border: %q", reason)
222234 }
223235 }
M internal/adapters/drm/power_test.go +2 -2
@@ -25,7 +25,7 @@ func TestRunOutputPower(t *testing.T) {
2525 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
2626 }
2727 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
28- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
28+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
2929 r.EXPECT().UseTarget(mock.Anything).Return()
3030 renders := make(chan struct{}, 8)
3131 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
@@ -133,7 +133,7 @@ func TestRunOutputStartOff(t *testing.T) {
133133 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
134134 }
135135 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
136- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
136+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
137137 r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
138138 renders := make(chan struct{}, 8)
139139 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
M internal/adapters/drm/protection_retry_test.go +2 -2
@@ -32,7 +32,7 @@ func runProtectedStartup(t *testing.T, o *Output, k *mockkms, renders func() (*o
3232 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
3333 }
3434 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
35- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
35+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
3636 r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
3737 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
3838 return renders()
@@ -266,7 +266,7 @@ func TestProtectedShowImagesHDRKeepsHDRForKMSErrors(t *testing.T) {
266266 r.EXPECT().SetHDR(float64(203)).Return().Once()
267267 r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
268268 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Maybe()
269- r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
269+ r.EXPECT().ExportTargets(2, []uint64{0}, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
270270 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil).Maybe()
271271 k.EXPECT().createBlob(mock.Anything).Return(321, nil).Maybe()
272272 k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
M internal/adapters/drm/report.go +3 -1
@@ -30,7 +30,9 @@ func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[port
3030 }
3131 clear(o.showsScratch)
3232 for id, seq := range seen {
33- if s.Shows(id) {
33+ // A closed window drawn from its kept content is not shown to its
34+ // client: the empty content has no frame to present.
35+ if s.Shows(id) && (o.kept == nil || !o.kept(id)) {
3436 o.showsScratch[id] = seq
3537 }
3638 }
M internal/adapters/drm/scanout.go +15 -3
@@ -85,7 +85,11 @@ func fullscreenShown(s *ports.Scene) bool {
8585 // scanoutCandidate returns the window content the output can scan out
8686 // directly, or a reason why it cannot. w, h are the output's physical size.
8787 // The plane's formats are checked by scanoutFB.
88-func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) {
88+//
89+// kept reports a window drawn from a content its client withdrew (it
90+// closed and fades out): the client may already have its buffer back, so
91+// it is never put on a plane (nil: none is).
92+func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) {
8993 if s.Transform != 0 {
9094 // The scanout buffer would have to be pre-rotated: rotated outputs compose.
9195 return ports.SurfaceContent{}, "output_transform"
@@ -107,6 +111,9 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
107111 if full == nil {
108112 return ports.SurfaceContent{}, "no_fullscreen"
109113 }
114+ if kept != nil && kept(full.ID) {
115+ return ports.SurfaceContent{}, "leaving"
116+ }
110117 if len(s.DropHints) > 0 {
111118 // Drag hints are drawn over the window.
112119 return ports.SurfaceContent{}, "drop_hint"
@@ -115,6 +122,11 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
115122 // Its veil is composed over it: the plane would show it bright.
116123 return ports.SurfaceContent{}, "dim"
117124 }
125+ if full.Fade > 0 || full.Preview > 0 || full.Zoom > 0 {
126+ // A fading or zoomed window is composed: the plane would show it
127+ // whole and opaque.
128+ return ports.SurfaceContent{}, "window_fade"
129+ }
118130 // Core leaves the layers a fullscreen window hides out of the scene:
119131 // one above the windows here is drawn.
120132 for _, l := range s.Layers {
@@ -200,7 +212,7 @@ func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) {
200212 func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent, mode colorMode) {
201213 reason := "disabled"
202214 if o.scanout {
203- c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
215+ c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height(), o.kept)
204216 if reason == "" {
205217 mode, reason = planeColor(c.Color, c.DMABuf.Format, c.Opaque, o.hdr.on, o.primary.pipeline)
206218 }
@@ -255,7 +267,7 @@ func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S
255267 var w ports.SceneWindow
256268 var c ports.SurfaceContent
257269 var mode colorMode
258- w, c, mode, reason = overlayCandidate(s, surfaces, o.hdr.on, o.overlay.pipeline)
270+ w, c, mode, reason = overlayCandidate(s, surfaces, o.hdr.on, o.overlay.pipeline, o.kept)
259271 if reason == "" && mode != colorBypass {
260272 if ok, known := o.overlay.verdict(c.DMABuf.Format, o.cursorShown()); known && !ok {
261273 reason = "color_refused"
M internal/adapters/drm/scanout_test.go +7 -4
@@ -27,6 +27,9 @@ func TestScanoutCandidate(t *testing.T) {
2727 {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"},
2828 {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"},
2929 {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"},
30+ {"window fade", func(s *ports.Scene) { s.Windows[0].Fade = 0.5 }, nil, "window_fade"},
31+ {"window preview", func(s *ports.Scene) { s.Windows[0].Preview = 0.9 }, nil, "window_fade"},
32+ {"window zoom", func(s *ports.Scene) { s.Windows[0].Zoom = 0.9 }, nil, "window_fade"},
3033 {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"},
3134 {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"},
3235 {"float above", func(s *ports.Scene) {
@@ -73,7 +76,7 @@ func TestScanoutCandidate(t *testing.T) {
7376 if tc.cont != nil {
7477 tc.cont(&c)
7578 }
76- if _, reason := scanoutCandidate(s, map[ports.WindowID]ports.SurfaceContent{1: c}, 200, 100); reason != tc.reason {
79+ if _, reason := scanoutCandidate(s, map[ports.WindowID]ports.SurfaceContent{1: c}, 200, 100, nil); reason != tc.reason {
7780 t.Errorf("%s: reason %q, want %q", tc.name, reason, tc.reason)
7881 }
7982 }
@@ -102,7 +105,7 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) {
102105 root := ports.SurfaceContent{ID: 1, Seq: 7, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, SHM: &ports.SHMBuffer{},
103106 Geometry: ports.Rect{W: 100, H: 50},
104107 Children: []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{Seq: 3, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, Opaque: true, DMABuf: game, Async: true, Acquire: acquire}}}}
105- c, reason := scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: root}, 200, 100)
108+ c, reason := scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: root}, 200, 100, nil)
106109 if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) {
107110 t.Fatalf("candidate %+v, reason %q", c, reason)
108111 }
@@ -111,12 +114,12 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) {
111114 // one; alone it is not a dmabuf.
112115 solidRoot := root
113116 solidRoot.SHM, solidRoot.Solid = nil, &ports.SolidColor{A: 1}
114- c, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100)
117+ c, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100, nil)
115118 if reason != "" || c.DMABuf != game || c.Solid != nil || c.ID != 1 || c.Seq != 7 {
116119 t.Fatalf("solid root with dmabuf child: candidate %+v, reason %q", c, reason)
117120 }
118121 solidRoot.Children = nil
119- if _, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100); reason != "not_dmabuf" {
122+ if _, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100, nil); reason != "not_dmabuf" {
120123 t.Fatalf("solid root alone: reason %q, want not_dmabuf", reason)
121124 }
122125 }
M internal/adapters/drm/security_recovery_test.go +1 -1
@@ -31,7 +31,7 @@ func TestSecurityStuckRecoveryInvalidatesBeforeFreshProof(t *testing.T) {
3131 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
3232 }
3333 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
34- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
34+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
3535 r.EXPECT().UseTarget(mock.Anything).Return()
3636 fence, signal := protectionFence(t, false)
3737 var renderedOnce atomic.Bool
M internal/adapters/drm/security_run_test.go +1 -1
@@ -32,7 +32,7 @@ func TestSecurityRunWakePendingResumeAndOff(t *testing.T) {
3232 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
3333 }
3434 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
35- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
35+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
3636 r.EXPECT().UseTarget(mock.Anything).Return()
3737 black := make(chan struct{}, 8)
3838 desktop := make(chan struct{}, 2)
M internal/adapters/drm/security_startup_test.go +3 -3
@@ -28,7 +28,7 @@ func TestSecurityProtectedStartupBlackProofAndCaptureRejection(t *testing.T) {
2828 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
2929 }
3030 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
31- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
31+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
3232 r.EXPECT().UseTarget(mock.Anything).Return()
3333 r.EXPECT().Render(ports.Scene{Background: "#000000"}, map[ports.WindowID]ports.SurfaceContent(nil)).Return(nil, nil).Times(3)
3434 r.EXPECT().Close().Return().Once()
@@ -94,8 +94,8 @@ func TestSecurityStartupClearWaitUsesRunContext(t *testing.T) {
9494 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
9595 }
9696 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
97- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
98- r.EXPECT().ExportTargets(0, []uint64(nil)).Return(nil, nil).Once()
97+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
98+ r.EXPECT().ExportTargets(0, []uint64(nil), false).Return(nil, nil).Once()
9999 r.EXPECT().UseTarget(0).Return().Once()
100100 fence, _ := protectionFence(t, false)
101101 ctx, cancel := context.WithCancel(context.Background())
M internal/adapters/drm/stuck_test.go +1 -1
@@ -52,7 +52,7 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun {
5252 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
5353 }
5454 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
55- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
55+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
5656 r.EXPECT().UseTarget(mock.Anything).Return()
5757 var started atomic.Bool
5858 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
M internal/adapters/drm/vrr_flip_gap_test.go +1 -1
@@ -92,7 +92,7 @@ func TestRunWaitsVRRFlipGap(t *testing.T) {
9292 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
9393 }
9494 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
95- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
95+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
9696 r.EXPECT().UseTarget(mock.Anything).Return()
9797 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
9898 r.EXPECT().Close().Return().Once()
A internal/adapters/headless/leaving_test.go +25 -0
@@ -0,0 +1,25 @@
1+package headless
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/adapters/surfaces"
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// A window drawn from a content its client withdrew (closed, fading out) is
11+// not reported shown: the empty content has no frame to present.
12+func TestFlipInfoSkipsKeptContent(t *testing.T) {
13+ table := surfaces.New()
14+ s := ports.Scene{OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 1, H: 1}}}}
15+ table.Update(ports.SurfaceContent{ID: 1, Seq: 3, SHM: &ports.SHMBuffer{Pool: 1}}, s)
16+ table.Update(ports.SurfaceContent{ID: 2, Seq: 1, SHM: &ports.SHMBuffer{Pool: 2}}, s)
17+ if f := flipInfo(s, table.Map(), table.Kept); f.Shows[1] != 3 || f.Shows[2] != 1 {
18+ t.Fatalf("control: %v", f.Shows)
19+ }
20+ table.Update(ports.SurfaceContent{ID: 1, Seq: 4}, s)
21+ f := flipInfo(s, table.Map(), table.Kept)
22+ if _, ok := f.Shows[1]; ok || f.Shows[2] != 1 {
23+ t.Fatalf("a leaving window is reported shown: %v", f.Shows)
24+ }
25+}
M internal/adapters/headless/output.go +24 -16
@@ -15,6 +15,7 @@ import (
1515 "github.com/bnema/neferwl/internal/adapters/capture"
1616 "github.com/bnema/neferwl/internal/adapters/clock"
1717 "github.com/bnema/neferwl/internal/adapters/presented"
18+ "github.com/bnema/neferwl/internal/adapters/surfaces"
1819 "github.com/bnema/neferwl/internal/adapters/syncfile"
1920 "github.com/bnema/neferwl/internal/ports"
2021 "github.com/bnema/zerowrap"
@@ -66,10 +67,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
6667 var confirmed *ports.OutputHDR
6768 if opts.HDR {
6869 r.SetHDR(203)
69- if bufs, err := r.ExportTargets(1, nil); err != nil {
70+ if bufs, err := r.ExportTargets(1, nil, false); err != nil {
7071 opts.Log.Warn().Err(err).Str("output", opts.Name).Msg("virtual HDR unavailable; falling back to SDR")
7172 r.SetHDR(0)
72- _, _ = r.ExportTargets(0, nil)
73+ _, _ = r.ExportTargets(0, nil, false)
7374 } else {
7475 for _, b := range bufs {
7576 for _, p := range b.Planes {
@@ -87,7 +88,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
8788 }
8889 }
8990 }
90- surfaces := make(map[ports.WindowID]ports.SurfaceContent)
91+ table := surfaces.New()
92+ drawn := table.Map()
9193 var scene ports.Scene
9294 haveScene, dirty := false, false
9395 frame := 0
@@ -199,16 +201,18 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
199201 if opts.Security != nil && (s.Security != security || s.Security != opts.Security.Snapshot()) {
200202 return
201203 }
204+ // The core keeps the Seq of a scene that draws the same: nothing to do.
205+ if haveScene && s.Seq != 0 && s.Seq == scene.Seq && s.Security == scene.Security {
206+ return
207+ }
202208 scene, haveScene, dirty = s, true, true
209+ // A closed window's last content goes once no scene draws it.
210+ table.Prune(scene)
203211 }
204212 update := func(c ports.SurfaceContent) {
205213 seen[c.ID] = max(seen[c.ID], c.Seq)
206214 dirty = dirty || capture.Shows(scene, c.ID)
207- if c.Empty() {
208- delete(surfaces, c.ID)
209- } else {
210- surfaces[c.ID] = c
211- }
215+ table.Update(c, scene)
212216 }
213217 clk := opts.Clock
214218 if clk == nil {
@@ -469,7 +473,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
469473 failRequests("security epoch changed")
470474 continue
471475 }
472- pipeline.SubmitHidden(scene, surfaces, hidden)
476+ pipeline.SubmitHidden(scene, drawn, hidden)
473477 clear(hidden)
474478 // A slow child may outlive Wayland's stale-report timeout even
475479 // though this owner waits. Publish its non-expiring holds first.
@@ -490,7 +494,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
490494 }
491495 }
492496 // Captures never show the capture indicator: their frame goes first.
493- took, pdone, perr := pipeline.SubmitPlain(r, scene, surfaces, normal)
497+ took, pdone, perr := pipeline.SubmitPlain(r, scene, drawn, normal)
494498 if pdone != nil {
495499 // The plain render reads client buffers even when its requests
496500 // are rejected by a gate transition: own and wait its fence
@@ -517,7 +521,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
517521 failRequests("security epoch changed")
518522 continue
519523 }
520- xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), surfaces)
524+ xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn)
521525 if xdone != nil {
522526 // The excluded-frame composition reads client buffers even when the displayed
523527 // frame is skipped by a gate/off transition. Own and wait its
@@ -545,7 +549,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
545549 failRequests("security epoch changed")
546550 continue
547551 }
548- done, err := r.Render(scene, surfaces)
552+ done, err := r.Render(scene, drawn)
549553 if err != nil {
550554 return fmt.Errorf("render frame: %w", err)
551555 }
@@ -575,7 +579,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
575579 pipeline.EndGate(nil)
576580 requests = capture.Waiting(requests, scene)
577581 frame++
578- opts.report(&reports, flipInfo(scene, surfaces), seen)
582+ opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen)
579583 if opts.ScreenshotDir != "" && !opts.protected() {
580584 shot := r.Pixels()
581585 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
@@ -613,7 +617,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
613617 }
614618 }
615619 }
616- opts.Log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame")
620+ if ev := opts.Log.Debug(); ev.Enabled() {
621+ ev.Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Int("redrawn", r.TakeRedrawn()).Msg("frame")
622+ }
617623 }
618624 }
619625
@@ -665,12 +671,14 @@ func (opts Options) flush(q *presented.Queue) {
665671 // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
666672 // time (software clock, refresh unknown), so presentation feedback and
667673 // frame callbacks follow headless frames.
668-func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) *ports.FlipInfo {
674+func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo {
669675 var ts unix.Timespec
670676 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
671677 shows := map[ports.WindowID]uint64{}
672678 for id, c := range surfaces {
673- if scene.Shows(id) {
679+ // A closed window drawn from its kept content is not shown to
680+ // its client.
681+ if scene.Shows(id) && !kept(id) {
674682 shows[id] = c.Seq
675683 }
676684 }
M internal/adapters/headless/output_test.go +47 -6
@@ -84,18 +84,27 @@ func TestRun(t *testing.T) {
8484 if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {
8585 t.Errorf("coalescing: %+v %+v", s, c)
8686 }
87+ // An empty content of a window the scene still draws (it unmapped
88+ // and fades out) keeps the last one; a scene without it drops it.
8789 contents <- ports.SurfaceContent{ID: 1}
8890 waitFrames(t, f, 2)
8991 if _, c := f.snapshot(); len(c) < 2 {
9092 t.Fatal("no second frame")
91- } else if _, ok := c[1][1]; ok {
93+ } else if kept, ok := c[1][1]; !ok || kept.SHM == nil || kept.SHM.Pool != 1 {
94+ t.Errorf("content of the shown window dropped on its empty content: %+v", c[1])
95+ }
96+ scenes <- ports.Scene{Seq: 3, OutputWidth: 2, OutputHeight: 2, Windows: shown[1:]}
97+ waitFrames(t, f, 3)
98+ if _, c := f.snapshot(); len(c) < 3 {
99+ t.Fatal("no third frame")
100+ } else if _, ok := c[2][1]; ok {
92101 t.Error("content not deleted")
93102 }
94103 cancel()
95104 if err := <-done; err != nil {
96105 t.Fatal(err)
97106 }
98- for _, name := range []string{"frame-000001.png", "frame-000002.png", "latest.png"} {
107+ for _, name := range []string{"frame-000001.png", "frame-000002.png", "frame-000003.png", "latest.png"} {
99108 file, err := os.Open(filepath.Join(dir, name))
100109 if err != nil {
101110 t.Fatal(err)
@@ -108,6 +117,38 @@ func TestRun(t *testing.T) {
108117 }
109118 }
110119
120+// A scene whose Seq the output already holds is not drawn again.
121+func TestHeadlessSameSeqRendersOnce(t *testing.T) {
122+ scenes := make(chan ports.Scene)
123+ contents := make(chan ports.SurfaceContent)
124+ r, f := recordingRenderer(t, nil)
125+ ctx, cancel := context.WithCancel(context.Background())
126+ done := make(chan error, 1)
127+ go func() {
128+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
129+ }()
130+ scene := ports.Scene{Seq: 5, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}}}
131+ scenes <- scene
132+ waitFrames(t, f, 1)
133+ scenes <- scene
134+ // Unshown content sends Run round its loop: the held scene is handled.
135+ contents <- ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}}
136+ contents <- ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}}
137+ if s, _ := f.snapshot(); len(s) != 1 {
138+ t.Fatalf("%d frames for a scene with the held Seq", len(s))
139+ }
140+ scene.Seq = 6
141+ scenes <- scene
142+ waitFrames(t, f, 2)
143+ cancel()
144+ if err := <-done; err != nil {
145+ t.Fatal(err)
146+ }
147+ if s, _ := f.snapshot(); len(s) != 2 {
148+ t.Fatalf("%d frames, want 2", len(s))
149+ }
150+}
151+
111152 // Content of a window the output does not show draws no frame.
112153 func TestRunSkipsContentNotShown(t *testing.T) {
113154 scenes := make(chan ports.Scene, 1)
@@ -308,7 +349,7 @@ func TestHeadlessRawHDRScreenshot(t *testing.T) {
308349 }
309350 r := portsmocks.NewMockRenderer(t)
310351 r.EXPECT().SetHDR(float64(203)).Return().Once()
311- r.EXPECT().ExportTargets(1, []uint64(nil)).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
352+ r.EXPECT().ExportTargets(1, []uint64(nil), false).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
312353 rendered := make(chan struct{}, 4)
313354 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
314355 rendered <- struct{}{}
@@ -381,16 +422,16 @@ func TestHeadlessHDRFormatsConfirmed(t *testing.T) {
381422 if err != nil {
382423 t.Fatal(err)
383424 }
384- r.EXPECT().ExportTargets(1, []uint64(nil)).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
425+ r.EXPECT().ExportTargets(1, []uint64(nil), false).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
385426 t.Cleanup(func() {
386427 if _, err := f.Stat(); err == nil {
387428 t.Error("exported fd not closed")
388429 }
389430 })
390431 } else {
391- r.EXPECT().ExportTargets(1, []uint64(nil)).Return(nil, errors.New("no compatible target")).Once()
432+ r.EXPECT().ExportTargets(1, []uint64(nil), false).Return(nil, errors.New("no compatible target")).Once()
392433 r.EXPECT().SetHDR(float64(0)).Return().Once()
393- r.EXPECT().ExportTargets(0, []uint64(nil)).Return(nil, nil).Once()
434+ r.EXPECT().ExportTargets(0, []uint64(nil), false).Return(nil, nil).Once()
394435 }
395436 r.EXPECT().Close().Return().Once()
396437 formats := make(chan ports.OutputFormats, 1)
M internal/adapters/headless/security_test.go +1 -1
@@ -267,7 +267,7 @@ func TestSecurityRawHDRSuppressed(t *testing.T) {
267267 }
268268 r := portsmocks.NewMockRenderer(t)
269269 r.EXPECT().SetHDR(float64(203)).Return().Once()
270- r.EXPECT().ExportTargets(1, []uint64(nil)).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
270+ r.EXPECT().ExportTargets(1, []uint64(nil), false).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
271271 rendered := make(chan ports.Scene, 4)
272272 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
273273 rendered <- s
A internal/adapters/surfaces/surfaces.go +100 -0
@@ -0,0 +1,100 @@
1+// Package surfaces keeps the latest content of every window for an output's
2+// renderer, and the last content of a window that closed while the scene
3+// still draws it.
4+package surfaces
5+
6+import (
7+ "github.com/bnema/neferwl/internal/adapters/capture"
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// Table is what an output passes to Renderer.Render: the latest content per
12+// window. Its owner goroutine is the only user.
13+//
14+// A window that unmaps sends an empty content right after the core learns
15+// of it; the scene that follows may keep drawing the window while it fades
16+// out (ports.SceneWindow of a leaving window). The table then keeps the
17+// window's last content until a scene no longer lists it, so the renderer
18+// draws it from the buffer it already imported or copied: no new upload,
19+// no copy. Its Acquire fence is dropped: the first frame that drew the
20+// content waited on it, and wayland closes it once the buffer is released.
21+//
22+// Buffer lifetime: wayland releases the buffer to the client once the
23+// output reports the empty content as seen, so the client may draw into,
24+// destroy or reuse it during the fade. A kept content is therefore only
25+// ever composed by the renderer, from what it already holds: the dmabuf
26+// import (its own duplicated descriptors keep the memory alive; a reused
27+// buffer shows stale pixels at worst) or the GPU copy of the wl_shm buffer
28+// (the GPU never reads the pool mapping, and a pool's fd close does not
29+// unmap it). A kept content is never scanned out on a plane and never
30+// reported as shown: outputs ask Kept. Window capture never reaches it:
31+// core resolves a window session through its workspace, which the closed
32+// window has left, so the session ends (CaptureReasonWindowGone) before
33+// the leaving scene; an output or workspace capture composes the fading
34+// window like the screen does.
35+// Every Acquire fence of the kept content (root and children) is dropped:
36+// the frame that first drew it waited on them, and wayland closes them
37+// when the buffers are released.
38+type Table struct {
39+ m map[ports.WindowID]ports.SurfaceContent
40+ // kept are the windows drawn from a content their client withdrew.
41+ kept map[ports.WindowID]struct{}
42+}
43+
44+// New returns an empty table.
45+func New() *Table {
46+ return &Table{m: make(map[ports.WindowID]ports.SurfaceContent), kept: make(map[ports.WindowID]struct{})}
47+}
48+
49+// Map is the contents the renderer draws from. It is the table's own map:
50+// read it, never keep it across an Update or Prune.
51+func (t *Table) Map() map[ports.WindowID]ports.SurfaceContent { return t.m }
52+
53+// Update takes a window's new content. An empty one (the window unmapped)
54+// keeps the previous content, without its Acquire fence, while scene draws
55+// the window; it drops it otherwise.
56+func (t *Table) Update(c ports.SurfaceContent, scene ports.Scene) {
57+ if !c.Empty() {
58+ t.m[c.ID] = c
59+ delete(t.kept, c.ID)
60+ return
61+ }
62+ prev, ok := t.m[c.ID]
63+ if !ok || !capture.Shows(scene, c.ID) {
64+ delete(t.m, c.ID)
65+ delete(t.kept, c.ID)
66+ return
67+ }
68+ prev.Acquire = nil
69+ if len(prev.Children) > 0 {
70+ // The slice is shared with wayland's publication: copy before
71+ // zeroing (one allocation per unmap).
72+ children := make([]ports.Subsurface, len(prev.Children))
73+ copy(children, prev.Children)
74+ for i := range children {
75+ children[i].Acquire = nil
76+ }
77+ prev.Children = children
78+ }
79+ t.m[c.ID] = prev
80+ t.kept[c.ID] = struct{}{}
81+}
82+
83+// Prune drops the kept contents of windows scene no longer draws. Outputs
84+// call it with every scene they accept.
85+func (t *Table) Prune(scene ports.Scene) {
86+ for id := range t.kept {
87+ if !capture.Shows(scene, id) {
88+ delete(t.m, id)
89+ delete(t.kept, id)
90+ }
91+ }
92+}
93+
94+// Kept reports whether id is drawn from a content its client withdrew: a
95+// buffer the client may have got back. Outputs compose it only: no plane
96+// scans it out, no capture or presentation report counts it as shown.
97+func (t *Table) Kept(id ports.WindowID) bool {
98+ _, ok := t.kept[id]
99+ return ok
100+}
A internal/adapters/surfaces/surfaces_test.go +124 -0
@@ -0,0 +1,124 @@
1+package surfaces
2+
3+import (
4+ "os"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func shows(ids ...ports.WindowID) ports.Scene {
11+ s := ports.Scene{OutputWidth: 100, OutputHeight: 100}
12+ for _, id := range ids {
13+ s.Windows = append(s.Windows, ports.SceneWindow{ID: id, Rect: ports.Rect{W: 10, H: 10}})
14+ }
15+ return s
16+}
17+
18+// An empty content of a window the scene draws keeps the previous one with
19+// its Acquire fence dropped; the content goes once a scene no longer lists
20+// the window.
21+func TestEmptyContentKeptWhileShown(t *testing.T) {
22+ tb := New()
23+ r, w, err := os.Pipe()
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ defer r.Close()
28+ defer w.Close()
29+ full := ports.SurfaceContent{ID: 1, Seq: 4, Width: 10, Height: 10, DMABuf: &ports.DMABuf{ID: 7}, Acquire: r}
30+ tb.Update(full, shows(1))
31+ tb.Update(ports.SurfaceContent{ID: 1, Seq: 5}, shows(1))
32+ kept, ok := tb.Map()[1]
33+ if !ok || kept.DMABuf != full.DMABuf || kept.Seq != 4 {
34+ t.Fatalf("kept %+v, want the previous content", kept)
35+ }
36+ if kept.Acquire != nil {
37+ t.Fatal("the kept content keeps its Acquire fence")
38+ }
39+ if !tb.Kept(1) {
40+ t.Fatal("Kept false for a content drawn after its empty one")
41+ }
42+ // A scene still listing it keeps it; one without it drops it.
43+ tb.Prune(shows(1, 2))
44+ if _, ok := tb.Map()[1]; !ok {
45+ t.Fatal("dropped while still listed")
46+ }
47+ tb.Prune(shows(2))
48+ if _, ok := tb.Map()[1]; ok || tb.Kept(1) {
49+ t.Fatal("not dropped once unlisted")
50+ }
51+}
52+
53+// An empty content of a window the scene does not draw drops it at once.
54+func TestEmptyContentOfUnshownWindowDropped(t *testing.T) {
55+ tb := New()
56+ tb.Update(ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}}, shows(1))
57+ tb.Update(ports.SurfaceContent{ID: 1}, shows(2))
58+ if _, ok := tb.Map()[1]; ok || tb.Kept(1) {
59+ t.Fatal("content of an unshown window kept")
60+ }
61+ // Hidden counts as not drawn.
62+ tb.Update(ports.SurfaceContent{ID: 3, SHM: &ports.SHMBuffer{Pool: 3}}, shows())
63+ hidden := ports.Scene{OutputWidth: 100, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 3, Hidden: true, Rect: ports.Rect{W: 10, H: 10}}}}
64+ tb.Update(ports.SurfaceContent{ID: 3}, hidden)
65+ if _, ok := tb.Map()[3]; ok {
66+ t.Fatal("content of a hidden window kept")
67+ }
68+}
69+
70+// A new content replaces a kept one and the window is no longer kept (the
71+// client mapped again).
72+func TestNewContentReplacesKept(t *testing.T) {
73+ tb := New()
74+ tb.Update(ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}}, shows(1))
75+ tb.Update(ports.SurfaceContent{ID: 1, Seq: 2}, shows(1))
76+ tb.Update(ports.SurfaceContent{ID: 1, Seq: 3, SHM: &ports.SHMBuffer{Pool: 2}}, shows(1))
77+ if c := tb.Map()[1]; c.Seq != 3 || c.SHM.Pool != 2 || tb.Kept(1) {
78+ t.Fatalf("after a new content: %+v kept %t", c, tb.Kept(1))
79+ }
80+ // The map handed out is the table's own: the renderer sees updates.
81+ m := tb.Map()
82+ tb.Update(ports.SurfaceContent{ID: 2, Seq: 1, SHM: &ports.SHMBuffer{Pool: 9}}, shows(1, 2))
83+ if _, ok := m[2]; !ok {
84+ t.Fatal("Map is a copy")
85+ }
86+}
87+
88+// Update and Prune allocate nothing in steady state.
89+func TestUpdateAllocations(t *testing.T) {
90+ tb := New()
91+ s := shows(1, 2)
92+ c := ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}}
93+ tb.Update(c, s)
94+ if n := testing.AllocsPerRun(100, func() {
95+ c.Seq++
96+ tb.Update(c, s)
97+ tb.Prune(s)
98+ }); n != 0 {
99+ t.Fatalf("%v allocations per update", n)
100+ }
101+}
102+
103+// The kept content's children lose their Acquire fences too, on a copy:
104+// wayland's own slice (shared with its publication) is left alone.
105+func TestKeptChildrenFencesDroppedOnACopy(t *testing.T) {
106+ tb := New()
107+ r, w, err := os.Pipe()
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ defer r.Close()
112+ defer w.Close()
113+ children := []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{DMABuf: &ports.DMABuf{ID: 2}, Acquire: r}}}
114+ full := ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}, Children: children}
115+ tb.Update(full, shows(1))
116+ tb.Update(ports.SurfaceContent{ID: 1, Seq: 2}, shows(1))
117+ kept := tb.Map()[1]
118+ if len(kept.Children) != 1 || kept.Children[0].Acquire != nil || kept.Children[0].DMABuf != children[0].DMABuf {
119+ t.Fatalf("kept children %+v", kept.Children)
120+ }
121+ if children[0].Acquire != r {
122+ t.Fatal("wayland's slice was modified")
123+ }
124+}
M internal/adapters/vulkan/capture_test.go +2 -2
@@ -529,7 +529,7 @@ func TestCaptureSurvivesHDRToggle(t *testing.T) {
529529 hdrShot := readPixels(t, r).RGBAAt(3, 3)
530530 leased := captureWait(t, r) // HDR capture held across the toggle
531531 r.SetHDR(0)
532- if _, err := r.ExportTargets(0, nil); err != nil {
532+ if _, err := r.ExportTargets(0, nil, false); err != nil {
533533 t.Fatal(err)
534534 }
535535 scene.Seq++
@@ -556,7 +556,7 @@ func TestCaptureSurvivesHDRToggle(t *testing.T) {
556556 }
557557 // Back to HDR: the capture pass and slots are reused.
558558 r.SetHDR(203)
559- if _, err := r.ExportTargets(1, r.hdrMods); err != nil {
559+ if _, err := r.ExportTargets(1, r.hdrMods, false); err != nil {
560560 t.Skipf("no HDR target: %v", err)
561561 }
562562 scene.Seq++
M internal/adapters/vulkan/compose.go +16 -0
@@ -326,6 +326,22 @@ func (r *Renderer) solidDraw(rect image.Rectangle, content *ports.SurfaceContent
326326 return dr
327327 }
328328
329+// fadeSolid scales a solid draw's premultiplied color by k (a window's
330+// fade). The shader decodes a solid's rgb on HDR outputs: there the rgb is
331+// re-encoded so the decode yields the scaled linear premultiplied color, as
332+// solidDraw does.
333+func (r *Renderer) fadeSolid(dr *draw, k float32) {
334+ c := &dr.pc.color
335+ if r.hdrNits > 0 {
336+ for i := range 3 {
337+ c[i] = float32(linearToSRGB(srgbToLinear(float64(c[i])) * float64(k)))
338+ }
339+ } else {
340+ c[0], c[1], c[2] = c[0]*k, c[1]*k, c[2]*k
341+ }
342+ c[3] *= k
343+}
344+
329345 // dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA
330346 // leaves the destination at (1-alpha) of its previous value.
331347 func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw {
M internal/adapters/vulkan/damage.go +159 -16
@@ -3,6 +3,7 @@ package vulkan
33 import (
44 "image"
55 "math"
6+ "slices"
67
78 "github.com/bnema/neferwl/internal/ports"
89 )
@@ -10,8 +11,10 @@ import (
1011 // Damage: an output target keeps what it holds (scene Seq, each window's
1112 // content Seq). When the scene is the same, only the region changed since
1213 // then is redrawn: every quad is clipped to it on the CPU and the pass
13-// keeps the rest of the image. A changed scene, a target never drawn or
14-// a history too short redraws everything.
14+// keeps the rest of the image. A scene with a new Seq redraws only the
15+// windows and separators that differ from the scene the target holds
16+// (sceneDelta). A scene that differs in anything else, a target never
17+// drawn or a history too short redraws everything.
1518
1619 // damageRegion collects the region of a target to redraw this frame and
1720 // what the target will hold after it: the windows drawn, their content
@@ -22,6 +25,8 @@ type damageRegion struct {
2225 seen map[ports.WindowID]bool
2326 bound image.Rectangle
2427 drawn map[ports.WindowID]heldWindow
28+ // rects is scratch for a content's damage rects (reused across frames).
29+ rects []ports.Rect
2530 }
2631
2732 // heldWindow is a window a target holds: its content Seq and rect.
@@ -32,11 +37,34 @@ type heldWindow struct {
3237
3338 func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion {
3439 d := &damageRegion{seen: map[ports.WindowID]bool{}, bound: bounds, drawn: map[ports.WindowID]heldWindow{}}
35- // Seq 0 is a scene core did not number (setup, tests): redraw all.
36- if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {
40+ d.hold(tg, s)
41+ return d
42+}
43+
44+// hold decides what the target keeps. Seq 0 is a scene core did not
45+// number (setup, tests): redraw all. The same Seq keeps everything; a new
46+// one keeps all but what sceneDelta finds changed.
47+func (d *damageRegion) hold(tg *target, s ports.Scene) {
48+ if !tg.valid || s.Seq == 0 {
49+ return
50+ }
51+ if tg.sceneSeq == s.Seq {
3752 d.held = tg
53+ return
54+ }
55+ scale := s.Scale
56+ if scale <= 0 {
57+ scale = 1
58+ }
59+ phys := func(r ports.Rect) image.Rectangle {
60+ p := func(v int) int { return int(math.Round(float64(v) * scale)) }
61+ return image.Rect(p(r.X), p(r.Y), p(r.X+r.W), p(r.Y+r.H)).Intersect(d.bound)
62+ }
63+ // The limit only bounds the CPU cost of clipping every quad.
64+ limit := d.bound.Dx() * d.bound.Dy() * 9 / 10
65+ if area, ok := sceneDelta(tg.scene, s, phys, limit); ok {
66+ d.held, d.area = tg, area
3867 }
39- return d
4068 }
4169
4270 // frameDamage reuses the bookkeeping maps. drawn is swapped with the
@@ -50,10 +78,8 @@ func (r *Renderer) frameDamage(tg *target, s ports.Scene, bounds image.Rectangle
5078 r.damageDrawn = make(map[ports.WindowID]heldWindow)
5179 }
5280 clear(r.damageDrawn)
53- r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds}
54- if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {
55- r.damage.held = tg
56- }
81+ r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds, rects: r.damage.rects[:0]}
82+ r.damage.hold(tg, s)
5783 return &r.damage
5884 }
5985
@@ -86,13 +112,12 @@ func (d *damageRegion) window(id ports.WindowID, c ports.SurfaceContent, rect im
86112 d.add(rect)
87113 return
88114 }
89- rects, known := c.DamageSince(held)
115+ var known bool
116+ d.rects, known = c.AppendDamageSince(d.rects[:0], held)
90117 if !known {
91118 d.add(rect)
92- return
93119 }
94- // Mapped by content, called next: nothing more here.
95- _ = rects
120+ // Otherwise mapped by content, called next: nothing more here.
96121 }
97122
98123 // content maps a root surface's buffer damage onto full (where its w×h
@@ -107,10 +132,12 @@ func (d *damageRegion) content(id ports.WindowID, c *ports.SurfaceContent, full,
107132 if !ok || held == c.Seq || len(c.Children) > 0 {
108133 return
109134 }
110- rects, known := c.DamageSince(held)
135+ var known bool
136+ d.rects, known = c.AppendDamageSince(d.rects[:0], held)
111137 if !known || c.Width <= 0 || c.Height <= 0 {
112138 return
113139 }
140+ rects := d.rects
114141 if c.Transform != 0 {
115142 d.add(dst)
116143 return
@@ -162,7 +189,123 @@ func (d *damageRegion) clip(ds []draw) []draw {
162189 }
163190
164191 // hold records what the target holds after this frame: only the windows
165-// it drew.
192+// it drew, and the scene with its compared slices copied into the target's
193+// buffers (zero allocations once they are large enough): the caller may
194+// rewrite its slices in place later.
166195 func (tg *target) hold(s ports.Scene, d *damageRegion) {
167- tg.valid, tg.sceneSeq, tg.windows = true, s.Seq, d.drawn
196+ tg.heldWindows = append(tg.heldWindows[:0], s.Windows...)
197+ tg.heldSeparators = append(tg.heldSeparators[:0], s.Separators...)
198+ tg.heldLayers = append(tg.heldLayers[:0], s.Layers...)
199+ tg.heldHints = append(tg.heldHints[:0], s.DropHints...)
200+ tg.heldIndicators = append(tg.heldIndicators[:0], s.CaptureIndicators...)
201+ tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn
202+ tg.scene.Windows, tg.scene.Separators, tg.scene.Layers = tg.heldWindows, tg.heldSeparators, tg.heldLayers
203+ tg.scene.DropHints, tg.scene.CaptureIndicators = tg.heldHints, tg.heldIndicators
204+ // Only whether they are set matters (any capture redraws everything):
205+ // sentinels, so no pointer into the caller's capture state is kept.
206+ tg.scene.Capture, tg.scene.CaptureScene = nil, nil
207+ if s.Capture != nil {
208+ tg.scene.Capture = &heldCapture
209+ }
210+ if s.CaptureScene != nil {
211+ tg.scene.CaptureScene = &heldCaptureScene
212+ }
213+}
214+
215+var (
216+ heldCapture ports.SceneCapture
217+ heldCaptureScene ports.Scene
218+)
219+
220+// sceneDelta is the region (physical, through phys) where cur differs from
221+// old, two scenes of one output; ok is false when the whole output must be
222+// redrawn: anything but the windows and separators differs, the windows
223+// are not the same list, or the region covers more than limit pixels.
224+// Each window that differs (any field) adds its old and new rects, each
225+// separator present in only one scene adds its own. A new Scene field
226+// belongs here too (see Scene.SameAs, TestSceneSameAsCoversEveryField); a
227+// new SceneWindow field is checked by TestSceneWindowFieldCount.
228+func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, limit int) (image.Rectangle, bool) {
229+ if old.Security != cur.Security || old.Output != cur.Output ||
230+ old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight ||
231+ old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off ||
232+ old.Background != cur.Background || old.Border != cur.Border ||
233+ old.WorkspaceClip != cur.WorkspaceClip || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind ||
234+ old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||
235+ !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||
236+ !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) ||
237+ len(old.Windows) != len(cur.Windows) {
238+ return image.Rectangle{}, false
239+ }
240+ // windows() paints the tile lines and the Dim veil under the first
241+ // window that opens the floats: when that moves, the paint order of the
242+ // whole output changed, not only the rects of the windows that differ.
243+ if floatsStart(old) != floatsStart(cur) {
244+ return image.Rectangle{}, false
245+ }
246+ var area image.Rectangle
247+ for i := range old.Windows {
248+ a, b := old.Windows[i], cur.Windows[i]
249+ if a.ID != b.ID {
250+ return image.Rectangle{}, false
251+ }
252+ if a != b {
253+ area = area.Union(phys(a.Rect)).Union(phys(b.Rect))
254+ }
255+ }
256+ area = area.Union(separatorDelta(old.Separators, cur.Separators, phys))
257+ if area.Dx()*area.Dy() > limit {
258+ return image.Rectangle{}, false
259+ }
260+ return area, true
261+}
262+
263+// separatorDelta is the region where the separator lists differ: a line in
264+// one list more often than in the other. Core builds the lists in layout
265+// order (one pass over the placements, sides in a fixed order), so between
266+// two frames of the same windows they are index-aligned: equal lists cost
267+// one comparison, and with lists of the same length only the pairs that
268+// differ by index are counted in the other list (a focus change lights a
269+// few lines, not all). Lists of different lengths (a line scrolled on or
270+// off) count every line.
271+func separatorDelta(old, cur []ports.Separator, phys func(ports.Rect) image.Rectangle) image.Rectangle {
272+ var area image.Rectangle
273+ if slices.Equal(old, cur) {
274+ return area
275+ }
276+ if len(old) == len(cur) {
277+ for i := range old {
278+ if old[i] == cur[i] {
279+ continue
280+ }
281+ if countSeparator(old, old[i]) != countSeparator(cur, old[i]) {
282+ area = area.Union(phys(old[i].Rect))
283+ }
284+ if countSeparator(old, cur[i]) != countSeparator(cur, cur[i]) {
285+ area = area.Union(phys(cur[i].Rect))
286+ }
287+ }
288+ return area
289+ }
290+ for _, sep := range old {
291+ if countSeparator(old, sep) != countSeparator(cur, sep) {
292+ area = area.Union(phys(sep.Rect))
293+ }
294+ }
295+ for _, sep := range cur {
296+ if countSeparator(old, sep) != countSeparator(cur, sep) {
297+ area = area.Union(phys(sep.Rect))
298+ }
299+ }
300+ return area
301+}
302+
303+func countSeparator(list []ports.Separator, sep ports.Separator) int {
304+ n := 0
305+ for _, o := range list {
306+ if o == sep {
307+ n++
308+ }
309+ }
310+ return n
168311 }
A internal/adapters/vulkan/damage_delta_test.go +406 -0
@@ -0,0 +1,406 @@
1+package vulkan
2+
3+import (
4+ "bytes"
5+ "image"
6+ "image/color"
7+ "reflect"
8+ "slices"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/neferwl/internal/ports"
15+)
16+
17+// TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
18+// lists the fields that force a full redraw.
19+func TestSceneFieldCount(t *testing.T) {
20+ const fields = 20
21+ assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
22+ "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
23+}
24+
25+// TestSceneWindowFieldCount fails when SceneWindow gains or loses a field:
26+// sceneDelta compares windows by struct equality, so a new field is covered
27+// as long as it only changes where or how that window draws (inside Rect).
28+func TestSceneWindowFieldCount(t *testing.T) {
29+ const fields = 15
30+ assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),
31+ "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")
32+}
33+
34+func deltaScene(seq uint64, ws ...ports.SceneWindow) ports.Scene {
35+ return ports.Scene{Seq: seq, OutputWidth: 1000, OutputHeight: 500, Scale: 1, Background: "#000000", Windows: ws}
36+}
37+
38+func win(id ports.WindowID, x, y, w, h int) ports.SceneWindow {
39+ return ports.SceneWindow{ID: id, Rect: ports.Rect{X: x, Y: y, W: w, H: h}}
40+}
41+
42+// held is a target that holds s after a full frame of its windows.
43+func held(s ports.Scene) *target {
44+ tg := &target{}
45+ tg.hold(s, &damageRegion{drawn: map[ports.WindowID]heldWindow{}})
46+ return tg
47+}
48+
49+func TestDamageOneWindowResizeIsUnionOfRects(t *testing.T) {
50+ bounds := image.Rect(0, 0, 1000, 500)
51+ old := deltaScene(1, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 400, 500))
52+ cur := deltaScene(2, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 380, 400))
53+ d := newDamage(held(old), cur, bounds)
54+ require.False(t, d.all())
55+ assert.Equal(t, image.Rect(600, 0, 1000, 500), d.area)
56+
57+ // Window moves: both places.
58+ cur = deltaScene(2, win(1, 0, 0, 300, 500), win(2, 310, 0, 300, 500), win(3, 600, 0, 400, 500))
59+ d = newDamage(held(old), cur, bounds)
60+ require.False(t, d.all())
61+ assert.Equal(t, image.Rect(300, 0, 610, 500), d.area)
62+}
63+
64+func TestDamageDeltaAtScale(t *testing.T) {
65+ old := deltaScene(1, win(1, 0, 0, 10, 10), win(2, 100, 0, 10, 10))
66+ cur := deltaScene(2, win(1, 0, 0, 10, 10), win(2, 100, 0, 20, 10))
67+ old.Scale, cur.Scale = 1.5, 1.5
68+ d := newDamage(held(old), cur, image.Rect(0, 0, 1500, 750))
69+ require.False(t, d.all())
70+ assert.Equal(t, image.Rect(150, 0, 180, 15), d.area)
71+}
72+
73+func TestDamageDeltaWindowFieldChange(t *testing.T) {
74+ old := deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
75+ cur := deltaScene(2, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
76+ cur.Windows[0].FocusEffect = 0.3
77+ d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
78+ require.False(t, d.all())
79+ assert.Equal(t, image.Rect(0, 0, 100, 100), d.area)
80+}
81+
82+func TestDamageDeltaFullRedraws(t *testing.T) {
83+ base := func() ports.Scene {
84+ return deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
85+ }
86+ bounds := image.Rect(0, 0, 1000, 500)
87+ for name, mod := range map[string]func(*ports.Scene){
88+ "camera shift over half": func(s *ports.Scene) {
89+ s.Windows = []ports.SceneWindow{win(1, 0, 0, 600, 500), win(2, 600, 0, 400, 500)}
90+ s.Windows[0].Rect.X = 50
91+ },
92+ "layer": func(s *ports.Scene) { s.Layers = []ports.SceneLayer{{ID: 9, Rect: ports.Rect{W: 10, H: 10}}} },
93+ "output": func(s *ports.Scene) { s.Output = "DP-2" },
94+ "size": func(s *ports.Scene) { s.OutputWidth = 999 },
95+ "scale": func(s *ports.Scene) { s.Scale = 2 },
96+ "transform": func(s *ports.Scene) { s.Transform = 1 },
97+ "off": func(s *ports.Scene) { s.Off = true },
98+ "background": func(s *ports.Scene) { s.Background = "#111111" },
99+ "border": func(s *ports.Scene) { s.Border.Width = 2 },
100+ "workspace clip": func(s *ports.Scene) { s.WorkspaceClip = ports.Rect{W: 10, H: 10} },
101+ "dim": func(s *ports.Scene) { s.Dim = 0.5 },
102+ "dim behind": func(s *ports.Scene) { s.DimBehind = true },
103+ "drop hints": func(s *ports.Scene) { s.DropHints = []ports.Rect{{W: 5, H: 5}} },
104+ "indicators": func(s *ports.Scene) { s.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 5, H: 5}}} },
105+ "capture": func(s *ports.Scene) { s.Capture = &ports.SceneCapture{} },
106+ "capture scene": func(s *ports.Scene) { s.CaptureScene = &ports.Scene{} },
107+ "security": func(s *ports.Scene) { s.Security.Protected = true },
108+ "window count": func(s *ports.Scene) { s.Windows = s.Windows[:1] },
109+ "window order": func(s *ports.Scene) { s.Windows[0], s.Windows[1] = s.Windows[1], s.Windows[0] },
110+ "window id": func(s *ports.Scene) { s.Windows[1].ID = 7 },
111+ } {
112+ t.Run(name, func(t *testing.T) {
113+ cur := base()
114+ mod(&cur)
115+ cur.Seq = 2
116+ assert.True(t, newDamage(held(base()), cur, bounds).all())
117+ })
118+ }
119+ t.Run("seq 0 and cold target", func(t *testing.T) {
120+ cur := base()
121+ cur.Seq = 0
122+ assert.True(t, newDamage(held(base()), cur, bounds).all())
123+ cur.Seq = 2
124+ assert.True(t, newDamage(&target{}, cur, bounds).all())
125+ })
126+}
127+
128+func TestDamageDeltaSeparators(t *testing.T) {
129+ sep := func(x int) ports.Separator { return ports.Separator{Rect: ports.Rect{X: x, W: 2, H: 500}} }
130+ a, b := sep(100), sep(200)
131+ old := deltaScene(1, win(1, 0, 0, 500, 500))
132+ old.Separators = []ports.Separator{a, b}
133+ cur := deltaScene(2, win(1, 0, 0, 500, 500))
134+ cur.Separators = []ports.Separator{a, sep(250)}
135+ d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
136+ require.False(t, d.all())
137+ assert.Equal(t, image.Rect(200, 0, 252, 500), d.area)
138+
139+ // Same lines, another order: nothing differs by value.
140+ cur.Separators = []ports.Separator{b, a}
141+ d = newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
142+ require.False(t, d.all())
143+ assert.True(t, d.area.Empty())
144+
145+ // A line that turns active is a different value.
146+ cur.Separators = []ports.Separator{a, {Rect: b.Rect, Active: true}}
147+ d = newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
148+ require.False(t, d.all())
149+ assert.Equal(t, image.Rect(200, 0, 202, 500), d.area)
150+}
151+
152+func TestSceneDeltaAllocations(t *testing.T) {
153+ // Many separators (twenty tiles' worth): the same-length lists are
154+ // compared index by index, and a list that lost a line still allocates
155+ // nothing.
156+ const seps = 80
157+ old := deltaScene(1, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 400, 500))
158+ cur := deltaScene(2, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 380, 500))
159+ for i := range seps {
160+ s := ports.Separator{Rect: ports.Rect{X: i * 10, W: 2, H: 500}}
161+ old.Separators = append(old.Separators, s)
162+ cur.Separators = append(cur.Separators, s)
163+ }
164+ cur.Separators[5].Active = true
165+ phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
166+ var area image.Rectangle
167+ var ok bool
168+ allocs := testing.AllocsPerRun(50, func() { area, ok = sceneDelta(old, cur, phys, 1<<30) })
169+ require.True(t, ok)
170+ assert.False(t, area.Empty())
171+ assert.True(t, image.Rect(50, 0, 52, 500).In(area), "the changed line is damaged: %v", area)
172+ assert.Zero(t, allocs, "sceneDelta must not allocate")
173+
174+ short := cur
175+ short.Separators = cur.Separators[:seps-1]
176+ allocs = testing.AllocsPerRun(50, func() { area, ok = sceneDelta(old, short, phys, 1<<30) })
177+ require.True(t, ok)
178+ assert.True(t, image.Rect((seps-1)*10, 0, (seps-1)*10+2, 500).In(area), "the dropped line is damaged: %v", area)
179+ assert.Zero(t, allocs, "sceneDelta must not allocate with lists of different lengths")
180+}
181+
182+// Separators are damaged where a line is in one list more often than in
183+// the other, whatever the order: equal lists add nothing, a changed pair
184+// adds both rects, a rotated list adds nothing, a duplicated line that
185+// replaces another adds both, a line added or dropped adds that line.
186+func TestSceneDeltaSeparators(t *testing.T) {
187+ phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
188+ line := func(x int) ports.Separator { return ports.Separator{Rect: ports.Rect{X: x, W: 2, H: 10}} }
189+ a, b, c := line(0), line(100), line(200)
190+ assert.True(t, separatorDelta([]ports.Separator{a, b}, []ports.Separator{a, b}, phys).Empty())
191+ assert.Equal(t, image.Rect(100, 0, 202, 10), separatorDelta([]ports.Separator{a, b}, []ports.Separator{a, c}, phys))
192+ assert.True(t, separatorDelta([]ports.Separator{a, b, c}, []ports.Separator{b, c, a}, phys).Empty())
193+ assert.Equal(t, image.Rect(0, 0, 102, 10), separatorDelta([]ports.Separator{a, b, c}, []ports.Separator{a, a, c}, phys))
194+ assert.Equal(t, image.Rect(200, 0, 202, 10), separatorDelta([]ports.Separator{a, b}, []ports.Separator{a, b, c}, phys))
195+ assert.Equal(t, image.Rect(0, 0, 2, 10), separatorDelta([]ports.Separator{a, b, c}, []ports.Separator{b, c}, phys))
196+}
197+
198+// The paint order splits at the first window that opens the floats: when
199+// that window changes, the whole output is redrawn.
200+func TestSceneDeltaFloatsStartMoves(t *testing.T) {
201+ phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
202+ tile := func(id ports.WindowID, x int) ports.SceneWindow { return win(id, x, 0, 100, 100) }
203+ float := func(id ports.WindowID, x int) ports.SceneWindow {
204+ w := win(id, x, 0, 100, 100)
205+ w.Floating = true
206+ return w
207+ }
208+ delta := func(a, b []ports.SceneWindow) bool {
209+ _, ok := sceneDelta(deltaScene(1, a...), deltaScene(2, b...), phys, 1<<30)
210+ return ok
211+ }
212+ // A tile becomes a float: the tile lines and the veil move under it.
213+ assert.False(t, delta([]ports.SceneWindow{tile(1, 0), tile(2, 100)}, []ports.SceneWindow{tile(1, 0), float(2, 100)}))
214+ // The first float moves in the list.
215+ assert.False(t, delta([]ports.SceneWindow{float(1, 0), float(2, 100)}, []ports.SceneWindow{tile(1, 0), float(2, 100)}))
216+ // A hidden or popup float opens nothing; showing it does.
217+ hidden := float(2, 100)
218+ hidden.Hidden = true
219+ assert.False(t, delta([]ports.SceneWindow{tile(1, 0), hidden}, []ports.SceneWindow{tile(1, 0), float(2, 100)}))
220+ popup := float(2, 100)
221+ popup.Popup = true
222+ assert.False(t, delta([]ports.SceneWindow{tile(1, 0), popup}, []ports.SceneWindow{tile(1, 0), float(2, 100)}))
223+ // A below float or an overview preview does not open the floats.
224+ below := float(2, 100)
225+ below.Below = true
226+ assert.True(t, delta([]ports.SceneWindow{tile(1, 0), tile(2, 100)}, []ports.SceneWindow{tile(1, 0), below}))
227+ preview := float(2, 100)
228+ preview.Preview = 0.5
229+ assert.True(t, delta([]ports.SceneWindow{tile(1, 0), tile(2, 100)}, []ports.SceneWindow{tile(1, 0), preview}))
230+ // The same first float with another rect stays a delta.
231+ assert.True(t, delta([]ports.SceneWindow{tile(1, 0), float(2, 100)}, []ports.SceneWindow{tile(1, 0), float(2, 130)}))
232+}
233+
234+// The target keeps its own copy of the compared slices: a caller reusing
235+// its slices in place (DRM overlay frames, capture) must not change what the
236+// target holds.
237+func TestTargetHoldCopiesSlices(t *testing.T) {
238+ ws := []ports.SceneWindow{win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100)}
239+ seps := []ports.Separator{{Rect: ports.Rect{X: 100, W: 2, H: 100}}}
240+ layers := []ports.SceneLayer{{ID: 9, Rect: ports.Rect{W: 10, H: 10}}}
241+ hints := []ports.Rect{{W: 5, H: 5}}
242+ marks := []ports.CaptureIndicator{{Rect: ports.Rect{W: 5, H: 5}}}
243+ s := deltaScene(1)
244+ s.Windows, s.Separators, s.Layers, s.DropHints, s.CaptureIndicators = ws, seps, layers, hints, marks
245+ tg := &target{}
246+ d := &damageRegion{drawn: map[ports.WindowID]heldWindow{}}
247+ tg.hold(s, d)
248+ ws[1].Rect.X, seps[0].Rect.X, layers[0].ID, hints[0].W, marks[0].Rect.W = 150, 150, 8, 6, 6
249+ phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
250+ // Compared with the mutated slices as the new scene, the held copy
251+ // differs in each; with a held reference nothing would differ.
252+ cur := s
253+ cur.Seq = 2
254+ _, ok := sceneDelta(tg.scene, cur, phys, 1<<30)
255+ assert.False(t, ok, "layers, hints and indicators changed: full redraw")
256+ cur.Layers, cur.DropHints, cur.CaptureIndicators = tg.heldLayers, tg.heldHints, tg.heldIndicators
257+ area, ok := sceneDelta(tg.scene, cur, phys, 1<<30)
258+ require.True(t, ok)
259+ assert.Equal(t, image.Rect(100, 0, 250, 100), area, "window 2, old and new")
260+ // Zero allocations once the buffers are warm.
261+ allocs := testing.AllocsPerRun(50, func() { tg.hold(s, d) })
262+ assert.Zero(t, allocs, "hold must not allocate once warm")
263+}
264+
265+// deltaCase is a frame sequence of two windows whose second one resizes.
266+type deltaCase struct {
267+ name string
268+ scale float64
269+ out ports.BufferTransform
270+ sceneDim float64
271+ floating bool // window 2 is a float: the veil and tile lines go under it
272+ windowDim bool // window 1 is dimmed on some frames
273+}
274+
275+func (c deltaCase) scene(seq uint64, i, w2 int, windows []ports.SceneWindow, seps []ports.Separator) ports.Scene {
276+ windows[0], windows[1] = win(1, 0, 0, 20, 48), win(2, 20, 0, w2, 48)
277+ windows[1].Floating = c.floating
278+ if c.windowDim && i%3 == 0 {
279+ windows[0].Dim = 0.5
280+ }
281+ seps[0] = ports.Separator{Rect: ports.Rect{X: 20, W: 1, H: 48}}
282+ return ports.Scene{Seq: seq, Transform: c.out, OutputWidth: 64, OutputHeight: 48, Scale: c.scale, Background: "#101010",
283+ Dim: c.sceneDim, Windows: windows, Separators: seps}
284+}
285+
286+func deltaCases() []deltaCase {
287+ return []deltaCase{
288+ {name: "scale 1", scale: 1},
289+ {name: "scale 1.5", scale: 1.5},
290+ {name: "transform 90", scale: 1, out: 1},
291+ {name: "scene dim over tiles", scale: 1, sceneDim: 0.4, floating: true},
292+ {name: "window dim", scale: 1, windowDim: true},
293+ {name: "scale 1.5 rotated, all", scale: 1.5, out: 1, sceneDim: 0.3, floating: true, windowDim: true},
294+ }
295+}
296+
297+// A partial redraw after a window resize is pixel-identical to a full
298+// redraw, on both targets of a double-buffered output. With reuse the scene
299+// slices are rewritten in place for every frame, like an output adapter
300+// that reuses them: the target must not have kept a reference.
301+func TestRendererSceneDeltaMatchesFullRedraw(t *testing.T) {
302+ for _, reuse := range []bool{false, true} {
303+ for _, tc := range deltaCases() {
304+ name := tc.name
305+ if reuse {
306+ name += " reused slices"
307+ }
308+ t.Run(name, func(t *testing.T) { sceneDeltaMatchesFullRedraw(t, tc, reuse) })
309+ }
310+ }
311+}
312+
313+func sceneDeltaMatchesFullRedraw(t *testing.T, tc deltaCase, reuse bool) {
314+ tw, th := tc.out.Size(int(64*tc.scale+0.5), int(48*tc.scale+0.5))
315+ newR := func() *Renderer {
316+ r, err := New(tw, th)
317+ if err != nil {
318+ t.Skipf("Vulkan unavailable: %v", err)
319+ }
320+ t.Cleanup(r.Close)
321+ return r
322+ }
323+ damaged, full := newR(), newR()
324+ bufs, err := damaged.ExportTargets(2, nil, false)
325+ if err != nil {
326+ t.Skipf("no exportable targets: %v", err)
327+ }
328+ for _, b := range bufs {
329+ for _, p := range b.Planes {
330+ p.File.Close()
331+ }
332+ }
333+ red := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255})
334+ red.ID, red.Surface, red.Seq, red.Version = 1, 1, 1, 1
335+ green := solidContent(t, 8, 8, color.RGBA{G: 255, A: 255})
336+ green.ID, green.Surface, green.Seq, green.Version = 2, 2, 1, 1
337+ contents := map[ports.WindowID]ports.SurfaceContent{1: red, 2: green}
338+ shared := make([]ports.SceneWindow, 2)
339+ sharedSeps := make([]ports.Separator, 1)
340+ var partial int
341+ for i, w2 := range []int{20, 20, 30, 24, 24, 40, 20, 22} {
342+ windows, seps := shared, sharedSeps
343+ if !reuse {
344+ windows, seps = make([]ports.SceneWindow, 2), make([]ports.Separator, 1)
345+ }
346+ if reuse {
347+ clear(windows)
348+ }
349+ s := tc.scene(uint64(10+i), i, w2, windows, seps)
350+ damaged.UseTarget(i % 2)
351+ before := damaged.redrawn
352+ require.NoError(t, render(damaged, s, contents))
353+ if damaged.redrawn-before < tw*th {
354+ partial++
355+ }
356+ plain := s
357+ plain.Seq = 0
358+ plain.Windows = slices.Clone(s.Windows)
359+ plain.Separators = slices.Clone(s.Separators)
360+ require.NoError(t, render(full, plain, contents))
361+ a, b := readPixels(t, damaged), readPixels(t, full)
362+ if !bytes.Equal(a.Pix, b.Pix) {
363+ t.Fatalf("frame %d (w2=%d) differs from a full redraw", i, w2)
364+ }
365+ }
366+ assert.Positive(t, partial, "no frame was redrawn partially")
367+}
368+
369+// A window's Fade changes only how it draws inside its rect: the delta is
370+// that rect. A leaving entry (core keeps a closed or hidden window in the
371+// scene while it fades) changes the window list when it appears and when
372+// it goes: both redraw everything, like any list change; while it fades,
373+// only its rect is redrawn.
374+func TestDamageDeltaFadeAndLeavingEntry(t *testing.T) {
375+ bounds := image.Rect(0, 0, 1000, 500)
376+ settled := deltaScene(1, win(1, 0, 0, 500, 500), win(2, 500, 0, 500, 500))
377+
378+ faded := deltaScene(2, win(1, 0, 0, 500, 500), win(2, 500, 0, 500, 500))
379+ faded.Windows[1].Fade = 0.5
380+ d := newDamage(held(settled), faded, bounds)
381+ require.False(t, d.all())
382+ assert.Equal(t, image.Rect(500, 0, 1000, 500), d.area)
383+
384+ // Window 2 was unmapped: the layout re-flows and a leaving entry is
385+ // appended. The list changed: full redraw.
386+ leaving := deltaScene(3, win(1, 0, 0, 1000, 500), win(2, 500, 0, 500, 500))
387+ leaving.Windows[1].Fade = 0.1
388+ assert.True(t, newDamage(held(settled), leaving, bounds).all())
389+
390+ // The fade advances: its rect only, the neighbours untouched.
391+ next := deltaScene(4, win(1, 0, 0, 1000, 500), win(2, 500, 0, 500, 500))
392+ next.Windows[1].Fade = 0.6
393+ d = newDamage(held(leaving), next, bounds)
394+ require.False(t, d.all())
395+ assert.Equal(t, image.Rect(500, 0, 1000, 500), d.area)
396+
397+ // The entry settled and is dropped: full redraw (its pixels are stale).
398+ gone := deltaScene(5, win(1, 0, 0, 1000, 500))
399+ assert.True(t, newDamage(held(next), gone, bounds).all())
400+
401+ // A leaving float opens the floats where it is appended: the tile
402+ // lines and the veil move under it, so floatsStart differs.
403+ float := deltaScene(6, win(1, 0, 0, 1000, 500), win(2, 100, 100, 200, 200))
404+ float.Windows[1].Floating, float.Windows[1].Fade = true, 0.1
405+ assert.True(t, newDamage(held(gone), float, bounds).all())
406+}
M internal/adapters/vulkan/damage_test.go +1 -1
@@ -50,7 +50,7 @@ func damageMatchesFullRedraw(t *testing.T, tr, out ports.BufferTransform) {
5050 return r
5151 }
5252 damaged, full := newR(), newR()
53- bufsD, err := damaged.ExportTargets(2, nil)
53+ bufsD, err := damaged.ExportTargets(2, nil, false)
5454 if err != nil {
5555 t.Skipf("no exportable targets: %v", err)
5656 }
M internal/adapters/vulkan/dmabuf_test.go +1 -1
@@ -14,7 +14,7 @@ import (
1414
1515 // udmabuf turns memfd pages into a real dmabuf: a linear buffer any GPU
1616 // driver imports, like a client's.
17-func udmabuf(t *testing.T, w, h int, fill func(x, y int) [4]byte) *os.File {
17+func udmabuf(t testing.TB, w, h int, fill func(x, y int) [4]byte) *os.File {
1818 t.Helper()
1919 dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0)
2020 if err != nil {
M internal/adapters/vulkan/hdr_composition_test.go +3 -3
@@ -27,7 +27,7 @@ func hdrTestRenderer(t *testing.T) *Renderer {
2727 if len(r.hdrMods) == 0 {
2828 t.Skip("no HDR transfer-src modifier")
2929 }
30- bufs, err := r.ExportTargets(1, r.hdrMods)
30+ bufs, err := r.ExportTargets(1, r.hdrMods, false)
3131 if err != nil {
3232 t.Skipf("no HDR target: %v", err)
3333 }
@@ -116,7 +116,7 @@ func TestHDRPixels(t *testing.T) {
116116 defer plain.Close()
117117 plain.SetHDR(203)
118118 plain.SetVirtualOutput(true)
119- bufs, err := plain.ExportTargets(1, nil)
119+ bufs, err := plain.ExportTargets(1, nil, false)
120120 if err != nil {
121121 t.Skipf("no HDR target: %v", err)
122122 }
@@ -149,7 +149,7 @@ func TestHDRPixelsReadsRenderedTarget(t *testing.T) {
149149 t.Fatal(err)
150150 }
151151 // New targets replace the one the frame drew into: nothing to read.
152- bufs, err := r.ExportTargets(2, r.hdrMods)
152+ bufs, err := r.ExportTargets(2, r.hdrMods, false)
153153 if err != nil {
154154 t.Skipf("no two HDR targets: %v", err)
155155 }
M internal/adapters/vulkan/renderer.go +10 -0
@@ -10,6 +10,7 @@ import (
1010 "unsafe"
1111
1212 "github.com/bnema/neferwl/internal/ports"
13+ "github.com/bnema/zerowrap"
1314
1415 vk "github.com/bnema/purego-vulkan/vulkan"
1516 )
@@ -35,6 +36,11 @@ type Renderer struct {
3536 hdrNits float64
3637 hdr hdrPass
3738 hdrError error
39+ // modPlanes is the memory plane count of each probed target modifier
40+ // (SDR and HDR): DCC modifiers have more than one.
41+ modPlanes map[uint64]uint32
42+ // log reports the chosen target modifier; the zero value discards.
43+ log zerowrap.Logger
3844 // hdrReadback makes exported HDR targets transfer sources, for GPU tests
3945 // and headless --screenshot-raw (SetHDRReadback); never DRM.
4046 hdrReadback bool
@@ -340,6 +346,10 @@ func (r *Renderer) TakeRedrawn() int {
340346 return n
341347 }
342348
349+// SetLogger sets the logger (with its component field) for render target
350+// events. Not on the port; the zero logger discards.
351+func (r *Renderer) SetLogger(l zerowrap.Logger) { r.log = l }
352+
343353 // QueuePriority is the global priority the queue got: realtime, high or
344354 // default.
345355 func (r *Renderer) QueuePriority() string { return r.queuePriority }
M internal/adapters/vulkan/renderer_test.go +41 -2
@@ -393,7 +393,7 @@ func TestRendererPreviewSmooth(t *testing.T) {
393393 }
394394
395395 // solidContent is a w×h B8G8R8A8 buffer of one color.
396-func solidContent(t *testing.T, w, h int, c color.RGBA) ports.SurfaceContent {
396+func solidContent(t testing.TB, w, h int, c color.RGBA) ports.SurfaceContent {
397397 px := make([]byte, w*h*4)
398398 for i := 0; i < len(px); i += 4 {
399399 px[i], px[i+1], px[i+2], px[i+3] = c.B, c.G, c.R, 255
@@ -402,7 +402,7 @@ func solidContent(t *testing.T, w, h int, c color.RGBA) ports.SurfaceContent {
402402 }
403403
404404 // shmContent puts pixels in a memfd pool, like a wl_shm client.
405-func shmContent(t *testing.T, w, h, stride int, pixels []byte) *ports.SurfaceContent {
405+func shmContent(t testing.TB, w, h, stride int, pixels []byte) *ports.SurfaceContent {
406406 t.Helper()
407407 fd, err := unix.MemfdCreate("shm-test", unix.MFD_CLOEXEC)
408408 if err != nil {
@@ -561,3 +561,42 @@ func TestRendererAlphaBlend(t *testing.T) {
561561 }
562562 }
563563 }
564+
565+// A window kept after its client closed (a closing window fading out) still
566+// draws from the GPU copy of its content: the pool file is closed and its
567+// mapping gone, and the copy is found without reading the pool.
568+func TestRendererSHMKeptWindowDrawsFromCopy(t *testing.T) {
569+ r, err := New(32, 32)
570+ if err != nil {
571+ t.Skipf("Vulkan unavailable: %v", err)
572+ }
573+ defer r.Close()
574+ scene := ports.Scene{Scale: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}}}}
575+ c := solidContent(t, 16, 16, color.RGBA{R: 255, A: 255})
576+ c.ID, c.Surface, c.Seq, c.Version = 1, 1, 1, 1
577+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
578+ t.Fatal(err)
579+ }
580+ // Wayland closed the pool; the mapping was dropped meanwhile.
581+ if err := c.SHM.File.Close(); err != nil {
582+ t.Fatal(err)
583+ }
584+ r.dropPools()
585+ for pool, m := range r.pools {
586+ _ = unix.Munmap(m.data)
587+ delete(r.pools, pool)
588+ }
589+ // The window moves (a fade-out), so it draws again.
590+ scene.Seq = 2
591+ scene.Windows[0].Rect.X = 4
592+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
593+ t.Fatal(err)
594+ }
595+ got := readPixels(t, r)
596+ if g, want := got.At(18, 8), (color.RGBA{R: 255, A: 255}); g != want {
597+ t.Fatalf("kept window pixel %v, want %v", g, want)
598+ }
599+ if g, want := got.At(1, 8), (color.RGBA{A: 255}); g != want {
600+ t.Fatalf("background pixel %v, want %v", g, want)
601+ }
602+}
M internal/adapters/vulkan/scene.go +183 -29
@@ -22,7 +22,11 @@ type sceneWalk struct {
2222 zoom float64
2323 // pulse is the focus effect of the window being placed
2424 // (ports.SceneWindow.FocusEffect); 0 otherwise.
25- pulse float32
25+ pulse float32
26+ // alpha is the opacity of the window being drawn, 1 - its Fade
27+ // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by
28+ // it (premultiplied); 1 otherwise.
29+ alpha float32
2630 bounds image.Rectangle
2731 draws []draw
2832 }
@@ -33,7 +37,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
3337 // The walk stays in scene-physical space (the target's size, swapped for
3438 // a rotated output); orient maps the draws to the target afterwards.
3539 sw, sh := r.sceneSize(s.Transform)
36- w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, bounds: image.Rect(0, 0, sw, sh), draws: r.scratchDraws[:0]}
40+ w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, alpha: 1, bounds: image.Rect(0, 0, sw, sh), draws: r.scratchDraws[:0]}
3741 r.scratchCovers = r.scratchCovers[:0]
3842 if w.scale <= 0 {
3943 w.scale = 1
@@ -129,21 +133,25 @@ func (w *sceneWalk) physRectF(x, y, width, height float64) image.Rectangle {
129133 return image.Rect(p(x), p(y), p(x+width), p(y+height))
130134 }
131135
132-// fill draws a solid rect (physical pixels).
136+// fill draws a solid rect (physical pixels), at the walk's alpha.
133137 func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) {
134138 rect = rect.Intersect(w.bounds)
135139 if rect.Empty() {
136140 return
137141 }
138- w.draws = append(w.draws, w.r.fillDraw(rect, c))
142+ dr := w.r.fillDraw(rect, c)
143+ if w.alpha < 1 {
144+ w.r.fadeSolid(&dr, w.alpha)
145+ }
146+ w.draws = append(w.draws, dr)
139147 }
140148
141149 // dim paints a translucent black quad over rect (physical), clipped to the
142-// output. Damage clipping later limits it to the region being repainted,
143-// just like other fills.
150+// output, at alpha scaled by the walk's alpha. Damage clipping later limits
151+// it to the region being repainted, just like other fills.
144152 func (w *sceneWalk) dim(rect image.Rectangle, alpha float64) {
145153 if rect = rect.Intersect(w.bounds); !rect.Empty() {
146- w.draws = append(w.draws, w.r.dimDraw(rect, alpha))
154+ w.draws = append(w.draws, w.r.dimDraw(rect, alpha*float64(w.alpha)))
147155 }
148156 }
149157
@@ -161,6 +169,30 @@ func (w *sceneWalk) layers(afterWindows bool) {
161169 }
162170 }
163171
172+// drawsAsWindow reports whether windows() paints win as a window (not
173+// hidden, a popup or empty).
174+func drawsAsWindow(win ports.SceneWindow) bool {
175+ return !win.Hidden && !win.Popup && win.Rect.W > 0 && win.Rect.H > 0
176+}
177+
178+// opensFloats reports whether win, once reached, closes the tiles: the tile
179+// lines and the dim veil are painted under it (an overview preview of a
180+// float is not one).
181+func opensFloats(win ports.SceneWindow) bool {
182+ return !win.Below && win.Floating && win.Preview == 0
183+}
184+
185+// floatsStart is the index of the first window that opens the floats in s,
186+// or -1 when none does: where windows() splits its paint order.
187+func floatsStart(s ports.Scene) int {
188+ for i, win := range s.Windows {
189+ if drawsAsWindow(win) && opensFloats(win) {
190+ return i
191+ }
192+ }
193+ return -1
194+}
195+
164196 // windows draws ordered placements with borders. Tile lines go after
165197 // the column group, before the first float above it; an overview preview
166198 // of a float is not one. The veil goes under that float, or under every
@@ -171,16 +203,22 @@ func (w *sceneWalk) windows() {
171203 }
172204 tileLines := false
173205 for _, win := range w.s.Windows {
174- if win.Hidden || win.Popup || win.Rect.W <= 0 || win.Rect.H <= 0 {
206+ if !drawsAsWindow(win) {
175207 continue
176208 }
177- if !win.Below && win.Floating && win.Preview == 0 && !tileLines {
209+ if opensFloats(win) && !tileLines {
178210 w.separators(0)
179211 tileLines = true
180212 if w.s.Dim > 0 && !w.s.DimBehind {
181213 w.dim(w.bounds, w.s.Dim)
182214 }
183215 }
216+ if win.Fade >= 1 {
217+ // Faded out: it still opens the floats (its lines and veil are
218+ // painted under where it would be), but draws nothing.
219+ continue
220+ }
221+ w.alpha = 1 - float32(max(0, win.Fade))
184222 // Content sits inside the border; core sized the client to match.
185223 b, inset := 0, ports.Sides(0)
186224 if !win.Fullscreen {
@@ -193,7 +231,11 @@ func (w *sceneWalk) windows() {
193231 // Until its first buffer, a window shows the background: no flash.
194232 w.fill(body, parseColor(w.s.Background))
195233 if !content.Empty() {
196- if win.Preview > 0 {
234+ // An animating frame zooms its content (Zoom); a card draws
235+ // at its Preview.
236+ if win.Zoom > 0 {
237+ w.zoom = win.Zoom
238+ } else if win.Preview > 0 {
197239 w.zoom = win.Preview
198240 }
199241 w.pulse = float32(max(0, min(win.FocusEffect, 1)))
@@ -207,6 +249,7 @@ func (w *sceneWalk) windows() {
207249 // A stashed window peeking in: dimmed with its border.
208250 w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim)
209251 }
252+ w.alpha = 1
210253 }
211254 if !tileLines {
212255 w.separators(0)
@@ -267,28 +310,56 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y
267310 }
268311 }
269312
270-// opaqueChildren gives the physical rects shown by the opaque subsurfaces
313+// opaqueChildren gives the physical rects shown opaque by the subsurfaces
271314 // above the root (e.g. a game's GPU surface over its window's shm buffer):
272-// they hide the surfaces below them. A child whose buffer will not draw is
273-// left out, so the surface below it still shows: a dmabuf that fails to
274-// import (opaqueChildren pre-imports it) or an unreadable shm buffer.
315+// they hide the surfaces below them. A child that is not wholly opaque but
316+// has an opaque region (Firefox's page, whose region stops a pixel short of
317+// its edges) hides the surfaces below only inside that rect. A child whose
318+// buffer will not draw is left out, so the surface below it still shows: a
319+// dmabuf that fails to import (opaqueChildren pre-imports it) or an
320+// unreadable shm buffer.
275321 func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64, clip image.Rectangle) []image.Rectangle {
276322 covers := w.r.scratchCovers[:0]
277323 for i := range content.Children {
278324 ch := &content.Children[i]
279- if ch.Below || !ch.Opaque {
325+ if ch.Below || !ch.Opaque && ch.OpaqueRect.W <= 0 {
280326 continue
281327 }
282- _, dst, ok := w.surfaceRects(&ch.SurfaceContent, ox+float64(ch.X)*w.zoom, oy+float64(ch.Y)*w.zoom, clip)
328+ full, dst, ok := w.surfaceRects(&ch.SurfaceContent, ox+float64(ch.X)*w.zoom, oy+float64(ch.Y)*w.zoom, clip)
283329 if !ok || !w.drawable(&ch.SurfaceContent) {
284330 continue
285331 }
332+ if !ch.Opaque {
333+ if dst = opaqueCover(&ch.SurfaceContent, full, dst); dst.Empty() {
334+ continue
335+ }
336+ }
286337 covers = append(covers, dst)
287338 }
288339 w.r.scratchCovers = covers
289340 return covers
290341 }
291342
343+// opaqueCover is the part of dst (physical, what the surface shows of full)
344+// that its OpaqueRect maps to. Edges round inward, so every pixel of it is
345+// inside the region; an edge on the surface's own edge stays on dst's.
346+func opaqueCover(c *ports.SurfaceContent, full, dst image.Rectangle) image.Rectangle {
347+ lw, lh := c.LogicalW, c.LogicalH
348+ if lw <= 0 || lh <= 0 {
349+ lw, lh = c.Width, c.Height
350+ }
351+ o := c.OpaqueRect
352+ if lw <= 0 || lh <= 0 || o.W <= 0 || o.H <= 0 {
353+ return image.Rectangle{}
354+ }
355+ // ceil and floor of full.Min + v*size/n, v >= 0.
356+ lo := func(min, v, size, n int) int { return min + (v*size+n-1)/n }
357+ hi := func(min, v, size, n int) int { return min + v*size/n }
358+ r := image.Rect(lo(full.Min.X, max(o.X, 0), full.Dx(), lw), lo(full.Min.Y, max(o.Y, 0), full.Dy(), lh),
359+ hi(full.Min.X, min(o.X+o.W, lw), full.Dx(), lw), hi(full.Min.Y, min(o.Y+o.H, lh), full.Dy(), lh))
360+ return r.Intersect(dst)
361+}
362+
292363 // drawable reports whether the child's buffer will draw. GPU buffer
293364 // imports are cached, so pre-importing here costs nothing.
294365 func (w *sceneWalk) drawable(c *ports.SurfaceContent) bool {
@@ -340,6 +411,7 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y float64, cl
340411 // surface draws one surface buffer with its origin at (x, y) logical,
341412 // clipped to clip (logical), unless an opaque surface above it (covers,
342413 // physical) hides all of it: its buffer is then neither copied nor drawn.
414+// A cover over only part of it clips the draw to the rest.
343415 func (w *sceneWalk) surface(content *ports.SurfaceContent, x, y float64, clip image.Rectangle, key shmKey, seq uint64, covers []image.Rectangle, root bool) {
344416 full, dst, ok := w.surfaceRects(content, x, y, clip)
345417 if !ok {
@@ -353,7 +425,74 @@ func (w *sceneWalk) surface(content *ports.SurfaceContent, x, y float64, clip im
353425 if root {
354426 w.dmg.content(key.win, content, full, dst)
355427 }
428+ n := len(w.draws)
356429 w.content(dst, full, content, key, seq, root)
430+ if len(w.draws) == n+1 && len(covers) > 0 {
431+ // The quad's mapping comes from full: only its rect shrinks.
432+ d := w.draws[n]
433+ w.draws = w.draws[:n]
434+ w.split(d, dst, covers)
435+ }
436+}
437+
438+// Splitting a quad around covers is bounded: covers come from the client, and
439+// each can cut a quad into four strips, recursively. Past these bounds the
440+// quad is drawn whole: the covers above it hide the rest, at the cost of
441+// overdraw.
442+const (
443+ maxSplitCovers = 8 // covers crossing the quad that are still split around
444+ maxSplitStrips = 32 // draws one quad may become
445+)
446+
447+// split appends d, a quad over rect, minus the covers, or the whole quad
448+// when the covers cut it into too many strips.
449+func (w *sceneWalk) split(d draw, rect image.Rectangle, covers []image.Rectangle) {
450+ crossing := 0
451+ for _, c := range covers {
452+ if !rect.Intersect(c).Empty() {
453+ crossing++
454+ }
455+ }
456+ n := len(w.draws)
457+ if crossing <= maxSplitCovers {
458+ w.uncovered(d, rect, covers, n+maxSplitStrips)
459+ if len(w.draws) <= n+maxSplitStrips {
460+ return
461+ }
462+ w.draws = w.draws[:n]
463+ }
464+ w.quad(d, rect)
465+}
466+
467+// uncovered appends d, a quad over rect, minus the covers: the strips of
468+// rect around each cover, in turn. d keeps its mapping. It stops once the
469+// draws pass limit, for the caller to drop them.
470+func (w *sceneWalk) uncovered(d draw, rect image.Rectangle, covers []image.Rectangle, limit int) {
471+ if len(w.draws) > limit {
472+ return
473+ }
474+ for i, c := range covers {
475+ in := rect.Intersect(c)
476+ if in.Empty() {
477+ continue
478+ }
479+ rest := covers[i+1:]
480+ w.uncovered(d, image.Rect(rect.Min.X, rect.Min.Y, rect.Max.X, in.Min.Y), rest, limit)
481+ w.uncovered(d, image.Rect(rect.Min.X, in.Max.Y, rect.Max.X, rect.Max.Y), rest, limit)
482+ w.uncovered(d, image.Rect(rect.Min.X, in.Min.Y, in.Min.X, in.Max.Y), rest, limit)
483+ w.uncovered(d, image.Rect(in.Max.X, in.Min.Y, rect.Max.X, in.Max.Y), rest, limit)
484+ return
485+ }
486+ w.quad(d, rect)
487+}
488+
489+// quad appends d over rect, unless it is empty.
490+func (w *sceneWalk) quad(d draw, rect image.Rectangle) {
491+ if rect.Empty() {
492+ return
493+ }
494+ d.pc.rect = [4]int32{int32(rect.Min.X), int32(rect.Min.Y), int32(rect.Max.X), int32(rect.Max.Y)}
495+ w.draws = append(w.draws, d)
357496 }
358497
359498 // content draws the part of a buffer mapped onto full (physical pixels)
@@ -365,7 +504,11 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
365504 return
366505 }
367506 if content.Solid != nil {
368- if dr := r.solidDraw(rect, content); dr.pc.color[3] > 0 {
507+ dr := r.solidDraw(rect, content)
508+ if w.alpha < 1 {
509+ r.fadeSolid(&dr, w.alpha)
510+ }
511+ if dr.pc.color[3] > 0 {
369512 w.draws = append(w.draws, dr)
370513 }
371514 return
@@ -378,6 +521,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
378521 }
379522 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)
380523 r.setContentColor(&dr, content)
524+ dr.pc.color[3] *= w.alpha
381525 dr.pc.mapy[2] = w.pulse
382526 if im.yuv {
383527 dr.pc.misc[1] |= flagYUV
@@ -399,22 +543,32 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
399543 return
400544 }
401545 b := content.SHM
402- pixels, err := r.shmPixels(b, b.Offset+(content.Height-1)*b.Stride+content.Width*4)
403- if err != nil {
404- return
405- }
406546 st := shmState{w: content.Width, h: content.Height, seq: seq, windowSeq: content.Seq}
407- // Damage history is the root surface's: children copy in full.
408- var damage func(uint64) ([]ports.Rect, bool)
409- if root {
410- damage = content.DamageSince
411- }
412- c, err := r.shmCopyFor(key, st, pixels, b.Offset, b.Stride, damage)
413- if err != nil {
414- return
547+ // The copy of this content, if any, needs no pool: a kept (closing)
548+ // window whose pool file is closed still draws.
549+ c := r.shmCached(key, st)
550+ if c != nil {
551+ // As a drawn pool, for Trim; nothing is mapped for it.
552+ if m := r.pools[b.Pool]; m != nil {
553+ m.last = r.frame
554+ }
555+ } else {
556+ pixels, err := r.shmPixels(b, b.Offset+(content.Height-1)*b.Stride+content.Width*4)
557+ if err != nil {
558+ return
559+ }
560+ // Damage history is the root surface's: children copy in full.
561+ var damage func(uint64) ([]ports.Rect, bool)
562+ if root {
563+ damage = content.DamageSince
564+ }
565+ if c, err = r.shmCopyFor(key, st, pixels, b.Offset, b.Stride, damage); err != nil {
566+ return
567+ }
415568 }
416569 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)
417570 r.setContentColor(&dr, content)
571+ dr.pc.color[3] *= w.alpha
418572 dr.pc.mapy[2] = w.pulse
419573 dr.set = c.set
420574 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}
A internal/adapters/vulkan/scene_fade_test.go +143 -0
@@ -0,0 +1,143 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "math"
6+ "slices"
7+ "testing"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// A window's Fade scales its body fill, its own border lines, its dim and
13+// its content by 1-Fade (premultiplied); at 1 the window draws nothing. The
14+// walk needs no Vulkan device: a solid single-pixel buffer imports nothing.
15+func TestSceneWindowFade(t *testing.T) {
16+ near := func(a, b float32) bool { return math.Abs(float64(a-b)) < 1e-6 }
17+ walk := func(r *Renderer, fade float64) []draw {
18+ s := ports.Scene{
19+ Background: "#ffffff", Dim: 0, Border: ports.Border{Width: 1, Active: "#ffffff", Inactive: "#808080"},
20+ Windows: []ports.SceneWindow{
21+ {ID: 1, Rect: ports.Rect{W: 40, H: 60}},
22+ {ID: 2, Floating: true, Rect: ports.Rect{X: 40, W: 20, H: 20}, Inset: ports.SideLeft | ports.SideRight | ports.SideTop | ports.SideBottom, Fade: fade, Dim: 0.5},
23+ },
24+ Separators: []ports.Separator{{Rect: ports.Rect{X: 40, W: 1, H: 20}, Window: 2}},
25+ }
26+ contents := map[ports.WindowID]ports.SurfaceContent{
27+ 2: {ID: 2, Width: 1, Height: 1, LogicalW: 18, LogicalH: 18, Solid: &ports.SolidColor{R: 1, G: 0.5, B: 0, A: 1}},
28+ }
29+ // The renderer reuses its draw buffer: each walk's result is kept.
30+ return slices.Clone(r.draws(s, contents, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))))
31+ }
32+ r := &Renderer{width: 80, height: 60}
33+ opaque := walk(r, 0)
34+ // Tile fill, float body fill, float content, float line, float dim.
35+ if len(opaque) != 5 {
36+ t.Fatalf("draws = %d, want 5: %+v", len(opaque), opaque)
37+ }
38+ half := walk(r, 0.5)
39+ if len(half) != 5 {
40+ t.Fatalf("faded draws = %d, want 5", len(half))
41+ }
42+ if half[0].pc.color != opaque[0].pc.color {
43+ t.Fatalf("the tile's fill changed with the float's fade: %v", half[0].pc.color)
44+ }
45+ for i := 1; i < 5; i++ {
46+ for ch := range 4 {
47+ if !near(half[i].pc.color[ch], opaque[i].pc.color[ch]*0.5) {
48+ t.Fatalf("draw %d channel %d: %v, want half of %v", i, ch, half[i].pc.color, opaque[i].pc.color)
49+ }
50+ }
51+ if half[i].pc.rect != opaque[i].pc.rect {
52+ t.Fatalf("draw %d moved with the fade: %v vs %v", i, half[i].pc.rect, opaque[i].pc.rect)
53+ }
54+ }
55+ if got := half[2].pc.misc[0]; got != modeSolid {
56+ t.Fatalf("content draw mode %d", got)
57+ }
58+ // Faded out: nothing of the float, the tile unchanged.
59+ gone := walk(r, 1)
60+ if len(gone) != 1 || gone[0].pc.color != opaque[0].pc.color {
61+ t.Fatalf("faded-out draws = %+v, want the tile only", gone)
62+ }
63+ // A second window after the faded one draws at full alpha.
64+ s := ports.Scene{Background: "#ffffff", Windows: []ports.SceneWindow{
65+ {ID: 1, Rect: ports.Rect{W: 40, H: 60}, Fade: 0.25},
66+ {ID: 2, Rect: ports.Rect{X: 40, W: 40, H: 60}},
67+ }}
68+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
69+ if len(ds) != 2 || !near(ds[0].pc.color[3], 0.75) || ds[1].pc.color[3] != 1 {
70+ t.Fatalf("alpha leaked past the faded window: %+v", ds)
71+ }
72+}
73+
74+// On an HDR output a solid's rgb is re-encoded so the shader's decode
75+// yields the faded linear premultiplied color.
76+func TestSceneWindowFadeHDR(t *testing.T) {
77+ r := &Renderer{width: 80, height: 60, hdrNits: 203}
78+ s := ports.Scene{Background: "#ffffff", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 40, H: 60}, Fade: 0.5}}}
79+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
80+ if len(ds) != 1 {
81+ t.Fatalf("draws = %d", len(ds))
82+ }
83+ want := float32(linearToSRGB(0.5))
84+ c := ds[0].pc.color
85+ if math.Abs(float64(c[0]-want)) > 1e-6 || c[0] != c[1] || c[1] != c[2] || c[3] != 0.5 {
86+ t.Fatalf("hdr faded white = %v, want rgb %v alpha 0.5", c, want)
87+ }
88+}
89+
90+// An appearing or leaving float carries a Zoom, not a Preview: it still
91+// opens the floats, so the tile lines and the veil are painted under it,
92+// exactly as for the same float unzoomed; only its content draw shrinks.
93+func TestSceneZoomedFloatOpensFloats(t *testing.T) {
94+ r := &Renderer{width: 80, height: 60}
95+ scene := func(zoom float64) ports.Scene {
96+ return ports.Scene{
97+ Dim: 0.4, Background: "#000000", Border: ports.Border{Width: 1, Active: "#ffffff", Inactive: "#808080"},
98+ Windows: []ports.SceneWindow{
99+ {ID: 1, Rect: ports.Rect{W: 80, H: 60}},
100+ {ID: 2, Floating: true, Rect: ports.Rect{X: 20, Y: 10, W: 40, H: 40}, Inset: ports.SideAll, Fade: 0.5, Zoom: zoom},
101+ },
102+ Separators: []ports.Separator{
103+ {Rect: ports.Rect{X: 40, W: 1, H: 60}},
104+ {Rect: ports.Rect{X: 20, Y: 10, W: 1, H: 40}, Window: 2},
105+ },
106+ }
107+ }
108+ contents := map[ports.WindowID]ports.SurfaceContent{
109+ 2: {ID: 2, Width: 1, Height: 1, LogicalW: 38, LogicalH: 38, Solid: &ports.SolidColor{R: 1, G: 1, B: 1, A: 1}},
110+ }
111+ walk := func(s ports.Scene) []draw {
112+ return slices.Clone(r.draws(s, contents, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))))
113+ }
114+ plain, zoomed := walk(scene(0)), walk(scene(0.9))
115+ // tile fill, tile line, veil, float fill, float content, float line
116+ if len(plain) != 6 || len(zoomed) != 6 {
117+ t.Fatalf("draws: plain %d zoomed %d, want 6", len(plain), len(zoomed))
118+ }
119+ for i := range plain {
120+ if i == 4 {
121+ continue
122+ }
123+ if plain[i].pc != zoomed[i].pc {
124+ t.Fatalf("draw %d differs with the zoom: %+v vs %+v", i, plain[i].pc, zoomed[i].pc)
125+ }
126+ }
127+ if veil := zoomed[2].pc; veil.rect != [4]int32{0, 0, 80, 60} || veil.color != [4]float32{0, 0, 0, 0.4} {
128+ t.Fatalf("veil = %+v, want the full-output 0.4 dim under the float", veil)
129+ }
130+ if zoomed[1].pc.rect != [4]int32{40, 0, 41, 60} {
131+ t.Fatalf("tile line = %+v, want it painted before the float", zoomed[1].pc.rect)
132+ }
133+ pr, zr := plain[4].pc.rect, zoomed[4].pc.rect
134+ if !(zr[2]-zr[0] < pr[2]-pr[0]) || zr[0] != pr[0] || zr[1] != pr[1] {
135+ t.Fatalf("zoomed content %v, want smaller than %v from the same origin", zr, pr)
136+ }
137+ // A card (Preview) keeps not opening the floats: no tile lines, no veil.
138+ card := scene(0)
139+ card.Windows[1].Preview = 0.9
140+ if ds := walk(card); len(ds) != 5 || ds[1].pc.color[3] != 0.5 {
141+ t.Fatalf("card draws = %d, want 5 (lines after, no veil): %+v", len(ds), ds)
142+ }
143+}
A internal/adapters/vulkan/scene_opaque_region_test.go +332 -0
@@ -0,0 +1,332 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "image/color"
6+ "slices"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// opaqueRegionContent is a 2x-scale shm root of w×h logical pixels under one
14+// child of the same size whose opaque region stops a pixel short of its right
15+// and bottom edges (Firefox's page over its shm root).
16+func opaqueRegionContent(t testing.TB, w, h int) ports.SurfaceContent {
17+ root := solidContent(t, 2*w, 2*h, color.RGBA{R: 255, A: 255})
18+ root.ID, root.Surface, root.Seq, root.Version = 1, 1, 1, 1
19+ root.LogicalW, root.LogicalH = w, h
20+ child := solidContent(t, w, h, color.RGBA{G: 255, A: 255})
21+ child.Surface, child.Version = 2, 1
22+ child.LogicalW, child.LogicalH = w, h
23+ child.OpaqueRect = ports.Rect{W: w - 1, H: h - 1}
24+ root.Children = []ports.Subsurface{{SurfaceContent: child}}
25+ return root
26+}
27+
28+// drawRects are the rects of the content draws (not the window's fills).
29+func drawRects(ds []draw) []image.Rectangle {
30+ var out []image.Rectangle
31+ for _, d := range ds {
32+ if d.pc.misc[0] == modeSolid {
33+ continue
34+ }
35+ out = append(out, image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3])))
36+ }
37+ return out
38+}
39+
40+// A child whose opaque region leaves a thin edge out hides the root under the
41+// region only: the root draws the two strips around it, and the child draws
42+// over its own whole rect.
43+func TestSceneOpaqueRegionClipsRoot(t *testing.T) {
44+ r, err := New(64, 48)
45+ if err != nil {
46+ t.Skipf("Vulkan unavailable: %v", err)
47+ }
48+ defer r.Close()
49+ scene := ports.Scene{Seq: 1, Scale: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 12}}}}
50+ contents := map[ports.WindowID]ports.SurfaceContent{1: opaqueRegionContent(t, 20, 12)}
51+ ds := r.draws(scene, contents, newDamage(r.target(), scene, image.Rect(0, 0, 64, 48)))
52+ want := []image.Rectangle{
53+ // The root: the strip under the last row, then the one right of the
54+ // region; then the child over its whole rect.
55+ image.Rect(0, 11, 20, 12), image.Rect(19, 0, 20, 11),
56+ image.Rect(0, 0, 20, 12),
57+ }
58+ if got := drawRects(ds); !sameRects(got, want) {
59+ t.Fatalf("draws %v, want %v", got, want)
60+ }
61+}
62+
63+func sameRects(a, b []image.Rectangle) bool {
64+ if len(a) != len(b) {
65+ return false
66+ }
67+ for i := range a {
68+ if a[i] != b[i] {
69+ return false
70+ }
71+ }
72+ return true
73+}
74+
75+// A wholly opaque child still skips the root.
76+func TestSceneOpaqueChildSkipsRoot(t *testing.T) {
77+ r, err := New(64, 48)
78+ if err != nil {
79+ t.Skipf("Vulkan unavailable: %v", err)
80+ }
81+ defer r.Close()
82+ root := opaqueRegionContent(t, 20, 12)
83+ root.Children[0].Opaque, root.Children[0].OpaqueRect = true, ports.Rect{}
84+ scene := ports.Scene{Seq: 1, Scale: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 12}}}}
85+ ds := r.draws(scene, map[ports.WindowID]ports.SurfaceContent{1: root}, newDamage(r.target(), scene, image.Rect(0, 0, 64, 48)))
86+ if got, want := drawRects(ds), []image.Rectangle{image.Rect(0, 0, 20, 12)}; !sameRects(got, want) {
87+ t.Fatalf("draws %v, want %v", got, want)
88+ }
89+}
90+
91+// The strips show the root exactly as it draws alone: the child is
92+// transparent on its last row and column, outside its opaque region, and the
93+// root's pixels show through there, mapped as without the clipping. Inside
94+// the region the child shows.
95+func TestRendererOpaqueRegionStripsKeepRoot(t *testing.T) {
96+ r, err := New(64, 48)
97+ if err != nil {
98+ t.Skipf("Vulkan unavailable: %v", err)
99+ }
100+ defer r.Close()
101+ const w, h = 20, 12
102+ scene := ports.Scene{Scale: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: w, H: h}}}}
103+ root := opaqueRegionContent(t, w, h)
104+ // A 2x root with a gradient, to see a wrong strip mapping; a child
105+ // that is green but for a transparent last row and column.
106+ px := make([]byte, 2*w*2*h*4)
107+ for y := range 2 * h {
108+ for x := range 2 * w {
109+ copy(px[(y*2*w+x)*4:], []byte{0, uint8(y * 10), uint8(x * 6), 255})
110+ }
111+ }
112+ root.SHM = shmContent(t, 2*w, 2*h, 2*w*4, px).SHM
113+ cp := make([]byte, w*h*4)
114+ for y := range h {
115+ for x := range w {
116+ if x < w-1 && y < h-1 {
117+ copy(cp[(y*w+x)*4:], []byte{0, 255, 0, 255})
118+ }
119+ }
120+ }
121+ root.Children[0].SHM = shmContent(t, w, h, w*4, cp).SHM
122+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: root}); err != nil {
123+ t.Fatal(err)
124+ }
125+ with := readPixels(t, r)
126+ alone := root
127+ alone.Children = nil
128+ alone.Seq, alone.Version = 2, 2
129+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: alone}); err != nil {
130+ t.Fatal(err)
131+ }
132+ without := readPixels(t, r)
133+ green := color.RGBA{G: 255, A: 255}
134+ for y := range h {
135+ for x := range w {
136+ want := without.At(x, y)
137+ if x < w-1 && y < h-1 {
138+ want = green
139+ }
140+ if got := with.At(x, y); got != want {
141+ t.Fatalf("At(%d,%d)=%v want %v", x, y, got, want)
142+ }
143+ }
144+ }
145+}
146+
147+// opaqueCover maps the opaque rect onto the physical rect of the surface,
148+// rounding inward, clipped to what shows.
149+func TestOpaqueCover(t *testing.T) {
150+ c := ports.SurfaceContent{LogicalW: 984, LogicalH: 1231, OpaqueRect: ports.Rect{W: 983, H: 1230}}
151+ for _, tc := range []struct {
152+ name string
153+ full, dst image.Rectangle
154+ rect ports.Rect
155+ want image.Rectangle
156+ }{
157+ {"scale 1.3", image.Rect(0, 0, 1279, 1600), image.Rect(0, 0, 1279, 1600), c.OpaqueRect, image.Rect(0, 0, 1277, 1598)},
158+ {"offset", image.Rect(10, 20, 1289, 1620), image.Rect(10, 20, 1289, 1620), c.OpaqueRect, image.Rect(10, 20, 1287, 1618)},
159+ {"clipped", image.Rect(0, 0, 1279, 1600), image.Rect(0, 0, 100, 100), c.OpaqueRect, image.Rect(0, 0, 100, 100)},
160+ {"inner rect", image.Rect(0, 0, 984, 1231), image.Rect(0, 0, 984, 1231), ports.Rect{X: 4, Y: 8, W: 100, H: 200}, image.Rect(4, 8, 104, 208)},
161+ {"outside", image.Rect(0, 0, 984, 1231), image.Rect(0, 0, 3, 3), ports.Rect{X: 4, Y: 8, W: 100, H: 200}, image.Rectangle{}},
162+ } {
163+ t.Run(tc.name, func(t *testing.T) {
164+ c.OpaqueRect = tc.rect
165+ got := opaqueCover(&c, tc.full, tc.dst)
166+ if got != tc.want {
167+ t.Fatalf("opaqueCover = %v, want %v", got, tc.want)
168+ }
169+ })
170+ }
171+}
172+
173+// Building the draws of a window with a partly opaque child allocates
174+// nothing.
175+func TestSceneOpaqueRegionAllocations(t *testing.T) {
176+ r, err := New(64, 48)
177+ if err != nil {
178+ t.Skipf("Vulkan unavailable: %v", err)
179+ }
180+ defer r.Close()
181+ scene := ports.Scene{Seq: 1, Scale: 1.5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 12}}}}
182+ contents := map[ports.WindowID]ports.SurfaceContent{1: opaqueRegionContent(t, 20, 12)}
183+ bounds := image.Rect(0, 0, 64, 48)
184+ frame := func() { _ = r.draws(scene, contents, r.frameDamage(r.target(), scene, bounds)) }
185+ frame()
186+ if allocs := testing.AllocsPerRun(50, frame); allocs != 0 {
187+ t.Errorf("partly opaque child draws: %.1f allocs/frame, want 0", allocs)
188+ }
189+}
190+
191+// BenchmarkComposeOpaqueRegionChild composes a Firefox-like window moved every
192+// frame as a workspace swipe does, at 2560x1600 and scale 1.3: a 2x shm root
193+// of 984x1231 logical pixels under a dmabuf page of 1279x1600 pixels shown
194+// at 984x1231 (the viewport destination). "none" is a page with no opaque
195+// region (the root draws in full), "short" one with a region a logical pixel
196+// short of its right and bottom edges, "whole" one that covers it. The GPU is
197+// waited for after every frame. Select it with NEFERWL_VK_DEVICE.
198+func BenchmarkComposeOpaqueRegionChild(b *testing.B) {
199+ const w, h, lw, lh = 2560, 1600, 984, 1231
200+ for _, tc := range []struct {
201+ name string
202+ opaque bool
203+ rect ports.Rect
204+ }{
205+ {"none", false, ports.Rect{}},
206+ {"short", false, ports.Rect{W: lw - 1, H: lh - 1}},
207+ {"whole", true, ports.Rect{}},
208+ } {
209+ b.Run(tc.name, func(b *testing.B) {
210+ r, err := New(w, h)
211+ if err != nil {
212+ b.Skipf("Vulkan unavailable: %v", err)
213+ }
214+ defer r.Close()
215+ linear := ports.DMABufFormat{Format: fourcc('A', 'R', '2', '4'), Modifier: 0}
216+ if !slices.Contains(r.DMABuf().Formats, linear) {
217+ b.Skipf("linear ARGB8888 not importable")
218+ }
219+ const pw, ph = 1279, 1600
220+ f := udmabuf(b, 1280, ph, func(x, y int) [4]byte { return [4]byte{byte(x), byte(y), 200, 255} })
221+ page := ports.SurfaceContent{Width: pw, Height: ph, LogicalW: lw, LogicalH: lh, Surface: 2, Version: 1,
222+ Opaque: tc.opaque, OpaqueRect: tc.rect,
223+ DMABuf: &ports.DMABuf{ID: 9, Width: pw, Height: ph, Format: linear.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 5120}}}}
224+ root := solidContent(b, 2*lw, 2*lh, color.RGBA{R: 240, G: 240, B: 240, A: 255})
225+ root.ID, root.Surface, root.Seq, root.Version = 1, 1, 1, 1
226+ root.LogicalW, root.LogicalH = lw, lh
227+ root.Opaque = true
228+ root.Children = []ports.Subsurface{{SurfaceContent: page}}
229+ contents := map[ports.WindowID]ports.SurfaceContent{1: root}
230+ frame := func(i int) {
231+ x := i % 64
232+ scene := ports.Scene{Seq: uint64(i + 1), Scale: 1.3, Background: "#101010", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: x, W: lw, H: lh}}}}
233+ if err := render(r, scene, contents); err != nil {
234+ b.Fatal(err)
235+ }
236+ if err := r.waitFrame(r.submitted); err != nil {
237+ b.Fatal(err)
238+ }
239+ }
240+ for i := range 8 {
241+ frame(i)
242+ }
243+ b.ResetTimer()
244+ start := time.Now()
245+ for i := range b.N {
246+ frame(i + 8)
247+ }
248+ b.ReportMetric(float64(time.Since(start).Microseconds())/1000/float64(b.N), "ms/frame")
249+ })
250+ }
251+}
252+
253+// crossingCovers is a root of w×h pixels under n opaque 10×10 children
254+// staggered over it, so that each cover crosses the root and the others.
255+func crossingCovers(t testing.TB, w, h, n int) ports.SurfaceContent {
256+ root := solidContent(t, w, h, color.RGBA{R: 255, A: 255})
257+ root.ID, root.Surface, root.Seq, root.Version = 1, 1, 1, 1
258+ for i := range n {
259+ child := solidContent(t, 10, 10, color.RGBA{G: 255, A: 255})
260+ child.Surface, child.Version, child.Opaque = uint64(2+i), 1, true
261+ root.Children = append(root.Children, ports.Subsurface{SurfaceContent: child, X: (i * 7) % (w - 10), Y: (i * 5) % (h - 10)})
262+ }
263+ return root
264+}
265+
266+// A client chooses how many opaque subsurfaces cross its root, and each one
267+// cuts the root quad into strips: the split stops at maxSplitCovers covers
268+// or maxSplitStrips draws, and the root then draws whole (overdraw only, the
269+// covers above it still hide the rest).
270+func TestSceneSplitIsBounded(t *testing.T) {
271+ r, err := New(64, 48)
272+ if err != nil {
273+ t.Skipf("Vulkan unavailable: %v", err)
274+ }
275+ defer r.Close()
276+ const w, h = 60, 40
277+ scene := ports.Scene{Seq: 1, Scale: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: w, H: h}}}}
278+ full := image.Rect(0, 0, w, h)
279+ for _, n := range []int{1, maxSplitCovers, maxSplitCovers + 1, 40} {
280+ contents := map[ports.WindowID]ports.SurfaceContent{1: crossingCovers(t, w, h, n)}
281+ ds := r.draws(scene, contents, newDamage(r.target(), scene, image.Rect(0, 0, 64, 48)))
282+ rects := drawRects(ds)
283+ root := len(rects) - n // the children draw once each, after the root
284+ if root < 1 || root > maxSplitStrips {
285+ t.Errorf("%d covers: %d root draws, want 1..%d", n, root, maxSplitStrips)
286+ }
287+ if n > maxSplitCovers && (root != 1 || rects[0] != full) {
288+ t.Errorf("%d covers: root draws %v, want the whole quad %v", n, rects[:root], full)
289+ }
290+ if n == 1 && root < 2 {
291+ t.Errorf("one cover: %d root draws, want the quad split around it", root)
292+ }
293+ bounds := image.Rect(0, 0, 64, 48)
294+ frame := func() { _ = r.draws(scene, contents, r.frameDamage(r.target(), scene, bounds)) }
295+ frame()
296+ if allocs := testing.AllocsPerRun(20, frame); allocs != 0 {
297+ t.Errorf("%d covers: %.1f allocs/frame, want 0", n, allocs)
298+ }
299+ }
300+}
301+
302+// With the split given up on, the covers above the whole root quad still
303+// show: the window's pixels are the children's inside them and the root's
304+// elsewhere.
305+func TestRendererSplitBoundKeepsRoot(t *testing.T) {
306+ r, err := New(64, 48)
307+ if err != nil {
308+ t.Skipf("Vulkan unavailable: %v", err)
309+ }
310+ defer r.Close()
311+ const w, h, n = 60, 40, 40
312+ scene := ports.Scene{Scale: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: w, H: h}}}}
313+ root := crossingCovers(t, w, h, n)
314+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: root}); err != nil {
315+ t.Fatal(err)
316+ }
317+ got := readPixels(t, r)
318+ red, green := color.RGBA{R: 255, A: 255}, color.RGBA{G: 255, A: 255}
319+ for y := range h {
320+ for x := range w {
321+ want := red
322+ for _, c := range root.Children {
323+ if image.Pt(x, y).In(image.Rect(c.X, c.Y, c.X+10, c.Y+10)) {
324+ want = green
325+ }
326+ }
327+ if g := got.At(x, y); g != want {
328+ t.Fatalf("At(%d,%d)=%v want %v", x, y, g, want)
329+ }
330+ }
331+ }
332+}
M internal/adapters/vulkan/scene_perf_test.go +92 -1
@@ -9,7 +9,11 @@ import (
99 )
1010
1111 // TestRenderSteadyStateAllocations measures the cost of a small stable scene,
12-// on a normal and on a rotated output (orient works in place).
12+// on a normal and on a rotated output (orient works in place), and that
13+// building the draws of a scene with a moving window (a new Seq and window
14+// slice every frame, as an animation publishes) allocates nothing.
15+// A new scene Seq with one of two windows moved is a partial redraw seeded by
16+// sceneDelta and allocates nothing either.
1317 func TestRenderSteadyStateAllocations(t *testing.T) {
1418 for _, tc := range []struct {
1519 name string
@@ -41,6 +45,93 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
4145 } else {
4246 t.Logf("small steady-state Render: %.1f allocs/frame", allocs)
4347 }
48+ // A moving window: scenes are prebuilt (core allocates them, not
49+ // the renderer), each with a fresh Seq, so every frame draws
50+ // everything again. The draw building must not allocate.
51+ var moving [4]ports.Scene
52+ for i := range moving {
53+ moving[i] = ports.Scene{Seq: uint64(100 + i), Transform: tc.transform, Scale: 1, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: i, W: tc.sceneW - 4, H: tc.sceneH - 4}}}}
54+ }
55+ bounds := image.Rect(0, 0, r.width, r.height)
56+ n := 0
57+ frameDraws := func() {
58+ n++
59+ s := moving[n%len(moving)]
60+ _ = r.draws(s, contents, r.frameDamage(r.last, s, bounds))
61+ }
62+ frameDraws()
63+ if allocs := testing.AllocsPerRun(50, frameDraws); allocs != 0 {
64+ t.Errorf("moving window draws: %.1f allocs/frame, want 0", allocs)
65+ }
66+ // A content change under an unchanged scene Seq: a partial
67+ // redraw clipped to the changed buffer region, replicating the
68+ // partial sequence of Render. The stand-in target holds the
69+ // previous content Seq of the window, and is never updated, so
70+ // every run is damaged.
71+ dc := content
72+ dc.Seq, dc.Version = 2, 2
73+ dc.DamageHistory = []ports.SeqDamage{{Seq: 2, Rects: []ports.Rect{{X: 1, Y: 1, W: 2, H: 2}}}}
74+ pcontents := map[ports.WindowID]ports.SurfaceContent{1: dc}
75+ held := &target{valid: true, sceneSeq: scene.Seq, windows: map[ports.WindowID]heldWindow{1: {seq: 1, rect: image.Rect(0, 0, r.width, r.height)}}}
76+ var area image.Rectangle
77+ var whole bool
78+ partial := func() {
79+ dmg := r.frameDamage(held, scene, bounds)
80+ ds := r.draws(scene, pcontents, dmg)
81+ dmg.finish()
82+ ds = append(ds, draw{})
83+ copy(ds[1:], ds[:len(ds)-1])
84+ ds[0] = r.fillDraw(dmg.area, parseColor(scene.Background))
85+ ds = dmg.clip(ds)
86+ r.scratchDraws = ds
87+ r.orient(ds, scene.Transform)
88+ area, whole = dmg.area, dmg.all()
89+ }
90+ partial()
91+ if whole || area.Empty() || area.Dx()*area.Dy() >= bounds.Dx()*bounds.Dy() {
92+ t.Fatalf("not a partial redraw: all %v, area %v of %v", whole, area, bounds)
93+ }
94+ if allocs := testing.AllocsPerRun(50, partial); allocs != 0 {
95+ t.Errorf("partial redraw draws: %.1f allocs/frame, want 0", allocs)
96+ }
97+ // A new scene Seq with one window moved: the delta against the
98+ // scene the target holds seeds the partial redraw (A2). The
99+ // stand-in target holds the old scene and is never updated.
100+ half := tc.sceneW / 2
101+ content2 := solidContent(t, 8, 8, color.RGBA{G: 255, A: 255})
102+ content2.ID, content2.Surface, content2.Seq, content2.Version = 2, 2, 1, 1
103+ dcontents := map[ports.WindowID]ports.SurfaceContent{1: content, 2: content2}
104+ before := ports.Scene{Seq: 200, Transform: tc.transform, Scale: 1, Windows: []ports.SceneWindow{
105+ {ID: 1, Rect: ports.Rect{W: half, H: tc.sceneH}},
106+ {ID: 2, Rect: ports.Rect{X: half, W: tc.sceneW - half, H: tc.sceneH}},
107+ }}
108+ moved := before
109+ moved.Seq = 201
110+ moved.Windows = []ports.SceneWindow{before.Windows[0], {ID: 2, Rect: ports.Rect{X: half + 1, W: tc.sceneW - half - 1, H: tc.sceneH}}}
111+ full := image.Rect(0, 0, r.width, r.height)
112+ held = &target{valid: true, sceneSeq: before.Seq, scene: before, windows: map[ports.WindowID]heldWindow{
113+ 1: {seq: 1, rect: image.Rect(0, 0, half, tc.sceneH)},
114+ 2: {seq: 1, rect: image.Rect(half, 0, tc.sceneW, tc.sceneH)},
115+ }}
116+ seeded := func() {
117+ dmg := r.frameDamage(held, moved, bounds)
118+ ds := r.draws(moved, dcontents, dmg)
119+ dmg.finish()
120+ ds = append(ds, draw{})
121+ copy(ds[1:], ds[:len(ds)-1])
122+ ds[0] = r.fillDraw(dmg.area, parseColor(moved.Background))
123+ ds = dmg.clip(ds)
124+ r.scratchDraws = ds
125+ r.orient(ds, moved.Transform)
126+ area, whole = dmg.area, dmg.all()
127+ }
128+ seeded()
129+ if whole || area.Empty() || area.Dx()*area.Dy() >= full.Dx()*full.Dy() {
130+ t.Fatalf("sceneDelta did not seed a partial redraw: all %v, area %v of %v", whole, area, bounds)
131+ }
132+ if allocs := testing.AllocsPerRun(50, seeded); allocs != 0 {
133+ t.Errorf("sceneDelta-seeded partial redraw draws: %.1f allocs/frame, want 0", allocs)
134+ }
44135 })
45136 }
46137 }
A internal/adapters/vulkan/scene_rect_content_test.go +60 -0
@@ -0,0 +1,60 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// A window under a presentation-only rect transition shows a rect that differs from its content
11+// (the client has its final size already). These walk tests need no Vulkan
12+// device: a single-pixel buffer is a solid quad.
13+func TestSceneContentVsWindowRect(t *testing.T) {
14+ r := &Renderer{width: 80, height: 60}
15+ // A draw's rect is its corners: x0, y0, x1, y1.
16+ // walk returns the content draws: the window's background fill is first.
17+ walk := func(rect ports.Rect, lw, lh int, geo ports.Rect) []draw {
18+ c := ports.SurfaceContent{
19+ ID: 1, Width: 1, Height: 1, LogicalW: lw, LogicalH: lh, Geometry: geo,
20+ Solid: &ports.SolidColor{R: 1, A: 1}, Opaque: true,
21+ }
22+ s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1, Rect: rect}}}
23+ contents := map[ports.WindowID]ports.SurfaceContent{1: c}
24+ ds := r.draws(s, contents, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
25+ if len(ds) < 2 {
26+ t.Fatalf("no content draw: %+v", ds)
27+ }
28+ return append([]draw(nil), ds[1:]...)
29+ }
30+
31+ t.Run("rect smaller than content is clipped", func(t *testing.T) {
32+ rect := ports.Rect{X: 10, Y: 10, W: 20, H: 20}
33+ for _, geo := range []ports.Rect{{}, {X: 5, Y: 5, W: 30, H: 30}} {
34+ ds := walk(rect, 40, 40, geo)
35+ clip := image.Rect(10, 10, 30, 30)
36+ for _, d := range ds {
37+ got := image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3]))
38+ if !got.In(clip) {
39+ t.Fatalf("geometry %+v: draw %v outside rect %v", geo, got, clip)
40+ }
41+ }
42+ if d := ds[0].pc.rect; d != [4]int32{10, 10, 30, 30} {
43+ t.Fatalf("geometry %+v: content = %v, want the whole rect", geo, d)
44+ }
45+ }
46+ })
47+
48+ t.Run("rect larger than content shows only the content", func(t *testing.T) {
49+ rect := ports.Rect{X: 10, Y: 10, W: 50, H: 40}
50+ ds := walk(rect, 20, 16, ports.Rect{})
51+ if len(ds) != 1 || ds[0].pc.rect != [4]int32{10, 10, 30, 26} {
52+ t.Fatalf("content = %+v, want 20x16 at the rect origin", ds)
53+ }
54+ // Geometry shifts the surface so its geometry origin sits at the rect origin.
55+ ds = walk(rect, 24, 20, ports.Rect{X: 4, Y: 4, W: 20, H: 16})
56+ if len(ds) != 1 || ds[0].pc.rect != [4]int32{10, 10, 30, 26} {
57+ t.Fatalf("content with geometry = %+v, want 20x16 at the rect origin", ds)
58+ }
59+ })
60+}
M internal/adapters/vulkan/shm.go +21 -9
@@ -178,24 +178,36 @@ type shmSurface struct {
178178 last uint64 // frame that drew it
179179 }
180180
181-// shmCopyFor returns the GPU buffer holding a surface's content st, copying
182-// the client pixels (stride bytes per row from offset) into one when no
183-// buffer holds them yet. A buffer holding an older content of the same
184-// size gets only what changed since (damage, in buffer pixels; false:
185-// everything).
186-func (r *Renderer) shmCopyFor(key shmKey, st shmState, pixels []byte, offset, stride int, damage func(since uint64) ([]ports.Rect, bool)) (*shmCopy, error) {
181+// shmCached returns the GPU buffer already holding a surface's content st,
182+// or nil. It needs no client pixels: a kept window whose pool is closed
183+// still draws from it.
184+func (r *Renderer) shmCached(key shmKey, st shmState) *shmCopy {
187185 s := r.shm[key]
188186 if s == nil {
189- s = &shmSurface{}
190- r.shm[key] = s
187+ return nil
191188 }
192189 s.last = r.frame
193190 for _, c := range s.bufs {
194191 if c != nil && c.valid && c.holds.w == st.w && c.holds.h == st.h && c.holds.seq == st.seq {
195192 c.gpu.last = r.frame
196- return c, nil
193+ return c
197194 }
198195 }
196+ return nil
197+}
198+
199+// shmCopyFor copies a surface's content st from the client pixels (stride
200+// bytes per row from offset) into a GPU buffer; the caller found none
201+// holding it (shmCached). A buffer holding an older content of the same
202+// size gets only what changed since (damage, in buffer pixels; false:
203+// everything).
204+func (r *Renderer) shmCopyFor(key shmKey, st shmState, pixels []byte, offset, stride int, damage func(since uint64) ([]ports.Rect, bool)) (*shmCopy, error) {
205+ s := r.shm[key]
206+ if s == nil {
207+ s = &shmSurface{}
208+ r.shm[key] = s
209+ }
210+ s.last = r.frame
199211 // The older buffer takes the new content.
200212 i := 0
201213 if s.bufs[0] != nil && (s.bufs[1] == nil || s.bufs[1].gpu.last < s.bufs[0].gpu.last) {
M internal/adapters/vulkan/targets.go +100 -23
@@ -3,6 +3,7 @@ package vulkan
33 import (
44 "errors"
55 "fmt"
6+ "maps"
67 "os"
78 "slices"
89 "unsafe"
@@ -29,11 +30,20 @@ type target struct {
2930 // exported images are shared with the display: ownership goes to the
3031 // foreign queue family after each frame, and KMS reads them.
3132 exported bool
32- // What the image holds (damage.go): the scene Seq and each window's
33- // content Seq; valid once a frame was drawn whole.
34- valid bool
35- sceneSeq uint64
36- windows map[ports.WindowID]heldWindow
33+ // What the image holds (damage.go): the scene, its Seq and each
34+ // window's content Seq; valid once a frame was drawn whole. Scenes are
35+ // immutable for core, but output adapters reuse their slices in place
36+ // (DRM overlay frames, capture), so the compared slices of scene point
37+ // into the target's own buffers, copied by hold.
38+ valid bool
39+ sceneSeq uint64
40+ scene ports.Scene
41+ heldWindows []ports.SceneWindow
42+ heldSeparators []ports.Separator
43+ heldLayers []ports.SceneLayer
44+ heldHints []ports.Rect
45+ heldIndicators []ports.CaptureIndicator
46+ windows map[ports.WindowID]heldWindow
3747 }
3848
3949 // fourccXRGB is DRM_FORMAT_XRGB8888: B8G8R8A8 in memory, alpha ignored.
@@ -44,7 +54,9 @@ const fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24
4454
4555 // ExportTargets allocates n exported images of the output size, replacing
4656 // the previous ones; n = 0 only drops them (back to the internal image).
47-func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) {
57+// singlePlane keeps only one-plane modifiers (no DCC metadata planes), for
58+// displays that refuse the multi-plane framebuffers of the others.
59+func (r *Renderer) ExportTargets(n int, modifiers []uint64, singlePlane bool) ([]ports.DMABuf, error) {
4860 r.dropTargets()
4961 if n > 0 && r.hdrNits > 0 && r.hdrReadback && r.physical != 0 {
5062 // Readback targets (GPU tests, headless --screenshot-raw) are also
@@ -60,7 +72,7 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, err
6072 if !r.dd.HasGetMemoryFdKHR() || !r.dd.HasGetImageDrmFormatModifierPropertiesEXT() || len(r.dmabuf.Formats) == 0 {
6173 return nil, errors.New("device cannot export dmabufs")
6274 }
63- mods := r.exportModifiers(modifiers)
75+ mods := r.exportModifiers(modifiers, singlePlane)
6476 if len(mods) == 0 {
6577 if r.hdrNits == 0 {
6678 return nil, errors.New("no XRGB8888 modifier both the device and the display accept")
@@ -108,14 +120,18 @@ func (r *Renderer) target() *target {
108120
109121 // exportModifiers intersects the selected signal format modifiers with
110122 // those supported by the display. HDR never assumes an unspecified list,
111-// except for a virtual output (no display) or a test readback.
112-func (r *Renderer) exportModifiers(display []uint64) []uint64 {
123+// except for a virtual output (no display) or a test readback. singlePlane
124+// drops the modifiers with more than one memory plane.
125+func (r *Renderer) exportModifiers(display []uint64, singlePlane bool) []uint64 {
113126 var out []uint64
114127 available := r.renderMods
115128 if r.hdrNits > 0 {
116129 available = r.hdrMods
117130 }
118131 for _, m := range available {
132+ if singlePlane && r.modPlanes[m] > 1 {
133+ continue
134+ }
119135 if (len(display) == 0 && (r.hdrNits == 0 || r.hdrReadback || r.virtual)) || slices.Contains(display, m) {
120136 out = append(out, m)
121137 }
@@ -123,14 +139,16 @@ func (r *Renderer) exportModifiers(display []uint64) []uint64 {
123139 return out
124140 }
125141
126-// probeRenderModifiers lists single-plane, exportable SDR and HDR targets.
142+// probeRenderModifiers lists exportable SDR and HDR targets.
127143 func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) {
128144 r.renderMods = r.probeModifiers(physical, vk.FormatB8g8r8a8Unorm)
129145 r.hdrMods = r.probeModifiers(physical, vk.FormatA2r10g10b10UnormPack32)
130146 }
131147
132-// probeModifiers lists the single-plane, exportable modifiers of format that
133-// support the target usage. HDR targets need transfer-src only when
148+// probeModifiers lists the exportable modifiers of format (one to four memory
149+// planes: DCC adds metadata planes; DCC retile modifiers are left out, see
150+// retilesDCC) that support the target usage, and
151+// records their plane counts in modPlanes. HDR targets need transfer-src only when
134152 // hdrReadback is set (GPU tests, headless --screenshot-raw; never DRM).
135153 func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 {
136154 list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT}
@@ -148,17 +166,55 @@ func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format)
148166 } else if r.hdrReadback {
149167 need |= formatFeatureTransferSrc
150168 }
151- var result []uint64
152169 // SDR targets are composed and read back; HDR targets are color
153170 // attachments, and transfer sources only with hdrReadback.
154- for _, m := range mods[:list.DrmFormatModifierCount] {
155- if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, format, m.DrmFormatModifier) {
156- result = append(result, m.DrmFormatModifier)
157- }
171+ result, planes := filterModifiers(mods[:list.DrmFormatModifierCount], need, func(m uint64) bool { return r.exportable(physical, format, m) })
172+ if r.modPlanes == nil {
173+ r.modPlanes = make(map[uint64]uint32)
158174 }
175+ // A modifier has the same plane count for every format (DCC planes
176+ // come from the modifier), so SDR and HDR share the map.
177+ maps.Copy(r.modPlanes, planes)
159178 return result
160179 }
161180
181+// maxModifierPlanes is the DRM limit of planes in a framebuffer.
182+const maxModifierPlanes = 4
183+
184+// AMD format modifiers (drm_fourcc.h): the vendor is in bits 56-63, and a DCC
185+// modifier with the retile bit keeps a second, display-aligned copy of the
186+// DCC metadata.
187+const (
188+ modVendorShift = 56
189+ modVendorAMD = 0x02
190+ amdModRetile = 1 << 14 // AMD_FMT_MOD_DCC_RETILE
191+)
192+
193+// retilesDCC reports whether m is an AMD DCC modifier with the retile bit.
194+// RADV renders such an image into pipe-aligned DCC and runs a compute pass
195+// that rewrites the display copy: an extra full-surface GPU pass per frame,
196+// so these modifiers are never offered for targets. Plain DCC (pipe-aligned
197+// or not) costs no such pass.
198+func retilesDCC(m uint64) bool {
199+ return m>>modVendorShift == modVendorAMD && m&amdModRetile != 0
200+}
201+
202+// filterModifiers keeps the modifiers with 1..4 memory planes whose tiling
203+// features include need, that are not DCC retile modifiers and that pass
204+// exportable, and returns each kept modifier's plane count.
205+func filterModifiers(mods []vk.DrmFormatModifierPropertiesEXT, need vk.FormatFeatureFlags, exportable func(uint64) bool) ([]uint64, map[uint64]uint32) {
206+ var result []uint64
207+ planes := make(map[uint64]uint32, len(mods))
208+ for _, m := range mods {
209+ if m.DrmFormatModifierPlaneCount < 1 || m.DrmFormatModifierPlaneCount > maxModifierPlanes || m.DrmFormatModifierTilingFeatures&need != need || retilesDCC(m.DrmFormatModifier) || !exportable(m.DrmFormatModifier) {
210+ continue
211+ }
212+ result = append(result, m.DrmFormatModifier)
213+ planes[m.DrmFormatModifier] = m.DrmFormatModifierPlaneCount
214+ }
215+ return result, planes
216+}
217+
162218 func (r *Renderer) exportable(physical vk.PhysicalDevice, format vk.Format, modifier uint64) bool {
163219 mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive}
164220 ext := vk.PhysicalDeviceExternalImageFormatInfo{SType: vk.StructureTypePhysicalDeviceExternalImageFormatInfo, Next: unsafe.Pointer(&mod), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
@@ -226,9 +282,12 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) {
226282 if err := checked("vkGetImageDrmFormatModifierPropertiesEXT", d.GetImageDrmFormatModifierPropertiesEXT(r.device, t.image, &props)); err != nil {
227283 return nil, ports.DMABuf{}, err
228284 }
229- var layout vk.SubresourceLayout
230- sub := vk.ImageSubresource{AspectMask: vk.ImageAspectMemoryPlane0BitEXT}
231- d.GetImageSubresourceLayout(r.device, t.image, &sub, &layout)
285+ n := min(max(r.modPlanes[props.DrmFormatModifier], 1), maxModifierPlanes)
286+ var layouts [maxModifierPlanes]vk.SubresourceLayout
287+ for i := range n {
288+ sub := vk.ImageSubresource{AspectMask: vk.ImageAspectMemoryPlane0BitEXT << i}
289+ d.GetImageSubresourceLayout(r.device, t.image, &sub, &layouts[i])
290+ }
232291 t.view, err = r.imageView(t.image, format)
233292 if err != nil {
234293 return nil, ports.DMABuf{}, err
@@ -238,11 +297,29 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) {
238297 if err := checked("vkGetMemoryFdKHR", d.GetMemoryFdKHR(r.device, &get, &fd)); err != nil {
239298 return nil, ports.DMABuf{}, err
240299 }
241- f := os.NewFile(uintptr(fd), "neferwl-target")
242- if f == nil {
300+ // Every plane owns its file: the planes share the one memory object,
301+ // so planes 1.. are duplicates of the exported fd.
302+ planes := make([]ports.DMABufPlane, 0, n)
303+ closePlanes := func() {
304+ for _, p := range planes {
305+ _ = p.File.Close()
306+ }
307+ }
308+ first := os.NewFile(uintptr(fd), "neferwl-target")
309+ if first == nil {
243310 return nil, ports.DMABuf{}, fmt.Errorf("invalid exported fd %d", fd)
244311 }
245- buf := ports.DMABuf{Width: r.width, Height: r.height, Format: fourcc, Modifier: props.DrmFormatModifier, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}
312+ planes = append(planes, ports.DMABufPlane{File: first, Offset: uint32(layouts[0].Offset), Stride: uint32(layouts[0].RowPitch)})
313+ for i := uint32(1); i < n; i++ {
314+ dup, err := dupFile(first)
315+ if err != nil {
316+ closePlanes()
317+ return nil, ports.DMABuf{}, fmt.Errorf("duplicate exported fd for plane %d: %w", i, err)
318+ }
319+ planes = append(planes, ports.DMABufPlane{File: os.NewFile(uintptr(dup), "neferwl-target"), Offset: uint32(layouts[i].Offset), Stride: uint32(layouts[i].RowPitch)})
320+ }
321+ r.log.Debug().Uint64("modifier", props.DrmFormatModifier).Uint32("planes", n).Msg("render target exported")
322+ buf := ports.DMABuf{Width: r.width, Height: r.height, Format: fourcc, Modifier: props.DrmFormatModifier, Planes: planes}
246323 ok = true
247324 return t, buf, nil
248325 }
A internal/adapters/vulkan/targets_perf_test.go +83 -0
@@ -0,0 +1,83 @@
1+package vulkan
2+
3+import (
4+ "fmt"
5+ "image/color"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// BenchmarkComposeFullTarget composes a full-redraw frame (three windows
13+// covering the output, moved every frame as a workspace swipe does) into an
14+// exported target of each modifier the device offers for B8G8R8A8, and waits
15+// for the GPU after every frame: the time per frame is the GPU cost of the
16+// target's tiling/compression layout (DCC retile passes included). Select the
17+// GPU with NEFERWL_VK_DEVICE; the size is 2560x1600.
18+func BenchmarkComposeFullTarget(b *testing.B) {
19+ const w, h = 2560, 1600
20+ probe, err := New(w, h)
21+ if err != nil {
22+ b.Skipf("Vulkan unavailable: %v", err)
23+ }
24+ mods := append([]uint64(nil), probe.renderMods...)
25+ planes := probe.modPlanes
26+ probe.Close()
27+ if len(mods) == 0 {
28+ b.Skip("device exports no render modifier")
29+ }
30+ for _, m := range mods {
31+ b.Run(fmt.Sprintf("%#x/planes=%d", m, planes[m]), func(b *testing.B) {
32+ r, err := New(w, h)
33+ if err != nil {
34+ b.Skipf("Vulkan unavailable: %v", err)
35+ }
36+ defer r.Close()
37+ bufs, err := r.ExportTargets(2, []uint64{m}, false)
38+ if err != nil {
39+ b.Skipf("modifier %#x not exportable: %v", m, err)
40+ }
41+ defer func() {
42+ for _, buf := range bufs {
43+ for _, p := range buf.Planes {
44+ p.File.Close()
45+ }
46+ }
47+ }()
48+ ww := w / 2
49+ contents := map[ports.WindowID]ports.SurfaceContent{}
50+ for i, c := range []color.RGBA{{R: 200, A: 255}, {G: 200, A: 255}, {B: 200, A: 255}} {
51+ content := solidContent(b, ww, h, c)
52+ content.ID, content.Surface, content.Seq, content.Version = ports.WindowID(i+1), uint64(i+1), 1, 1
53+ contents[content.ID] = content
54+ }
55+ scene := func(i int) ports.Scene {
56+ x := i % 64
57+ return ports.Scene{Seq: uint64(i + 1), Scale: 1, Background: "#101010", Windows: []ports.SceneWindow{
58+ {ID: 1, Rect: ports.Rect{X: x - ww, W: ww, H: h}},
59+ {ID: 2, Rect: ports.Rect{X: x, W: ww, H: h}},
60+ {ID: 3, Rect: ports.Rect{X: x + ww, W: ww, H: h}},
61+ }}
62+ }
63+ frame := func(i int) {
64+ r.UseTarget(i % 2)
65+ if err := render(r, scene(i), contents); err != nil {
66+ b.Fatal(err)
67+ }
68+ if err := r.waitFrame(r.submitted); err != nil {
69+ b.Fatal(err)
70+ }
71+ }
72+ for i := range 8 {
73+ frame(i)
74+ }
75+ b.ResetTimer()
76+ start := time.Now()
77+ for i := range b.N {
78+ frame(i + 8)
79+ }
80+ b.ReportMetric(float64(time.Since(start).Microseconds())/1000/float64(b.N), "ms/frame")
81+ })
82+ }
83+}
M internal/adapters/vulkan/targets_test.go +206 -6
@@ -3,6 +3,7 @@ package vulkan
33 import (
44 "image/color"
55 "math"
6+ "slices"
67 "testing"
78
89 "github.com/bnema/neferwl/internal/ports"
@@ -11,20 +12,24 @@ import (
1112
1213 // Frames drawn into exported targets reach their dmabufs: another renderer
1314 // importing a target (as KMS scans it out) sees the frame, and readback
14-// reads the target drawn last.
15+// reads the target drawn last. The viewer imports one-plane buffers only,
16+// so the targets are single-plane here; TestExportTargetsPlanes covers the
17+// multi-plane (DCC) ones.
1518 func TestExportTargets(t *testing.T) {
1619 r, err := New(64, 32)
1720 if err != nil {
1821 t.Skipf("Vulkan unavailable: %v", err)
1922 }
2023 defer r.Close()
21- bufs, err := r.ExportTargets(2, nil)
24+ bufs, err := r.ExportTargets(2, nil, true)
2225 if err != nil {
2326 t.Skipf("no exportable targets: %v", err)
2427 }
2528 defer func() {
2629 for _, b := range bufs {
27- b.Planes[0].File.Close()
30+ for _, p := range b.Planes {
31+ p.File.Close()
32+ }
2833 }
2934 }()
3035 if len(bufs) != 2 || bufs[0].Width != 64 || bufs[0].Format != fourccXRGB || bufs[0].Planes[0].Stride < 64*4 {
@@ -83,7 +88,7 @@ func TestHDRExportTarget(t *testing.T) {
8388 if len(r.hdrMods) == 0 {
8489 t.Skip("no HDR modifier supporting transfer-src")
8590 }
86- bufs, err := r.ExportTargets(1, r.hdrMods)
91+ bufs, err := r.ExportTargets(1, r.hdrMods, false)
8792 if err != nil {
8893 t.Skipf("no HDR-exportable target: %v", err)
8994 }
@@ -140,12 +145,12 @@ func TestVirtualHDRExportsWithoutDisplayList(t *testing.T) {
140145 t.Skip("no exportable HDR GPU")
141146 }
142147 r.SetHDR(203)
143- if bufs, err := r.ExportTargets(1, nil); err == nil {
148+ if bufs, err := r.ExportTargets(1, nil, false); err == nil {
144149 bufs[0].Planes[0].File.Close()
145150 t.Fatal("HDR export without a display list succeeded on a non-virtual output")
146151 }
147152 r.SetVirtualOutput(true)
148- bufs, err := r.ExportTargets(1, nil)
153+ bufs, err := r.ExportTargets(1, nil, false)
149154 if err != nil {
150155 t.Fatalf("virtual HDR export: %v", err)
151156 }
@@ -167,3 +172,198 @@ func checkHDRPixel(t *testing.T, r *Renderer, rgb [3]float64) {
167172 }
168173 }
169174 }
175+
176+// Targets may use DCC modifiers: one to four memory planes are kept and
177+// their counts recorded; singlePlane drops the multi-plane ones.
178+func TestFilterModifiersKeepsMultiPlane(t *testing.T) {
179+ need := vk.FormatFeatureFlags(vk.FormatFeatureColorAttachmentBit)
180+ mods := []vk.DrmFormatModifierPropertiesEXT{
181+ {DrmFormatModifier: 0, DrmFormatModifierPlaneCount: 1, DrmFormatModifierTilingFeatures: need},
182+ {DrmFormatModifier: 10, DrmFormatModifierPlaneCount: 3, DrmFormatModifierTilingFeatures: need},
183+ {DrmFormatModifier: 11, DrmFormatModifierPlaneCount: 4, DrmFormatModifierTilingFeatures: need},
184+ {DrmFormatModifier: 12, DrmFormatModifierPlaneCount: 5, DrmFormatModifierTilingFeatures: need},
185+ {DrmFormatModifier: 13, DrmFormatModifierPlaneCount: 0, DrmFormatModifierTilingFeatures: need},
186+ {DrmFormatModifier: 14, DrmFormatModifierPlaneCount: 2},
187+ {DrmFormatModifier: 15, DrmFormatModifierPlaneCount: 2, DrmFormatModifierTilingFeatures: need},
188+ }
189+ got, planes := filterModifiers(mods, need, func(m uint64) bool { return m != 15 })
190+ if !slices.Equal(got, []uint64{0, 10, 11}) {
191+ t.Fatalf("kept %v", got)
192+ }
193+ if planes[0] != 1 || planes[10] != 3 || planes[11] != 4 || len(planes) != 3 {
194+ t.Fatalf("planes %v", planes)
195+ }
196+ r := &Renderer{renderMods: got, modPlanes: planes}
197+ if all := r.exportModifiers(nil, false); !slices.Equal(all, []uint64{0, 10, 11}) {
198+ t.Fatalf("multi-plane export %v", all)
199+ }
200+ if one := r.exportModifiers(nil, true); !slices.Equal(one, []uint64{0}) {
201+ t.Fatalf("single-plane export %v", one)
202+ }
203+ if one := r.exportModifiers([]uint64{10, 11}, true); len(one) != 0 {
204+ t.Fatalf("single-plane with only DCC offered: %v", one)
205+ }
206+ if two := r.exportModifiers([]uint64{10, 0}, false); !slices.Equal(two, []uint64{0, 10}) {
207+ t.Fatalf("display intersection %v", two)
208+ }
209+}
210+
211+// Every exported target carries one file per memory plane of its modifier
212+// (DCC modifiers have two to four), each with a layout, and drawing into
213+// each target works: the frames read back through the producing renderer.
214+// A multi-plane image cannot be read back through a viewer import, which
215+// only imports one-plane buffers.
216+func TestExportTargetsPlanes(t *testing.T) {
217+ r, err := New(64, 32)
218+ if err != nil {
219+ t.Skipf("Vulkan unavailable: %v", err)
220+ }
221+ defer r.Close()
222+ closeAll := func(bufs []ports.DMABuf) {
223+ for _, b := range bufs {
224+ for _, p := range b.Planes {
225+ p.File.Close()
226+ }
227+ }
228+ }
229+ // Single-plane export never returns a multi-plane modifier.
230+ one, err := r.ExportTargets(1, nil, true)
231+ if err != nil {
232+ t.Skipf("no single-plane targets: %v", err)
233+ }
234+ if len(one[0].Planes) != 1 || r.modPlanes[one[0].Modifier] > 1 {
235+ t.Fatalf("single-plane export has %d planes (modifier %#x)", len(one[0].Planes), one[0].Modifier)
236+ }
237+ closeAll(one)
238+ multi := false
239+ for _, m := range r.renderMods {
240+ multi = multi || r.modPlanes[m] > 1
241+ }
242+ if !multi {
243+ t.Skip("device has no multi-plane target modifier")
244+ }
245+ bufs, err := r.ExportTargets(2, nil, false)
246+ if err != nil {
247+ t.Skipf("no exportable targets: %v", err)
248+ }
249+ defer closeAll(bufs)
250+ for _, b := range bufs {
251+ want := max(r.modPlanes[b.Modifier], 1)
252+ if uint32(len(b.Planes)) != want {
253+ t.Fatalf("modifier %#x: %d plane files, want %d", b.Modifier, len(b.Planes), want)
254+ }
255+ seen := map[int]bool{}
256+ for i, p := range b.Planes {
257+ if p.File == nil || p.Stride == 0 {
258+ t.Fatalf("modifier %#x plane %d: %+v", b.Modifier, i, p)
259+ }
260+ // The planes share one memory object: planes 1.. lie after plane 0.
261+ if i > 0 && p.Offset == 0 {
262+ t.Fatalf("modifier %#x plane %d at offset 0", b.Modifier, i)
263+ }
264+ if seen[int(p.File.Fd())] {
265+ t.Fatalf("modifier %#x: plane %d shares a file", b.Modifier, i)
266+ }
267+ seen[int(p.File.Fd())] = true
268+ }
269+ t.Logf("modifier %#x planes %d", b.Modifier, len(b.Planes))
270+ }
271+ for i, tc := range []struct {
272+ bg string
273+ want color.RGBA
274+ }{{"#ff0000", color.RGBA{255, 0, 0, 255}}, {"#0000ff", color.RGBA{0, 0, 255, 255}}} {
275+ r.UseTarget(i)
276+ if err := render(r, ports.Scene{Background: tc.bg}, nil); err != nil {
277+ t.Fatal(err)
278+ }
279+ if got := readPixels(t, r).At(3, 3); got != tc.want {
280+ t.Fatalf("target %d (modifier %#x): read back %v, want %v", i, bufs[i].Modifier, got, tc.want)
281+ }
282+ }
283+}
284+
285+// A target exported for a display modifier list carries one of its
286+// modifiers, whichever they are: KMS refuses a framebuffer whose modifier
287+// the plane does not advertise, so the intersection must hold on the real
288+// driver (RADV also exports DCC variants displays do not list).
289+func TestExportTargetsStayInTheDisplayList(t *testing.T) {
290+ r, err := New(64, 32)
291+ if err != nil {
292+ t.Skipf("Vulkan unavailable: %v", err)
293+ }
294+ defer r.Close()
295+ if len(r.renderMods) == 0 {
296+ t.Skip("device exports no render modifier")
297+ }
298+ for _, m := range r.renderMods {
299+ bufs, err := r.ExportTargets(1, []uint64{m}, false)
300+ if err != nil {
301+ t.Fatalf("modifier %#x is exportable but export failed: %v", m, err)
302+ }
303+ got := bufs[0].Modifier
304+ for _, p := range bufs[0].Planes {
305+ p.File.Close()
306+ }
307+ if got != m {
308+ t.Fatalf("display lists only %#x, target has modifier %#x", m, got)
309+ }
310+ }
311+ // A list with no exportable modifier is an error, not a guess.
312+ if bufs, err := r.ExportTargets(1, []uint64{0x1234567}, false); err == nil {
313+ for _, p := range bufs[0].Planes {
314+ p.File.Close()
315+ }
316+ t.Fatalf("export with an unexportable display list gave modifier %#x", bufs[0].Modifier)
317+ }
318+}
319+
320+// The AMD DCC retile modifiers cost a compute pass per frame: they are never
321+// targets, whatever the display lists. Plain DCC (pipe-aligned or not), the
322+// non-DCC tiled modifier and non-AMD modifiers with bit 14 set stay.
323+func TestFilterModifiersDropsDCCRetile(t *testing.T) {
324+ const (
325+ retile = 0x200000010567b03 // AMD DCC, DCC_RETILE (laptop RADV)
326+ pipeAligned = 0x20000000056bb03 // AMD DCC, pipe-aligned
327+ unaligned = 0x200000000563b03 // AMD DCC, not pipe-aligned
328+ tiled = 0x200000010401b03 // AMD tiled, no DCC
329+ foreignRetil = 0x0100000000004003 // vendor 1, bit 14 set: not AMD's
330+ )
331+ need := vk.FormatFeatureFlags(vk.FormatFeatureColorAttachmentBit)
332+ var mods []vk.DrmFormatModifierPropertiesEXT
333+ for _, m := range []uint64{retile, pipeAligned, unaligned, tiled, foreignRetil, 0} {
334+ planes := uint32(1)
335+ if m == retile || m == pipeAligned || m == unaligned {
336+ planes = 3
337+ }
338+ mods = append(mods, vk.DrmFormatModifierPropertiesEXT{DrmFormatModifier: m, DrmFormatModifierPlaneCount: planes, DrmFormatModifierTilingFeatures: need})
339+ }
340+ got, planes := filterModifiers(mods, need, func(uint64) bool { return true })
341+ if want := []uint64{pipeAligned, unaligned, tiled, foreignRetil, 0}; !slices.Equal(got, want) {
342+ t.Fatalf("kept %#x, want %#x", got, want)
343+ }
344+ if _, ok := planes[retile]; ok {
345+ t.Fatalf("retile modifier has a plane count: %v", planes)
346+ }
347+ r := &Renderer{renderMods: got, modPlanes: planes}
348+ if out := r.exportModifiers([]uint64{retile, tiled}, false); !slices.Equal(out, []uint64{tiled}) {
349+ t.Fatalf("display listing the retile modifier: %#x", out)
350+ }
351+ if out := r.exportModifiers([]uint64{retile}, false); len(out) != 0 {
352+ t.Fatalf("display listing only the retile modifier: %#x", out)
353+ }
354+}
355+
356+// The device's exportable modifiers hold no AMD DCC retile one, whatever
357+// RADV lists (it lists them on GPUs with several render backends).
358+func TestProbedModifiersHaveNoDCCRetile(t *testing.T) {
359+ r, err := New(64, 32)
360+ if err != nil {
361+ t.Skipf("Vulkan unavailable: %v", err)
362+ }
363+ defer r.Close()
364+ for _, m := range append(slices.Clone(r.renderMods), r.hdrMods...) {
365+ if retilesDCC(m) {
366+ t.Fatalf("target modifier %#x retiles DCC", m)
367+ }
368+ }
369+}
M internal/adapters/vulkan/workspace_clip_test.go +4 -2
@@ -56,15 +56,17 @@ func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) {
5656 t.Fatalf("popup escaped clip: %v", got)
5757 }
5858 // Popups above layers belong to output-wide controls, not the workspace.
59+ // Scenes are immutable snapshots (the target holds the previous one):
60+ // a changed window goes in a fresh slice, as core publishes it.
5961 scene.Seq++
60- scene.Windows[0].OverLayers = true
62+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true, OverLayers: true}}
6163 if err := render(r, scene, contents); err != nil {
6264 t.Fatal(err)
6365 }
6466 if got := readPixels(t, r).RGBAAt(10, 10); got != red {
6567 t.Fatalf("output-wide popup clipped: %v", got)
6668 }
67- scene.Windows[0].OverLayers = false
69+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true}}
6870 // Switching back to inherited geometry repaints the margins.
6971 scene.Seq++
7072 scene.WorkspaceClip = ports.Rect{}
M internal/adapters/wayland/surface.go +24 -3
@@ -393,7 +393,7 @@ func (s *surface) applyCommit(u *update) {
393393 s.bufferScale = u.scale
394394 }
395395 s.transform = u.transform
396- oldOpaque := s.content.Opaque
396+ oldOpaque, oldOpaqueRect := s.content.Opaque, s.content.OpaqueRect
397397 if u.opaqueSet {
398398 s.opaque = u.opaque
399399 }
@@ -455,6 +455,7 @@ func (s *surface) applyCommit(u *update) {
455455 c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height)
456456 s.formatOpaque = c.Opaque
457457 c.Opaque = (c.Opaque || s.opaqueCovers(c.LogicalW, c.LogicalH)) && u.fade == 0
458+ c.OpaqueRect = s.opaqueRect(c.Opaque, c.LogicalW, c.LogicalH, u.fade)
458459 c.Fade = u.fade
459460 resized := !s.has || c.Width != s.content.Width || c.Height != s.content.Height || c.LogicalW != s.content.LogicalW || c.LogicalH != s.content.LogicalH
460461 s.commitDamage(u, true, resized, c.Width, c.Height)
@@ -484,13 +485,14 @@ func (s *surface) applyCommit(u *update) {
484485 s.content.Source, _ = s.source(s.content.Width, s.content.Height)
485486 s.content.LogicalW, s.content.LogicalH = s.logicalSize(s.content.Width, s.content.Height)
486487 s.content.Opaque = (s.formatOpaque || s.opaqueCovers(s.content.LogicalW, s.content.LogicalH)) && u.fade == 0
488+ s.content.OpaqueRect = s.opaqueRect(s.content.Opaque, s.content.LogicalW, s.content.LogicalH, u.fade)
487489 s.content.Fade = u.fade
488490 }
489491 }
490492 if s.current == nil {
491493 s.content, s.has = ports.SurfaceContent{}, false
492494 }
493- if fresh || damaged || oldW != s.content.LogicalW || oldH != s.content.LogicalH || oldSource != s.content.Source || oldColor != s.color || oldRepresentation != s.representation || oldTransform != s.content.Transform || oldOpaque != s.content.Opaque || oldFade != s.content.Fade {
495+ if fresh || damaged || oldW != s.content.LogicalW || oldH != s.content.LogicalH || oldSource != s.content.Source || oldColor != s.color || oldRepresentation != s.representation || oldTransform != s.content.Transform || oldOpaque != s.content.Opaque || oldOpaqueRect != s.content.OpaqueRect || oldFade != s.content.Fade {
494496 s.version++
495497 s.root().treeDirty = true
496498 }
@@ -519,7 +521,7 @@ func (s *surface) applyCommit(u *update) {
519521 }
520522 reshaped := s.has && (s.content.LogicalW != oldW || s.content.LogicalH != oldH || s.content.Source != oldSource || s.content.Transform != oldTransform)
521523 // Opacity changes how every pixel blends, so it repaints the whole surface.
522- opacity := s.has && (s.content.Opaque != oldOpaque || s.content.Fade != oldFade)
524+ opacity := s.has && (s.content.Opaque != oldOpaque || s.content.OpaqueRect != oldOpaqueRect || s.content.Fade != oldFade)
523525 drawn := fresh || damaged || moved || geometry || hinted || reshaped || opacity || s.sub.parent != nil
524526 if drawn {
525527 if moved || geometry || reshaped || opacity {
@@ -623,6 +625,25 @@ func (s *surface) opaqueCovers(w, h int) bool {
623625 return w > 0 && h > 0 && covers(s.opaque, ports.Rect{W: w, H: h})
624626 }
625627
628+// opaqueRect is the largest rect of the committed opaque region inside the
629+// w×h surface, for a surface that is not wholly opaque (opaque) and not
630+// faded: clients often leave an edge out of a region that covers nearly
631+// all of the surface (a 983×1230 region on a 984×1231 surface). The zero
632+// Rect means none.
633+func (s *surface) opaqueRect(opaque bool, w, h int, fade float32) ports.Rect {
634+ var best ports.Rect
635+ if opaque || fade != 0 || w <= 0 || h <= 0 {
636+ return best
637+ }
638+ for _, r := range s.opaque {
639+ x0, y0, x1, y1 := max(r.X, 0), max(r.Y, 0), min(r.X+r.W, w), min(r.Y+r.H, h)
640+ if x1 > x0 && y1 > y0 && (x1-x0)*(y1-y0) > best.W*best.H {
641+ best = ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
642+ }
643+ }
644+ return best
645+}
646+
626647 // maxCoverRects bounds the region covers checks: beyond it, not covered.
627648 const maxCoverRects = 16
628649
M internal/adapters/wayland/transform_test.go +42 -2
@@ -161,13 +161,16 @@ func TestOpaqueRegionContent(t *testing.T) {
161161 if prev.Opaque {
162162 t.Fatalf("partial region made the surface opaque: %+v", prev)
163163 }
164+ if want := (ports.Rect{W: 15, H: 20}); prev.OpaqueRect != want {
165+ t.Fatalf("partial region: OpaqueRect %v, want %v", prev.OpaqueRect, want)
166+ }
164167 // Two halves cover the whole surface, even through a larger rect.
165168 // Opacity changes blending everywhere: the commit damages it all.
166169 whole := tc.region(t, ports.Rect{W: 15, H: 20}, ports.Rect{X: 15, W: 100, H: 20})
167170 requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetOpaqueRegion, whole)
168171 tc.commit(t)
169172 got := tc.next(t)
170- if !got.Opaque {
173+ if !got.Opaque || got.OpaqueRect != (ports.Rect{}) {
171174 t.Fatalf("covering region: %+v", got)
172175 }
173176 if rects, ok := got.DamageSince(prev.Seq); ok {
@@ -186,9 +189,46 @@ func TestOpaqueRegionContent(t *testing.T) {
186189 }
187190 requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetOpaqueRegion, uint32(0))
188191 tc.commit(t)
189- if got := tc.next(t); got.Opaque {
192+ if got := tc.next(t); got.Opaque || got.OpaqueRect != (ports.Rect{}) {
190193 t.Fatalf("NULL region: %+v", got)
191194 }
195+ // A region one pixel short of the surface (what Firefox sets) is not
196+ // opaque, but its rect is reported.
197+ short := tc.region(t, ports.Rect{W: 29, H: 19})
198+ requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetOpaqueRegion, short)
199+ tc.commit(t)
200+ if got := tc.next(t); got.Opaque || got.OpaqueRect != (ports.Rect{W: 29, H: 19}) {
201+ t.Fatalf("short region: %+v", got)
202+ }
203+}
204+
205+// opaqueRect is the largest rect of the region inside the surface, none for
206+// an opaque or faded surface.
207+func TestOpaqueRect(t *testing.T) {
208+ for _, tc := range []struct {
209+ name string
210+ region []ports.Rect
211+ opaque bool
212+ w, h int
213+ fade float32
214+ want ports.Rect
215+ }{
216+ {"none", nil, false, 10, 10, 0, ports.Rect{}},
217+ {"one pixel short", []ports.Rect{{W: 983, H: 1230}}, false, 984, 1231, 0, ports.Rect{W: 983, H: 1230}},
218+ {"clipped to the surface", []ports.Rect{{X: -5, Y: 2, W: 100, H: 100}}, false, 10, 10, 0, ports.Rect{Y: 2, W: 10, H: 8}},
219+ {"largest of several", []ports.Rect{{W: 2, H: 2}, {X: 3, Y: 3, W: 5, H: 4}}, false, 10, 10, 0, ports.Rect{X: 3, Y: 3, W: 5, H: 4}},
220+ {"outside", []ports.Rect{{X: 10, W: 5, H: 5}}, false, 10, 10, 0, ports.Rect{}},
221+ {"wholly opaque", []ports.Rect{{W: 9, H: 9}}, true, 10, 10, 0, ports.Rect{}},
222+ {"faded", []ports.Rect{{W: 9, H: 9}}, false, 10, 10, 0.5, ports.Rect{}},
223+ {"no size", []ports.Rect{{W: 9, H: 9}}, false, 0, 0, 0, ports.Rect{}},
224+ } {
225+ t.Run(tc.name, func(t *testing.T) {
226+ s := &surface{opaque: tc.region}
227+ if got := s.opaqueRect(tc.opaque, tc.w, tc.h, tc.fade); got != tc.want {
228+ t.Fatalf("opaqueRect = %v, want %v", got, tc.want)
229+ }
230+ })
231+ }
192232 }
193233
194234 // Transform and opaque region requested after a queued commit belong to
M internal/app/app.go +1 -0
@@ -294,6 +294,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
294294 if err != nil {
295295 return nil, err
296296 }
297+ r.SetLogger(renderLog)
297298 renderLog.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
298299 return r, nil
299300 }
M internal/app/app_test.go +54 -6
@@ -9,6 +9,7 @@ import (
99 "os/exec"
1010 "path/filepath"
1111 "strings"
12+ "sync"
1213 "testing"
1314 "time"
1415
@@ -101,7 +102,13 @@ func TestQuitJoinsWorkers(t *testing.T) {
101102 }
102103 }
103104
104-func TestHeadlessSpawnClose(t *testing.T) {
105+// Animations off: the lone window is at its final rect in the first scene.
106+func TestHeadlessSpawnClose(t *testing.T) { headlessSpawnClose(t, false) }
107+
108+// Animations on: the window appears (faded, 90 %), then settles at 1920.
109+func TestHeadlessSpawnCloseAnimated(t *testing.T) { headlessSpawnClose(t, true) }
110+
111+func headlessSpawnClose(t *testing.T, animated bool) {
105112 if _, err := exec.LookPath("weston-simple-shm"); err != nil {
106113 t.Skip("weston-simple-shm not installed")
107114 }
@@ -109,7 +116,8 @@ func TestHeadlessSpawnClose(t *testing.T) {
109116 t.Setenv("XDG_STATE_HOME", t.TempDir())
110117 cfg := config.Defaults()
111118 cfg.Terminal.Command = []string{"weston-simple-shm"}
112- scenes := make(chan []ports.Scene, 64)
119+ cfg.Animations.On = animated
120+ scenes := make(chan []ports.Scene, 4096)
113121 ctx, cancel := context.WithCancel(context.Background())
114122 defer cancel()
115123 inputReady := make(chan chan<- ports.InputEvent, 1)
@@ -144,6 +152,12 @@ func TestHeadlessSpawnClose(t *testing.T) {
144152 case set := <-scenes:
145153 s := set[0]
146154 if len(s.Windows) == count {
155+ if count == 1 && animated {
156+ // The entrance runs first: wait for the settled scene.
157+ if w := s.Windows[0]; w.Fade != 0 || w.Rect.W != 1920 {
158+ continue
159+ }
160+ }
147161 // A lone column fills the 1920 output (default zero gaps).
148162 if count == 1 && s.Windows[0].Rect.W != 1920 {
149163 t.Errorf("width = %d, want 1920", s.Windows[0].Rect.W)
@@ -214,7 +228,13 @@ func TestHeadlessTyping(t *testing.T) {
214228 }
215229 }
216230
217-func TestHeadlessPointerClickFocus(t *testing.T) {
231+func TestHeadlessPointerClickFocus(t *testing.T) { headlessPointerClickFocus(t, false) }
232+
233+// Animations on: the maps' frames and the settled scenes are all tapped; the
234+// click still lands on window 1 once the second column settled.
235+func TestHeadlessPointerClickFocusAnimated(t *testing.T) { headlessPointerClickFocus(t, true) }
236+
237+func headlessPointerClickFocus(t *testing.T, animated bool) {
218238 if _, err := exec.LookPath("foot"); err != nil {
219239 t.Skip("foot unavailable")
220240 }
@@ -225,17 +245,45 @@ func TestHeadlessPointerClickFocus(t *testing.T) {
225245 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
226246 // Pulse frames would fill the scene tap before the click.
227247 cfg.Focus.Animation = ports.FocusAnimationOff
248+ cfg.Animations.On = animated
249+ // The tap drops a scene it has no room for: a collector keeps up with
250+ // the animation frames.
228251 scenes := make(chan []ports.Scene, 128)
229- err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes})
252+ var got [][]ports.Scene
253+ stop := make(chan struct{})
254+ collected := make(chan struct{})
255+ go func() {
256+ defer close(collected)
257+ for {
258+ select {
259+ case set := <-scenes:
260+ got = append(got, set)
261+ case <-stop:
262+ return
263+ }
264+ }
265+ }()
266+ // Stopped before the scenes are read, or at the latest when the test
267+ // ends (an early Fatal or Skip).
268+ var halt sync.Once
269+ stopCollector := func() { halt.Do(func() { close(stop); <-collected }) }
270+ t.Cleanup(stopCollector)
271+ // The script ends with the default quit bind: the run stops when it
272+ // is done, the timeout is only a backstop.
273+ err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\nkey Ctrl+Alt+BackSpace\n")), Timeout: 5 * time.Second, testScenes: scenes})
230274 if err != nil {
231275 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
232276 t.Skipf("Vulkan unavailable: %v", err)
233277 }
234278 t.Fatal(err)
235279 }
236- two, focused := false, false
280+ stopCollector()
237281 for len(scenes) > 0 {
238- s := (<-scenes)[0]
282+ got = append(got, <-scenes)
283+ }
284+ two, focused := false, false
285+ for _, set := range got {
286+ s := set[0]
239287 if len(s.Windows) == 2 {
240288 two = true
241289 for _, w := range s.Windows {
M internal/app/headless_test.go +2 -2
@@ -25,7 +25,7 @@ func TestVirtualRendererExportsHDRTargets(t *testing.T) {
2525 }
2626 defer r.Close()
2727 // A device that cannot export at all says nothing about the wiring.
28- sdr, err := r.ExportTargets(1, nil)
28+ sdr, err := r.ExportTargets(1, nil, false)
2929 if err != nil {
3030 t.Skipf("no exportable target: %v", err)
3131 }
@@ -35,7 +35,7 @@ func TestVirtualRendererExportsHDRTargets(t *testing.T) {
3535 }
3636 }
3737 r.SetHDR(203)
38- bufs, err := r.ExportTargets(1, nil)
38+ bufs, err := r.ExportTargets(1, nil, false)
3939 if err != nil {
4040 t.Fatalf("virtual HDR export: %v", err)
4141 }
A internal/core/animate_alloc_test.go +63 -0
@@ -0,0 +1,63 @@
1+package core
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+// TestAnimateAllocations guards animate: with a camera, a switch and 20 rect
9+// motions running (fade, dim and scale components, half of them leaving) it
10+// allocates nothing.
11+func TestAnimateAllocations(t *testing.T) {
12+ c, ic := indicatorCore(t)
13+ c.cfg.Animations.On = true
14+ sc := c.cur()
15+ t0 := ic.now
16+ m := sc.mon
17+ m.AddWindow(1)
18+ m.AddWindow(2)
19+
20+ ws := m.Workspaces[0]
21+ ws.motion = c.spring(viewSpring(100, 0), t0)
22+ ws.shift = 100
23+ m.switchMotion = c.spring(workspaceSpring(100, 0), t0)
24+ m.switchOff = 100
25+ sc.rects = make(map[WindowID]rectMotion, 20)
26+ for i := range 20 {
27+ sp := viewSpring(50, 0)
28+ rm := rectMotion{
29+ x: c.spring(sp, t0), y: c.spring(sp, t0),
30+ w: c.spring(sp, t0), h: c.spring(sp, t0),
31+ dx: 50, dy: 50, dw: 50, dh: 50,
32+ fade: c.spring(levelSpring(1, 0), t0), df: 1,
33+ dim: c.spring(levelSpring(0.5, 0), t0), ddim: 0.5,
34+ scale: true,
35+ }
36+ if i%2 == 0 {
37+ rm.leaving, rm.left = true, Placement{ID: WindowID(100 + i), Rect: Rect{W: 10, H: 10}}
38+ }
39+ sc.rects[WindowID(100+i)] = rm
40+ }
41+
42+ // A stash peek's veil moves alone (a navigation): dim-only motions,
43+ // some going below zero, as an offset from Stash.Dim.
44+ for i := range 4 {
45+ var rm rectMotion
46+ from := 0.5
47+ if i%2 == 1 {
48+ from = -0.5
49+ }
50+ rm.dim, rm.ddim = c.spring(levelSpring(from, 0), t0), from
51+ sc.rects[WindowID(200+i)] = rm
52+ }
53+
54+ now := t0.Add(10 * time.Millisecond)
55+ for name, only := range map[string]*screen{"all": nil, "only": sc} {
56+ if n := testing.AllocsPerRun(100, func() { c.animate(now, only) }); n != 0 {
57+ t.Errorf("animate(%s) allocs = %v, want 0", name, n)
58+ }
59+ }
60+ if !ws.motion.on || !m.switchMotion.on || len(sc.rects) != 24 {
61+ t.Fatalf("motions settled: camera %v switch %v rects %d", ws.motion.on, m.switchMotion.on, len(sc.rects))
62+ }
63+}
A internal/core/animations_interrupt_internal_test.go +205 -0
@@ -0,0 +1,205 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// movingRects makes two windows on sc swap places and returns with their
13+// rect motions running, one frame in (the clock moved 16 ms and stepped).
14+func movingRects(t *testing.T, c *Core, ic *indicatorClock, sc *screen) {
15+ t.Helper()
16+ c.cfg.Animations.On = true
17+ sc.mon.AddWindow(1)
18+ sc.mon.AddWindow(2)
19+ settleShown(t, c)
20+ c.focusScreen = c.screenIndex(sc.name())
21+ before := c.snapshot(ic.now)
22+ c.applyAction(ActionMoveColumnLeft)
23+ c.transition(before, ic.now)
24+ if len(sc.rects) != 2 {
25+ t.Fatalf("%d rect motions after the move, want 2", len(sc.rects))
26+ }
27+ indicatorScene(t, c)
28+ ic.now = ic.now.Add(16 * time.Millisecond)
29+ if err := c.step(context.Background(), nil); err != nil {
30+ t.Fatal(err)
31+ }
32+ <-c.ch.Scenes
33+ if rm := sc.rects[1]; rm.dx == 0 || !rm.on() {
34+ t.Fatalf("window 1 offset %v (running %t) one frame in: not mid-motion", rm.dx, rm.on())
35+ }
36+}
37+
38+// A session lock in the middle of a rect motion stops every motion and the
39+// frame timer, drops the screens' last scene and shown layouts, and the
40+// scene after the unlock is the settled one with a Seq nothing used before.
41+func TestSessionLockMidRectMotion(t *testing.T) {
42+ c, ic := indicatorCore(t)
43+ state := ports.SecurityState{}
44+ gate := portsmocks.NewMockSessionSecurity(t)
45+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
46+ c.ch.Security = gate
47+ sc := c.cur()
48+ movingRects(t, c, ic, sc)
49+ if !c.animating() || c.frameC == nil || sc.last.Seq == 0 {
50+ t.Fatalf("nothing to interrupt: animating %t, timer %t, last seq %d", c.animating(), c.frameC != nil, sc.last.Seq)
51+ }
52+ seq := sc.last.Seq
53+
54+ state = ports.SecurityState{Generation: 1, Protected: true}
55+ if err := c.publish(context.Background()); err != nil {
56+ t.Fatal(err)
57+ }
58+ locked := (<-c.ch.Scenes)[0]
59+ if len(locked.Windows) != 0 || len(locked.Separators) != 0 || locked.Seq <= seq {
60+ t.Fatalf("locked scene shows the desktop or reuses a Seq (%d <= %d): %+v", locked.Seq, seq, locked)
61+ }
62+ if len(sc.rects) != 0 || sc.rectsWS != nil || sc.springing() || c.animating() || c.frameC != nil {
63+ t.Fatalf("motion survived the lock: rects %d, springing %t, animating %t, timer %t", len(sc.rects), sc.springing(), c.animating(), c.frameC != nil)
64+ }
65+ if sc.last.Seq != 0 || sc.shown != nil || sc.settledLayout != nil {
66+ t.Fatalf("lock kept the desktop scene: last seq %d, shown %v, settled %v", sc.last.Seq, sc.shown != nil, sc.settledLayout != nil)
67+ }
68+
69+ // Unlocked: the windows are where the move left them, nothing runs.
70+ state = ports.SecurityState{Generation: 2}
71+ s := indicatorScene(t, c)
72+ if s.Seq <= locked.Seq {
73+ t.Fatalf("unlocked scene Seq %d reuses the locked one %d", s.Seq, locked.Seq)
74+ }
75+ var got1, got2 Rect
76+ for _, w := range s.Windows {
77+ switch w.ID {
78+ case 1:
79+ got1 = w.Rect
80+ case 2:
81+ got2 = w.Rect
82+ }
83+ }
84+ // 300x200 output, two columns of 150: the move put 2 first.
85+ if got2.X != 0 || got1.X != 150 {
86+ t.Fatalf("after the unlock 1 at %+v and 2 at %+v, want the settled 150 and 0", got1, got2)
87+ }
88+ if c.animating() || len(sc.rects) != 0 || c.frameC != nil {
89+ t.Fatalf("a motion runs after the unlock: animating %t, rects %d, timer %t", c.animating(), len(sc.rects), c.frameC != nil)
90+ }
91+}
92+
93+// An output unplugged in the middle of its windows' rect motion: no panic,
94+// its motions go with it, a late page flip for it does nothing, and the
95+// remaining output keeps no motion of the gone one.
96+func TestOutputUnplugMidRectMotion(t *testing.T) {
97+ c, ic := indicatorCore(t)
98+ a := c.cur()
99+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
100+ b := c.screens[1]
101+ movingRects(t, c, ic, b)
102+ c.removeScreen("B")
103+ if c.screenIndex("B") >= 0 || len(c.screens) != 1 {
104+ t.Fatalf("B still plugged: %d screens", len(c.screens))
105+ }
106+ if len(a.rects) != 0 || a.springing() {
107+ t.Fatalf("A inherited the motions of B: rects %d, springing %t", len(a.rects), a.springing())
108+ }
109+ if c.sliding("B") {
110+ t.Fatal("the gone output still slides")
111+ }
112+ // B's windows are guests of A now: the next publish and a stray flip are fine.
113+ if err := c.publish(context.Background()); err != nil {
114+ t.Fatal(err)
115+ }
116+ <-c.ch.Scenes
117+ ic.now = ic.now.Add(5 * time.Second)
118+ if err := c.step(context.Background(), nil); err != nil {
119+ t.Fatal(err)
120+ }
121+ <-c.ch.Scenes
122+ if c.animating() || c.frameC != nil {
123+ t.Fatalf("still animating after the unplug and a settle: animating %t, timer %t", c.animating(), c.frameC != nil)
124+ }
125+}
126+
127+// The last output going away keeps its screen as a placeholder: none of its
128+// springs (rect motions, a workspace switch) may outlive the output, or the
129+// fallback timer would wake core for a screen nothing draws. When an output
130+// comes back, a new move animates and lands from a fresh Seq.
131+func TestLastOutputUnplugMidRectMotion(t *testing.T) {
132+ c, ic := indicatorCore(t)
133+ sc := c.cur()
134+ movingRects(t, c, ic, sc)
135+ // A workspace switch spring runs too.
136+ m := sc.mon
137+ m.switchOff, m.switchMotion = 0.5, c.spring(workspaceSpring(0.5, 0), ic.now)
138+ if !m.switchMotion.on || !sc.springing() {
139+ t.Fatal("no workspace switch to interrupt")
140+ }
141+ c.removeScreen("A")
142+ if sc.mon.springing() || len(sc.rects) != 0 || sc.rectsWS != nil || sc.mon.switchMotion.on || sc.mon.switchOff != 0 {
143+ t.Fatalf("the placeholder keeps springs: monitor %t, rects %d, switch %t (%v)", sc.mon.springing(), len(sc.rects), sc.mon.switchMotion.on, sc.mon.switchOff)
144+ }
145+ if err := c.publish(context.Background()); err != nil {
146+ t.Fatal(err)
147+ }
148+ gone := (<-c.ch.Scenes)[0]
149+ if c.animating() || c.frameC != nil {
150+ t.Fatalf("the placeholder keeps asking for frames: animating %t, timer %t", c.animating(), c.frameC != nil)
151+ }
152+
153+ // The output comes back: its windows are where the move left them.
154+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
155+ settled := indicatorScene(t, c)
156+ if settled.Seq <= gone.Seq {
157+ t.Fatalf("scene after the replug reuses Seq %d (placeholder %d)", settled.Seq, gone.Seq)
158+ }
159+ x := func(s ports.Scene, id WindowID) int {
160+ for _, w := range s.Windows {
161+ if w.ID == id {
162+ return w.Rect.X
163+ }
164+ }
165+ t.Fatalf("window %d not in the scene", id)
166+ return 0
167+ }
168+ if x(settled, 2) != 0 || x(settled, 1) != 150 {
169+ t.Fatalf("after the replug 1 at %d and 2 at %d, want the settled 150 and 0", x(settled, 1), x(settled, 2))
170+ }
171+ if c.animating() || len(c.cur().rects) != 0 {
172+ t.Fatal("a motion runs after the replug")
173+ }
174+
175+ // A new move animates from the settled rects and lands.
176+ before := c.snapshot(ic.now)
177+ c.applyAction(ActionMoveColumnRight)
178+ c.transition(before, ic.now)
179+ start := indicatorScene(t, c)
180+ if x(start, 1) != 150 || x(start, 2) != 0 {
181+ t.Fatalf("right after the move 1 at %d and 2 at %d, want the old 150 and 0", x(start, 1), x(start, 2))
182+ }
183+ ic.now = ic.now.Add(16 * time.Millisecond)
184+ if err := c.step(context.Background(), nil); err != nil {
185+ t.Fatal(err)
186+ }
187+ mid := (<-c.ch.Scenes)[0]
188+ if got := x(mid, 1); got >= 150 || got <= 0 {
189+ t.Fatalf("window 1 at %d on the first frame, want between 0 and 150", got)
190+ }
191+ if mid.Seq == start.Seq {
192+ t.Fatalf("an animating frame reuses Seq %d", mid.Seq)
193+ }
194+ ic.now = ic.now.Add(5 * time.Second)
195+ if err := c.step(context.Background(), nil); err != nil {
196+ t.Fatal(err)
197+ }
198+ end := (<-c.ch.Scenes)[0]
199+ if x(end, 1) != 0 || x(end, 2) != 150 || end.Seq == mid.Seq {
200+ t.Fatalf("landed 1 at %d and 2 at %d (Seq %d after %d), want 0 and 150 and a fresh Seq", x(end, 1), x(end, 2), end.Seq, mid.Seq)
201+ }
202+ if c.animating() || c.frameC != nil {
203+ t.Fatal("still animating after the landing")
204+ }
205+}
A internal/core/animations_off_test.go +155 -0
@@ -0,0 +1,155 @@
1+package core_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func animationsOff(c *ports.Config) { c.Animations.On = false }
11+
12+// stillMoves sends n swipe updates of (dx, dy) and expects no scene: with
13+// animations off the view does not follow the fingers.
14+func (r *swipeRig) stillMoves(t *testing.T, n int, dx, dy float64) {
15+ t.Helper()
16+ for range n {
17+ r.at += 8 * time.Millisecond
18+ r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at}
19+ }
20+ if s, ok := r.published(); ok {
21+ t.Fatalf("a swipe update published a scene (%d windows): the view follows the fingers", len(s))
22+ }
23+}
24+
25+// published reports the scene set core published for the events sent so
26+// far, if any. A flip that names no output is a barrier: core handles its
27+// events in order and publishes before taking the next one, so once the
28+// send returns every earlier event is handled.
29+func (r *swipeRig) published() ([]ports.Scene, bool) {
30+ send(r.frames, ports.OutputFrame{Output: "sync"})
31+ select {
32+ case s := <-r.scenes:
33+ return s, true
34+ default:
35+ return nil, false
36+ }
37+}
38+
39+func focusedID(s ports.Scene) ports.WindowID {
40+ for _, w := range s.Windows {
41+ if w.Focused {
42+ return w.ID
43+ }
44+ }
45+ return 0
46+}
47+
48+func TestAnimationsOffColumnSwipeFocusesAtOnce(t *testing.T) {
49+ r := startSwipe(t, animationsOff)
50+ before := threeColumns(t, r)
51+ r.begin()
52+ r.stillMoves(t, 10, -40, 0)
53+ s := r.end(t, false)
54+ if got := focusedID(s); got != 2 {
55+ t.Fatalf("focused %d right after the lift, want 2", got)
56+ }
57+ if got, _ := rectOf(before, 3); got.X == 0 {
58+ t.Fatalf("bad setup: column 3 at %+v", got)
59+ }
60+ r.noFrameScene(t)
61+}
62+
63+func TestAnimationsOffWorkspaceSwipeLandsAtOnce(t *testing.T) {
64+ r := startSwipe(t, animationsOff)
65+ r.workspaces(t)
66+ r.begin()
67+ r.stillMoves(t, 9, 0, 30)
68+ s := r.end(t, false)
69+ if _, ok := rectOf(s, 1); ok {
70+ t.Fatal("old workspace still shown right after the lift")
71+ }
72+ if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
73+ t.Fatalf("workspace 2 at %+v (shown %t), want the settled rect", got, ok)
74+ }
75+}
76+
77+func TestAnimationsOffSlowSwipeStays(t *testing.T) {
78+ r := startSwipe(t, animationsOff)
79+ before := threeColumns(t, r)
80+ r.begin()
81+ r.stillMoves(t, 2, -10, 0)
82+ r.input <- ports.SwipeEnd{Time: r.at + time.Second}
83+ if s := scene(t, r.scenes); !s.SameAs(before) {
84+ t.Fatalf("a short slow swipe changed the scene: %+v, was %+v", s.Windows, before.Windows)
85+ }
86+ r.noFrameScene(t)
87+}
88+
89+func TestReloadToOffSettlesALanding(t *testing.T) {
90+ r := startSwipe(t, nil)
91+ threeColumns(t, r)
92+ s := r.flick(t, 10, -40, 0)
93+ if got, _ := rectOf(s, 1); got.X == 0 {
94+ t.Fatal("no landing slide to interrupt")
95+ }
96+ cfg := r.cfg
97+ cfg.Animations.On = false
98+ r.reload <- ports.ConfigChanged{Config: cfg}
99+ s = scene(t, r.scenes)
100+ if got, _ := rectOf(s, 1); got.X != 0 {
101+ t.Fatalf("column 1 at %d after the reload, want the settled rect", got.X)
102+ }
103+ r.noFrameScene(t)
104+}
105+
106+// noFrameScene sends page flips and expects no scene: no spring runs, so
107+// the flips have nothing to move and publish nothing.
108+func (r *swipeRig) noFrameScene(t *testing.T, outputs ...string) {
109+ t.Helper()
110+ if len(outputs) == 0 {
111+ outputs = []string{wide.Name}
112+ }
113+ for range 2 {
114+ r.advance(16 * time.Millisecond)
115+ for _, o := range outputs {
116+ r.frames <- ports.OutputFrame{Output: o}
117+ }
118+ }
119+ if s, ok := r.published(); ok {
120+ t.Fatalf("a flip published a scene (%d windows): a spring still runs", len(s))
121+ }
122+}
123+
124+// landing flicks left and returns the rect of column 1 after each given
125+// time since the lift.
126+func landing(t *testing.T, slowdown float64, after ...time.Duration) []int {
127+ t.Helper()
128+ r := startSwipe(t, func(c *ports.Config) { c.Animations.Slowdown = slowdown })
129+ threeColumns(t, r)
130+ r.flick(t, 10, -40, 0)
131+ var xs []int
132+ var at time.Duration
133+ for _, d := range after {
134+ got, _ := rectOf(r.frame(t, d-at), 1)
135+ xs = append(xs, got.X)
136+ at = d
137+ }
138+ return xs
139+}
140+
141+func TestSlowdownStretchesALanding(t *testing.T) {
142+ // Critically damped at stiffness 800: settled after about 330 ms, 660 ms
143+ // with slowdown 2 (the exact doubling is checked on the motion).
144+ one := landing(t, 1, 100*time.Millisecond, 400*time.Millisecond)
145+ two := landing(t, 2, 100*time.Millisecond, 400*time.Millisecond, 700*time.Millisecond)
146+ if one[0] == 0 || two[0] == 0 {
147+ t.Fatalf("landing does not move after 100 ms: %v %v", one, two)
148+ }
149+ if one[1] != 0 || two[1] == 0 {
150+ t.Fatalf("at 400 ms: slowdown 1 at %d, want settled; slowdown 2 at %d, want moving", one[1], two[1])
151+ }
152+ if two[2] != 0 {
153+ t.Fatalf("slowdown 2 does not settle by 700 ms: %v", two)
154+ }
155+}
A internal/core/animations_stop_test.go +21 -0
@@ -0,0 +1,21 @@
1+package core
2+
3+import "testing"
4+
5+func TestStopAnimationsSettlesEverySpring(t *testing.T) {
6+ c, ic := indicatorCore(t)
7+ c.cfg.Animations.On = true
8+ m := c.cur().mon
9+ m.AddWindow(1)
10+ w := m.Current()
11+ w.shift, w.motion = 40, c.spring(viewSpring(40, 0), ic.now)
12+ m.switchOff, m.switchMotion = 0.5, c.spring(workspaceSpring(0.5, 0), ic.now)
13+ c.armFrame()
14+ if !c.animating() || c.frameC == nil {
15+ t.Fatal("no running spring to stop")
16+ }
17+ c.stopAnimations()
18+ if c.animating() || w.motion.on || w.shift != 0 || m.switchMotion.on || m.switchOff != 0 || c.frameC != nil {
19+ t.Fatalf("springs left: shift %v, switch %v, motion %v %v, timer %v", w.shift, m.switchOff, w.motion.on, m.switchMotion.on, c.frameC != nil)
20+ }
21+}
M internal/core/border_invariants_test.go +137 -33
@@ -2,7 +2,9 @@ package core_test
22
33 import (
44 "math/rand"
5+ "slices"
56 "testing"
7+ "time"
68
79 "github.com/bnema/neferwl/internal/ports"
810 )
@@ -13,52 +15,154 @@ func TestBorderSceneInvariants(t *testing.T) {
1315 for _, overflow := range []string{"fixed", "scroll"} {
1416 for _, gap := range []int{0, 6} {
1517 t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {
16- rng := rand.New(rand.NewSource(20260928))
1718 r := startMulti(t, func(c *ports.Config) {
19+ c.Animations.On = false
1820 c.Border.Width = 2
1921 c.Layout.MaxColumns = 3
2022 c.Layout.Overflow = overflow
2123 c.Layout.Gaps = gap
2224 }, left, right)
23- var ids []ports.WindowID
24- next := ports.WindowID(1)
25- // A single keypress may publish a scene even when the action is
26- // inapplicable; check every resulting scene independently.
27- keys := []struct {
28- sym string
29- mods ports.Mods
30- }{
31- {"Left", ports.ModAlt | ports.ModShift}, {"Right", ports.ModAlt | ports.ModShift},
32- {"Left", ports.ModAlt}, {"Right", ports.ModAlt},
33- {"Left", ports.ModAlt | ports.ModCtrl}, {"Right", ports.ModAlt | ports.ModCtrl},
34- {"Left", ports.ModAlt | ports.ModCtrl | ports.ModShift}, {"Right", ports.ModAlt | ports.ModCtrl | ports.ModShift},
35- {"bracketleft", ports.ModAlt}, {"bracketright", ports.ModAlt},
36- {"f", ports.ModAlt}, {"f", ports.ModAlt | ports.ModShift},
37- {"s", ports.ModAlt}, {"S", ports.ModAlt | ports.ModShift}, {"Up", ports.ModAlt}, {"Down", ports.ModAlt},
38- }
39- for step := range 180 {
40- var scenes []ports.Scene
41- switch {
42- case len(ids) < 2 || rng.Intn(5) == 0 && len(ids) < 12:
43- scenes = r.mapWindow(t, next)
44- ids = append(ids, next)
45- next++
46- case rng.Intn(9) == 0:
47- i := rng.Intn(len(ids))
48- r.client <- ports.WindowUnmapped{ID: ids[i]}
49- scenes = receive(t, r.scenes)
50- ids = append(ids[:i], ids[i+1:]...)
51- default:
52- k := keys[rng.Intn(len(keys))]
53- scenes = r.key(t, k.sym, k.mods)
25+ borderScenario(t, r, gap, nil)
26+ })
27+ }
28+ }
29+}
30+
31+// With animations on, the scene right after an action shows the old layout
32+// moving: only the weak invariants hold there. Once every spring settled
33+// (the test moves the clock and sends the page flips) the full ones do,
34+// whatever the actions that ran mid-transition. Insets and lit lines come
35+// from the settled topology by design: separators follow the shown layout.
36+func TestBorderSceneInvariantsAnimated(t *testing.T) {
37+ for _, overflow := range []string{"fixed", "scroll"} {
38+ for _, gap := range []int{0, 6} {
39+ t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {
40+ r := startSwipeOn(t, func(c *ports.Config) {
41+ c.Animations.On = true
42+ c.Border.Width = 2
43+ c.Layout.MaxColumns = 3
44+ c.Layout.Overflow = overflow
45+ c.Layout.Gaps = gap
46+ }, left, right)
47+ moved := 0
48+ borderScenario(t, r.multiRig, gap, func(set []ports.Scene) []ports.Scene {
49+ settled, ok := r.settleAll(t, left.Name, right.Name)
50+ if !ok {
51+ return set
52+ }
53+ if !sameRects(set, settled) {
54+ moved++
5455 }
55- checkBorderScenes(t, step, gap, scenes)
56+ // Settled means no spring runs: a further flip has nothing to do.
57+ if _, again := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, left.Name, right.Name); again {
58+ t.Fatal("a flip after the settle still published: a spring runs")
59+ }
60+ return settled
61+ })
62+ if moved == 0 {
63+ t.Fatal("no scene was ever mid-transition: the case checks nothing")
5664 }
65+ t.Logf("%d steps checked mid-transition and settled", moved)
5766 })
5867 }
5968 }
6069 }
6170
71+// borderScenario drives 180 random steps and checks every scene set. With
72+// settle set the steps are animated: the set a step publishes is checked
73+// with the weak invariants, then the settled set (settle returns the set
74+// itself when nothing animated) with the full ones. One step in four skips
75+// the settling, so the next action starts mid-transition; the last step
76+// always settles.
77+func borderScenario(t *testing.T, r *multiRig, gap int, settle func([]ports.Scene) []ports.Scene) {
78+ t.Helper()
79+ rng := rand.New(rand.NewSource(20260928))
80+ var ids []ports.WindowID
81+ next := ports.WindowID(1)
82+ // A single keypress may publish a scene even when the action is
83+ // inapplicable; check every resulting scene independently.
84+ keys := []struct {
85+ sym string
86+ mods ports.Mods
87+ }{
88+ {"Left", ports.ModAlt | ports.ModShift}, {"Right", ports.ModAlt | ports.ModShift},
89+ {"Left", ports.ModAlt}, {"Right", ports.ModAlt},
90+ {"Left", ports.ModAlt | ports.ModCtrl}, {"Right", ports.ModAlt | ports.ModCtrl},
91+ {"Left", ports.ModAlt | ports.ModCtrl | ports.ModShift}, {"Right", ports.ModAlt | ports.ModCtrl | ports.ModShift},
92+ {"bracketleft", ports.ModAlt}, {"bracketright", ports.ModAlt},
93+ {"f", ports.ModAlt}, {"f", ports.ModAlt | ports.ModShift},
94+ {"s", ports.ModAlt}, {"S", ports.ModAlt | ports.ModShift}, {"Up", ports.ModAlt}, {"Down", ports.ModAlt},
95+ }
96+ const steps = 180
97+ for step := range steps {
98+ var scenes []ports.Scene
99+ switch {
100+ case len(ids) < 2 || rng.Intn(5) == 0 && len(ids) < 12:
101+ scenes = r.mapWindow(t, next)
102+ ids = append(ids, next)
103+ next++
104+ case rng.Intn(9) == 0:
105+ i := rng.Intn(len(ids))
106+ r.client <- ports.WindowUnmapped{ID: ids[i]}
107+ scenes = receive(t, r.scenes)
108+ ids = append(ids[:i], ids[i+1:]...)
109+ default:
110+ k := keys[rng.Intn(len(keys))]
111+ scenes = r.key(t, k.sym, k.mods)
112+ }
113+ if settle == nil {
114+ checkBorderScenes(t, step, gap, scenes)
115+ continue
116+ }
117+ checkBorderScenesMoving(t, step, scenes)
118+ if rng.Intn(4) != 0 || step == steps-1 {
119+ checkBorderScenes(t, step, gap, settle(scenes))
120+ }
121+ }
122+}
123+
124+// sameRects reports whether two scene sets draw every window at the same
125+// rect.
126+func sameRects(a, b []ports.Scene) bool {
127+ if len(a) != len(b) {
128+ return false
129+ }
130+ for i := range a {
131+ if !slices.EqualFunc(a[i].Windows, b[i].Windows, func(x, y ports.SceneWindow) bool { return x.ID == y.ID && x.Rect == y.Rect }) {
132+ return false
133+ }
134+ }
135+ return true
136+}
137+
138+// checkBorderScenesMoving is what holds for a scene published while
139+// windows still move: every separator lies inside its output and belongs
140+// to a window that is shown, and at most one window is focused.
141+func checkBorderScenesMoving(t *testing.T, step int, scenes []ports.Scene) {
142+ t.Helper()
143+ focused := 0
144+ for _, s := range scenes {
145+ o := ports.Rect{W: s.OutputWidth, H: s.OutputHeight}
146+ for _, sep := range s.Separators {
147+ r := sep.Rect
148+ if r.W <= 0 || r.H <= 0 || r.X < 0 || r.Y < 0 || r.X+r.W > o.W || r.Y+r.H > o.H {
149+ t.Fatalf("step %d %s (moving): line outside output: %+v; scene %+v", step, s.Output, sep, s)
150+ }
151+ if sep.Window != 0 && !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == sep.Window && !w.Hidden }) {
152+ t.Fatalf("step %d %s (moving): line belongs to a hidden or absent window: %+v; scene %+v", step, s.Output, sep, s)
153+ }
154+ }
155+ for _, w := range s.Windows {
156+ if w.Focused && !w.Hidden && !w.Popup {
157+ focused++
158+ }
159+ }
160+ }
161+ if focused > 1 {
162+ t.Fatalf("step %d (moving): several focused windows: %+v", step, scenes)
163+ }
164+}
165+
62166 func checkBorderScenes(t *testing.T, step, gap int, scenes []ports.Scene) {
63167 t.Helper()
64168 focused := 0
M internal/core/capindicator_test.go +7 -1
@@ -12,10 +12,11 @@ import (
1212
1313 // indicatorClock is a clock the test moves; its timers never fire by
1414 // themselves: the test calls captureFlashTick, and the durations asked for
15-// are recorded.
15+// are recorded: timers holds the NewTimer durations, resets the Reset ones.
1616 type indicatorClock struct {
1717 now time.Time
1818 timers []time.Duration
19+ resets []time.Duration
1920 }
2021
2122 func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
@@ -28,6 +29,10 @@ func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
2829 timer := portsmocks.NewMockTimer(t)
2930 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
3031 timer.EXPECT().Stop().Return(true).Maybe()
32+ timer.EXPECT().Reset(mock.Anything).RunAndReturn(func(d time.Duration) bool {
33+ ic.resets = append(ic.resets, d)
34+ return false
35+ }).Maybe()
3136 return timer
3237 }).Maybe()
3338 var cfg ports.Config
@@ -193,6 +198,7 @@ func TestCaptureIndicatorHiddenWorkspacePill(t *testing.T) {
193198 timer := portsmocks.NewMockTimer(t)
194199 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
195200 timer.EXPECT().Stop().Return(true).Maybe()
201+ timer.EXPECT().Reset(mock.Anything).Return(false).Maybe()
196202 clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()
197203 c.ch.Clock = clock
198204 c.configures.cw.reset()
M internal/core/capture_workspace_test.go +2 -2
@@ -193,7 +193,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
193193 c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}}
194194 c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
195195 c.popupOrder = append(c.popupOrder, 10)
196- ws.motion = newMotion(viewSpring(10, 0), time.Time{})
196+ ws.motion = newMotion(viewSpring(10, 0), time.Time{}, 1)
197197 ctx := context.Background()
198198 if err := c.publish(ctx); err != nil {
199199 t.Fatal(err)
@@ -207,7 +207,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
207207 if v, ok := c.configures.sent[1]; !ok || !v.Captured || v.Visible || v.Activated || v.Width <= 0 {
208208 t.Fatalf("window during the slide %+v (sent %v)", v, ok)
209209 }
210- ws.motion = nil
210+ ws.motion = motion{}
211211 if err := c.publish(ctx); err != nil {
212212 t.Fatal(err)
213213 }
M internal/core/configures.go +14 -0
@@ -130,6 +130,20 @@ func (s *configures) mark(v ports.ConfigureWindow) {
130130 delete(s.answer, v.ID)
131131 }
132132
133+// keep marks id as seen without a configure: a window drawn while it leaves
134+// keeps what was sent to it. Only for a window the layout still holds (a
135+// hidden stash window): a destroyed one is forgotten (forget).
136+func (s *configures) keep(id WindowID) { s.seen[id] = true }
137+
138+// forget drops what was sent to id: the window is unmapped. A toplevel
139+// that maps again keeps its ID, so it must start from nothing, or an
140+// unchanged first configure would be withheld.
141+func (s *configures) forget(id WindowID) {
142+ delete(s.sent, id)
143+ delete(s.answer, id)
144+ delete(s.seen, id)
145+}
146+
133147 // prune forgets the windows no next call saw since the last prune: they
134148 // left the layout.
135149 func (s *configures) prune() {
M internal/core/core.go +158 -55
@@ -67,6 +67,11 @@ type binding struct {
6767 key string
6868 }
6969 type Core struct {
70+ // overviewReal is publish's scratch map of the real layouts under an open overview.
71+ overviewReal map[WindowID]Placement
72+ // realBuf backs the layout read through at once by publish (each
73+ // workspace while overviewReal is filled) and by state(); never retained.
74+ realBuf []Placement
7075 security ports.SecurityState
7176 lockSurfaces []ports.LockSurfacePlacement
7277 lockFocus WindowID
@@ -126,10 +131,15 @@ type Core struct {
126131 // (ports.UserActivity), sent at most once per ActivityInterval.
127132 activity time.Time
128133 // swipe is the touchpad swipe in progress. frameC fires when no page
129- // flip came in time to move a running slide (frameStop stops it).
130- swipe *swipeGesture
131- frameC <-chan time.Time
132- frameStop func() bool
134+ // flip came in time to move a running slide; nil when not armed. It is
135+ // the channel of frameTimer, made once and reset per frame: an
136+ // animation frame allocates no timer.
137+ swipe *swipeGesture
138+ frameC <-chan time.Time
139+ frameTimer ports.Timer
140+ // shots is the snapshot of what the screens show before an action
141+ // (transition.go), reused by every action.
142+ shots []viewShot
133143 // motionTime is the time of the last pointer motion; pointerAt is the
134144 // last position sent in the pointer's window.
135145 motionTime time.Duration
@@ -355,14 +365,26 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
355365
356366 // newTimer makes a timer on clock, or the system timer when clock is nil.
357367 func newTimer(clock ports.Clock, d time.Duration) (<-chan time.Time, func() bool) {
368+ t := newPortTimer(clock, d)
369+ return t.C(), t.Stop
370+}
371+
372+// newPortTimer makes a ports.Timer on clock, or on the system clock when
373+// clock is nil.
374+func newPortTimer(clock ports.Clock, d time.Duration) ports.Timer {
358375 if clock != nil {
359- t := clock.NewTimer(d)
360- return t.C(), t.Stop
376+ return clock.NewTimer(d)
361377 }
362- t := time.NewTimer(d)
363- return t.C, t.Stop
378+ return sysTimer{time.NewTimer(d)}
364379 }
365380
381+// sysTimer is a time.Timer as a ports.Timer.
382+type sysTimer struct{ t *time.Timer }
383+
384+func (t sysTimer) C() <-chan time.Time { return t.t.C }
385+func (t sysTimer) Stop() bool { return t.t.Stop() }
386+func (t sysTimer) Reset(d time.Duration) bool { return t.t.Reset(d) }
387+
366388 func (c *Core) now() time.Time {
367389 if c.ch.Clock != nil {
368390 return c.ch.Clock.Now()
@@ -405,21 +427,20 @@ func onScreen(p Placement, o Rect) bool {
405427
406428 // floatDim is the veil opacity of a layout: dim only when a float is
407429 // drawn above the tiles, never for a demoted covering float alone nor for
408-// an overview preview.
430+// an overview preview. A leaving float (fading out after it closed or hid)
431+// drops the veil at once: a veil that fades changes Scene.Dim every frame,
432+// which redraws the whole output for the length of the fade, where the
433+// float's own fade redraws only its rect.
409434 func floatDim(layout []Placement, o Rect, dim float64) float64 {
410- shown := false
411435 for _, p := range layout {
412436 if p.Floating && !p.Below && p.Preview == 0 && onScreen(p, o) {
413437 if p.Fullscreen {
414438 return 0
415439 }
416- shown = true
440+ return dim
417441 }
418442 }
419- if !shown {
420- return 0
421- }
422- return dim
443+ return 0
423444 }
424445
425446 // visible reports whether the window, layer surface or popup is on screen
@@ -491,7 +512,13 @@ func (c *Core) setLayers(all []ports.LayerSurface) {
491512 }
492513 }
493514
494-func (c *Core) publish(ctx context.Context) error {
515+func (c *Core) publish(ctx context.Context) error { return c.publishFrame(ctx, nil) }
516+
517+// publishFrame publishes the scenes. only is the screen whose page flip
518+// moved the springs (nil: any, or none moved): the pulse advances with the
519+// focused screen's flip, and only a flip's own screen gets a fresh Seq for
520+// its animation, so another output keeps its Seq while it draws the same.
521+func (c *Core) publishFrame(ctx context.Context, only *screen) error {
495522 if c.inputEpochChanged() {
496523 return errSecurityChanged
497524 }
@@ -505,6 +532,7 @@ func (c *Core) publish(ctx context.Context) error {
505532 sc.mon.Current().settleLeft()
506533 }
507534 }
535+ c.refreshShown()
508536 c.captureExpire()
509537 capture, err := c.captureEvaluate(ctx)
510538 if err != nil {
@@ -530,17 +558,20 @@ func (c *Core) publish(ctx context.Context) error {
530558 focus := c.keyboardFocus()
531559 window, _ := c.cur().mon.Focused()
532560 c.pulseFocus(window, focus == window)
533- var pulse float64
534- if c.pulse.id != 0 {
535- pulse = c.advancePulse(c.now())
536- }
561+ // The pulse runs on the focused output: a flip of another output reuses
562+ // its value so the focused scene keeps its Seq.
563+ if c.pulse.id == 0 {
564+ c.pulse.value = 0
565+ } else if only == nil || only == c.cur() {
566+ c.pulse.value = c.advancePulse(c.now())
567+ }
568+ pulse := c.pulse.value
537569 // drawable: the scene can show a pulse on the focused window.
538570 drawable := false
539571 scenes := make([]ports.Scene, 0, len(c.screens))
540572 // Before the first output (and after the last is unplugged) the
541573 // placeholder's scene has no output name: no renderer draws it.
542574 for i, sc := range c.screens {
543- c.seq++
544575 o := sc.mon.Output()
545576 // frame is the viewport of the workspace on screen: the whole output
546577 // unless it has a size override (never in the overview).
@@ -549,13 +580,21 @@ func (c *Core) publish(ctx context.Context) error {
549580 if frame != (Rect{W: o.W, H: o.H}) {
550581 clip = frame
551582 }
552- scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
553- layout := sc.mon.Layout()
583+ // layout is what is drawn; settled (same indexes) is where the
584+ // windows are going, and alone sizes the configures.
585+ layout, settled := sc.shown, sc.settledLayout
586+ scene := ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)}
554587 var real map[WindowID]Placement
555588 if sc.mon.ov.open {
556- real = make(map[WindowID]Placement)
589+ // Local to this screen's build: reused, cleared each time.
590+ if c.overviewReal == nil {
591+ c.overviewReal = make(map[WindowID]Placement)
592+ }
593+ clear(c.overviewReal)
594+ real = c.overviewReal
557595 for w := range sc.mon.all() {
558- for _, p := range w.Layout() {
596+ c.realBuf = w.layoutInto(c.realBuf)
597+ for _, p := range c.realBuf {
559598 real[p.ID] = p
560599 }
561600 }
@@ -569,21 +608,42 @@ func (c *Core) publish(ctx context.Context) error {
569608 if d := c.drag; d != nil && d.target.screen == sc && d.target.kind != dropNone {
570609 scene.DropHints = slices.Clone(d.target.hints)
571610 }
572- // Only the focused output lights the focused window's lines.
573- scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
574611 if sc.mon.ov.open {
575612 // Frame the selection and separate numbered and named row groups.
576613 _, scene.Separators = sc.mon.overviewRows()
577614 scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)
615+ } else {
616+ // Only the focused output lights the focused window's lines.
617+ scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
578618 }
579619 // A window alone on screen needs no pulse to show it has the focus.
580620 alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1
581- for _, p := range layout {
621+ for k, p := range layout {
622+ ps := settled[k]
582623 // Only the focused output has an activated window.
583624 focused := p.Focused && i == c.focusScreen
584- sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview}
585- if p.Peek {
586- sw.Dim = c.cfg.Stash.Dim
625+ sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}
626+ if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
627+ // A scale motion: the content follows the drawn size. A
628+ // card in flight drawn at its size still needs Zoom 1:
629+ // without it the renderer would shrink it by Preview.
630+ sw.Zoom = p.Zoom
631+ }
632+ // A peek's veil is the configured one plus its animated offset.
633+ sw.Dim = max(0, min(p.Dim+c.peekDim(p), 1))
634+ if p.Leaving {
635+ // A window fading out after it closed or hid: drawn, but
636+ // hidden to everything else, its configures included.
637+ sw.Hidden = false
638+ scene.Windows = append(scene.Windows, sw)
639+ // A window the workspace still holds (a hidden stash
640+ // window) keeps its last configure: prune would forget it,
641+ // and its next one would start from nothing. A closed one
642+ // was forgotten at its unmap and stays so.
643+ if w, _ := sc.mon.find(p.ID); w != nil {
644+ c.configures.keep(p.ID)
645+ }
646+ continue
587647 }
588648 if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
589649 drawable = true
@@ -595,7 +655,7 @@ func (c *Core) publish(ctx context.Context) error {
595655 t := configureTarget{output: sc.name(), area: frame, focused: focused, captured: capture != nil && capture.window == p.ID}
596656 if !p.Hidden && p.Preview == 0 {
597657 // Only a sized configure needs the client size.
598- t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID)
658+ t.client, t.imposed = c.clientRect(ps), sc.mon.Current().imposedFloat(p.ID)
599659 } else if t.captured && p.Hidden {
600660 // A captured hidden window is sized like its capture.
601661 t.client = capture.windowSz
@@ -605,8 +665,8 @@ func (c *Core) publish(ctx context.Context) error {
605665 t.client = c.clientRect(rp)
606666 }
607667 }
608- cp, t := c.captureConfigure(sc, p, t)
609- if v, send := c.configures.nextWithCapture(p, t, cp); send {
668+ cp, t := c.captureConfigure(sc, ps, t)
669+ if v, send := c.configures.nextWithCapture(ps, t, cp); send {
610670 if err := c.command(ctx, v); err != nil {
611671 return err
612672 }
@@ -615,7 +675,23 @@ func (c *Core) publish(ctx context.Context) error {
615675 }
616676 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
617677 scene.CaptureIndicators = c.captureIndicators(sc)
618- if scene.Capture = c.captureSceneFor(sc, capture); scene.Capture != nil {
678+ scene.Capture = c.captureSceneFor(sc, capture)
679+ // A scene carrying a capture image always gets a fresh Seq, and so
680+ // does one of an animating screen (the one being stepped, or any
681+ // when none is): the outputs drop a scene they already show, and a
682+ // spring that starts at the previous scene, or rounds to it in its
683+ // tail, would get no flip and wait for the fallback timer. Any other
684+ // keeps its output's Seq while it draws the same, so an idle output
685+ // is not recomposed.
686+ withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr)
687+ animating := (sc.springing() || c.pulsing(sc)) && (only == nil || only == sc)
688+ if !withCapture && !animating && sc.last.Seq != 0 && scene.SameAs(sc.last) {
689+ scene.Seq = sc.last.Seq
690+ } else {
691+ c.seq++
692+ scene.Seq = c.seq
693+ }
694+ if withCapture {
619695 switch sc {
620696 case capture.hiddenScr:
621697 scene.CaptureScene = c.captureScene(scene.Seq)
@@ -623,6 +699,7 @@ func (c *Core) publish(ctx context.Context) error {
623699 scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)
624700 }
625701 }
702+ sc.last = scene
626703 scenes = append(scenes, scene)
627704 }
628705 c.configures.prune()
@@ -651,7 +728,7 @@ func (c *Core) publish(ctx context.Context) error {
651728 return errSecurityChanged
652729 }
653730 if c.syncSecurity() {
654- return c.publish(ctx)
731+ return c.publishFrame(ctx, only)
655732 }
656733 latest(c.ch.Scenes, scenes)
657734 c.publishState()
@@ -668,20 +745,41 @@ func (c *Core) publish(ctx context.Context) error {
668745 return nil
669746 }
670747
671-// step moves the running slides to now and publishes the frame.
672-func (c *Core) step(ctx context.Context) error {
673- c.stopFrame()
674- c.animate(c.now())
675- return c.slid(ctx, false)
748+// step moves the running slides of only (nil: every screen) to now and
749+// publishes the frame.
750+func (c *Core) step(ctx context.Context, only *screen) error {
751+ // The fallback timer guards every animating output: a flip of one keeps
752+ // it running while another still animates, or that one could starve.
753+ if only == nil || !c.animatingOther(only) {
754+ c.stopFrame()
755+ }
756+ c.animate(c.now(), only)
757+ return c.slidFrame(ctx, false, only)
758+}
759+
760+// animatingOther reports whether a spring or the pulse runs on a screen
761+// other than only.
762+func (c *Core) animatingOther(only *screen) bool {
763+ for _, sc := range c.screens {
764+ if sc != only && (sc.springing() || c.pulsing(sc)) {
765+ return true
766+ }
767+ }
768+ return false
676769 }
677770
678771 // slid publishes a moved view; shown reports another workspace came on
679772 // screen.
680773 func (c *Core) slid(ctx context.Context, shown bool) error {
774+ return c.slidFrame(ctx, shown, nil)
775+}
776+
777+// slidFrame is slid for the page flip of only (nil: any screen).
778+func (c *Core) slidFrame(ctx context.Context, shown bool, only *screen) error {
681779 if err := c.workspaceVisible(ctx, shown); err != nil {
682780 return err
683781 }
684- if err := c.publish(ctx); err != nil {
782+ if err := c.publishFrame(ctx, only); err != nil {
685783 return err
686784 }
687785 return c.rehit(ctx)
@@ -788,7 +886,7 @@ func (c *Core) shownClient(id WindowID) (full, visible Rect, ok bool) {
788886 if s == nil {
789887 return Rect{}, Rect{}, false
790888 }
791- for _, p := range s.mon.Layout() {
889+ for _, p := range s.shownLayout() {
792890 if p.ID != id || p.Hidden || p.Preview > 0 {
793891 continue
794892 }
@@ -938,7 +1036,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
9381036 }
9391037 var id WindowID
9401038 var sx, sy float64
941- for _, p := range sc.mon.Layout() {
1039+ for _, p := range sc.shownLayout() {
9421040 r := c.clientRect(p)
9431041 // A peek is clickable wherever it shows, border included: it may
9441042 // be narrower than its border. The point is clamped to its client.
@@ -1029,12 +1127,9 @@ func (c *Core) Run(ctx context.Context) error {
10291127 case ports.InputRegionChanged:
10301128 c.windows.setRegion(v)
10311129 case ports.WindowMapped:
1032- c.windows.mapped(v, c.now())
1033- c.placement.place(c, v)
1130+ c.mapWindow(v)
10341131 case ports.WindowResized:
1035- if _, w := c.screenOf(v.ID); w != nil {
1036- w.ResizeFloating(v.ID, v.Width, v.Height)
1037- }
1132+ c.resizeFloating(v)
10381133 case ports.PopupRequest:
10391134 if err := c.placePopup(ctx, v); err != nil {
10401135 return nil
@@ -1076,9 +1171,7 @@ func (c *Core) Run(ctx context.Context) error {
10761171 if c.drag != nil && c.drag.id == v.ID {
10771172 c.abortDrag()
10781173 }
1079- if s, _ := c.screenOf(v.ID); s != nil {
1080- s.mon.RemoveWindow(v.ID)
1081- }
1174+ c.unmapWindow(v)
10821175 c.releaseSlots()
10831176 if c.pointer == v.ID {
10841177 c.pointer = 0
@@ -1109,6 +1202,13 @@ func (c *Core) Run(ctx context.Context) error {
11091202 }
11101203 if s, _ := c.screenOf(v.ID); s != nil {
11111204 s.mon.SetFullscreen(v.ID, v.Fullscreen)
1205+ if w := s.mon.Current(); v.Fullscreen && w.fullscreen == v.ID {
1206+ // A taskbar or late fullscreen request that took
1207+ // effect: the window does not fade in, it keeps the
1208+ // direct scanout path. One that was ignored (the
1209+ // grace after the map) leaves the entrance running.
1210+ delete(s.rects, v.ID)
1211+ }
11121212 }
11131213 case ports.WorkspaceActivate:
11141214 before := c.cur().mon.Current()
@@ -1145,7 +1245,7 @@ func (c *Core) Run(ctx context.Context) error {
11451245 c.ch.Frames = nil
11461246 continue
11471247 }
1148- if c.sliding(f.Output) && c.step(ctx) != nil {
1248+ if i := c.screenIndex(f.Output); i >= 0 && c.sliding(f.Output) && c.step(ctx, c.screens[i]) != nil {
11491249 return nil
11501250 }
11511251 continue
@@ -1153,8 +1253,8 @@ func (c *Core) Run(ctx context.Context) error {
11531253 if c.securityCheckpoint(ctx) != nil {
11541254 return nil
11551255 }
1156- c.frameC, c.frameStop = nil, nil
1157- if c.step(ctx) != nil {
1256+ c.frameC = nil
1257+ if c.step(ctx, nil) != nil {
11581258 return nil
11591259 }
11601260 continue
@@ -1217,6 +1317,9 @@ func (c *Core) Run(ctx context.Context) error {
12171317 }
12181318 continue
12191319 }
1320+ if !c.animOn() {
1321+ c.stopAnimations()
1322+ }
12201323 // A rotation can shrink the layout under a still pointer.
12211324 c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
12221325 if c.workspaceVisible(ctx, false) != nil {
M internal/core/core_test.go +176 -49
@@ -5,13 +5,11 @@ import (
55 "errors"
66 "reflect"
77 "slices"
8- "sync/atomic"
98 "testing"
109 "time"
1110
1211 "github.com/bnema/neferwl/internal/adapters/config"
1312 "github.com/bnema/neferwl/internal/core"
14- portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1513 "github.com/bnema/neferwl/internal/ports"
1614 )
1715
@@ -46,7 +44,56 @@ func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
4644 }
4745 }
4846
49-func TestOwner(t *testing.T) {
47+// modeLanding gives a hand-rolled core test its animation mode: off, or on with
48+// a clock the test moves and the page flips it sends. land returns the scene
49+// set once every spring has landed (the set itself when none runs, and
50+// always with animations off). An on run that never saw a spring would be
51+// the off run again, so it fails.
52+type modeLanding struct {
53+ t *testing.T
54+ clk *stepClock
55+ frames chan ports.OutputFrame
56+ scenes chan []ports.Scene
57+ outputs []string
58+ lands int
59+}
60+
61+// newLanding sets cfg's animations and, when on, the clock and flips of ch.
62+func newLanding(t *testing.T, animated bool, cfg *ports.Config, ch *core.Channels, outputs ...string) *modeLanding {
63+ t.Helper()
64+ cfg.Animations.On = animated
65+ l := &modeLanding{t: t, scenes: ch.Scenes, outputs: outputs}
66+ if animated {
67+ l.clk, l.frames = newStepClock(t), make(chan ports.OutputFrame)
68+ ch.Clock, ch.Frames = l.clk.clock, l.frames
69+ t.Cleanup(func() {
70+ if l.lands == 0 {
71+ t.Error("no spring ever ran: the animations-on variant checked nothing")
72+ }
73+ })
74+ }
75+ return l
76+}
77+
78+func (l *modeLanding) land(set []ports.Scene) []ports.Scene {
79+ l.t.Helper()
80+ if l.clk == nil {
81+ return set
82+ }
83+ landed, ok := l.clk.settle(l.t, l.frames, l.scenes, l.outputs...)
84+ if !ok {
85+ return set
86+ }
87+ l.lands++
88+ return landed
89+}
90+
91+// one is land for the first output's scene.
92+func (l *modeLanding) one(s ports.Scene) ports.Scene { return l.land([]ports.Scene{s})[0] }
93+
94+func TestOwner(t *testing.T) { both(t, owner) }
95+
96+func owner(t *testing.T, animated bool) {
5097 cfg := altCmdDefaults()
5198 cfg.Border.Width = 0
5299 cfg.Layout.Gaps = 8
@@ -58,7 +105,9 @@ func TestOwner(t *testing.T) {
58105 spawn := make(chan ports.SpawnRequest, 8)
59106 scenes := make(chan []ports.Scene, 1)
60107 errs := make(chan error, 8)
61- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs})
108+ ch := core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs}
109+ l := newLanding(t, animated, &cfg, &ch, "OUT-1")
110+ c, err := core.New(cfg, ch)
62111 if err != nil {
63112 t.Fatal(err)
64113 }
@@ -72,7 +121,7 @@ func TestOwner(t *testing.T) {
72121 t.Fatal(s)
73122 }
74123 client <- ports.WindowMapped{ID: 1}
75- s = scene(t, scenes)
124+ s = l.one(scene(t, scenes))
76125 // A single column fills the usable width.
77126 if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 {
78127 t.Fatal(s)
@@ -117,7 +166,7 @@ func TestOwner(t *testing.T) {
117166 }
118167 cfg.Layout.Gaps = 4
119168 reload <- ports.ConfigChanged{Config: cfg}
120- s = scene(t, scenes)
169+ s = l.one(scene(t, scenes))
121170 if s.Windows[0].Rect.W != 92 {
122171 t.Fatal(s)
123172 }
@@ -283,7 +332,9 @@ func TestPointerFocusAndGrab(t *testing.T) {
283332 }
284333 }
285334
286-func TestBorderInset(t *testing.T) {
335+func TestBorderInset(t *testing.T) { both(t, borderInset) }
336+
337+func borderInset(t *testing.T, animated bool) {
287338 cfg := config.Defaults()
288339 cfg.Border.Width = 2
289340 client := make(chan ports.ClientEvent, 8)
@@ -291,7 +342,9 @@ func TestBorderInset(t *testing.T) {
291342 output := make(chan ports.OutputEvent, 8)
292343 commands := make(chan ports.ClientCommand, 64)
293344 scenes := make(chan []ports.Scene, 1)
294- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
345+ ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
346+ l := newLanding(t, animated, &cfg, &ch, "OUT-1")
347+ c, err := core.New(cfg, ch)
295348 if err != nil {
296349 t.Fatal(err)
297350 }
@@ -302,14 +355,14 @@ func TestBorderInset(t *testing.T) {
302355 scene(t, scenes)
303356 // Alone, the window is borderless and gets the full rect.
304357 client <- ports.WindowMapped{ID: 1}
305- r := scene(t, scenes).Windows[0].Rect
358+ r := l.one(scene(t, scenes)).Windows[0].Rect
306359 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1", Visible: true}) {
307360 t.Fatalf("%v for outer %v", v, r)
308361 }
309362 // With a second column, the left window owns the shared separator: its
310363 // client is 2px narrower on the right; the right window keeps its rect.
311364 client <- ports.WindowMapped{ID: 2}
312- s := scene(t, scenes)
365+ s := l.one(scene(t, scenes))
313366 r = s.Windows[1].Rect
314367 if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 {
315368 t.Fatal(s)
@@ -383,14 +436,46 @@ func TestLayerKeyboardFocus(t *testing.T) {
383436 }
384437 }
385438
386-func TestWorkspaceSwitch(t *testing.T) {
439+func TestWorkspaceSwitch(t *testing.T) { workspaceSwitch(t, false) }
440+
441+// With animations on the scene right after the key shows the slide's start:
442+// the test lands it before it asserts.
443+func TestWorkspaceSwitchAnimated(t *testing.T) { workspaceSwitch(t, true) }
444+
445+// settledScene lands the springs an action started on the single output
446+// OUT-1 of a rig with a stepClock and returns its scene; animations off:
447+// the scene itself.
448+func settledScene(t *testing.T, sc *stepClock, frames chan ports.OutputFrame, scenes chan []ports.Scene, s ports.Scene, mustAnimate bool) ports.Scene {
449+ t.Helper()
450+ if sc == nil {
451+ return s
452+ }
453+ set, ok := sc.settle(t, frames, scenes, "OUT-1")
454+ if !ok {
455+ if mustAnimate {
456+ t.Fatal("the action started no animation: the case checks nothing")
457+ }
458+ return s
459+ }
460+ return set[0]
461+}
462+
463+func workspaceSwitch(t *testing.T, animated bool) {
387464 cfg := config.Defaults()
465+ cfg.Animations.On = animated
388466 client := make(chan ports.ClientEvent, 8)
389467 input := make(chan ports.InputEvent, 8)
390468 output := make(chan ports.OutputEvent, 8)
391469 commands := make(chan ports.ClientCommand, 64)
392470 scenes := make(chan []ports.Scene, 1)
393- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
471+ ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
472+ var sc *stepClock
473+ frames := make(chan ports.OutputFrame)
474+ if animated {
475+ sc = newStepClock(t)
476+ ch.Clock, ch.Frames = sc.clock, frames
477+ }
478+ c, err := core.New(cfg, ch)
394479 if err != nil {
395480 t.Fatal(err)
396481 }
@@ -406,7 +491,7 @@ func TestWorkspaceSwitch(t *testing.T) {
406491 }
407492 // AZERTY: the 2 key prints eacute; the default bind is on its physical key (code 3).
408493 input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
409- s := scene(t, scenes)
494+ s := settledScene(t, sc, frames, scenes, scene(t, scenes), animated)
410495 if len(s.Windows) != 1 || !s.Windows[0].Hidden {
411496 t.Fatal(s)
412497 }
@@ -425,20 +510,32 @@ func TestWorkspaceSwitch(t *testing.T) {
425510 }
426511 // A new window opens on workspace 2, alone and borderless.
427512 client <- ports.WindowMapped{ID: 2}
428- s = scene(t, scenes)
513+ s = settledScene(t, sc, frames, scenes, scene(t, scenes), false)
429514 if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || s.Windows[1].Inset != 0 {
430515 t.Fatal(s)
431516 }
432517 }
433518
434-func TestClickAfterWorkspaceSwitch(t *testing.T) {
519+func TestClickAfterWorkspaceSwitch(t *testing.T) { clickAfterWorkspaceSwitch(t, false) }
520+
521+func TestClickAfterWorkspaceSwitchAnimated(t *testing.T) { clickAfterWorkspaceSwitch(t, true) }
522+
523+func clickAfterWorkspaceSwitch(t *testing.T, animated bool) {
435524 cfg := config.Defaults()
525+ cfg.Animations.On = animated
436526 client := make(chan ports.ClientEvent, 8)
437527 input := make(chan ports.InputEvent, 8)
438528 output := make(chan ports.OutputEvent, 8)
439529 commands := make(chan ports.ClientCommand, 64)
440530 scenes := make(chan []ports.Scene, 1)
441- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
531+ ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
532+ var sc *stepClock
533+ frames := make(chan ports.OutputFrame)
534+ if animated {
535+ sc = newStepClock(t)
536+ ch.Clock, ch.Frames = sc.clock, frames
537+ }
538+ c, err := core.New(cfg, ch)
442539 if err != nil {
443540 t.Fatal(err)
444541 }
@@ -458,7 +555,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) {
458555 }
459556 // Cmd+2 hides window 1; the pointer must leave it.
460557 input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
461- scene(t, scenes)
558+ settledScene(t, sc, frames, scenes, scene(t, scenes), animated)
462559 var cleared bool
463560 for len(commands) > 0 {
464561 if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 {
@@ -472,7 +569,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) {
472569 input <- ports.PointerButton{Button: 0x110, Pressed: true}
473570 input <- ports.PointerButton{Button: 0x110}
474571 client <- ports.WindowMapped{ID: 2}
475- s := scene(t, scenes)
572+ s := settledScene(t, sc, frames, scenes, scene(t, scenes), false)
476573 if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden {
477574 t.Fatal(s)
478575 }
@@ -512,7 +609,9 @@ func TestShiftReleasedFirst(t *testing.T) {
512609 }
513610 }
514611
515-func TestOutputScale(t *testing.T) {
612+func TestOutputScale(t *testing.T) { both(t, outputScale) }
613+
614+func outputScale(t *testing.T, animated bool) {
516615 cfg := config.Defaults()
517616 cfg.Border.Width = 0
518617 cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}}
@@ -524,7 +623,9 @@ func TestOutputScale(t *testing.T) {
524623 reload := make(chan ports.ConfigChanged, 1)
525624 commands := make(chan ports.ClientCommand, 64)
526625 scenes := make(chan []ports.Scene, 1)
527- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes})
626+ ch := core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes}
627+ l := newLanding(t, animated, &cfg, &ch, "DP-2")
628+ c, err := core.New(cfg, ch)
528629 if err != nil {
529630 t.Fatal(err)
530631 }
@@ -541,7 +642,7 @@ func TestOutputScale(t *testing.T) {
541642 t.Fatal(v)
542643 }
543644 client <- ports.WindowMapped{ID: 1}
544- scene(t, scenes)
645+ l.one(scene(t, scenes))
545646 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2", Visible: true}) {
546647 t.Fatal(v)
547648 }
@@ -550,7 +651,13 @@ func TestOutputScale(t *testing.T) {
550651 }
551652 // The pointer arrives in global logical pixels.
552653 input <- ports.PointerMotion{X: 500, Y: 200}
553- if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 500, Y: 200}) {
654+ if animated {
655+ // The page flips that landed the map's springs re-hit the pointer
656+ // (still at the origin, over the window): focus was already sent.
657+ if v, ok := command(t, commands).(ports.PointerMotionTo); !ok || v.ID != 1 || v.X != 500 || v.Y != 200 {
658+ t.Fatal(v)
659+ }
660+ } else if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 500, Y: 200}) {
554661 t.Fatal(v)
555662 }
556663 // Cmd+- steps down through the clean scales of 5120x2160: 2 → 5/3.
@@ -635,7 +742,9 @@ func TestPointerWarp(t *testing.T) {
635742 }
636743 }
637744
638-func TestPointerConstraint(t *testing.T) {
745+func TestPointerConstraint(t *testing.T) { both(t, pointerConstraint) }
746+
747+func pointerConstraint(t *testing.T, animated bool) {
639748 cfg := config.Defaults()
640749 cfg.Border.Width = 0
641750 cfg.Layout.Gaps = 0
@@ -645,7 +754,9 @@ func TestPointerConstraint(t *testing.T) {
645754 commands := make(chan ports.ClientCommand, 64)
646755 scenes := make(chan []ports.Scene, 1)
647756 constraints := make(chan ports.PointerConstraint, 1)
648- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints})
757+ ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints}
758+ l := newLanding(t, animated, &cfg, &ch, "A", "B")
759+ c, err := core.New(cfg, ch)
649760 if err != nil {
650761 t.Fatal(err)
651762 }
@@ -659,7 +770,7 @@ func TestPointerConstraint(t *testing.T) {
659770 input <- ports.PointerMotion{X: 150, Y: 40}
660771 scene(t, scenes) // the pointer moves the focus to B
661772 client <- ports.WindowMapped{ID: 1}
662- r := receive(t, scenes)[1].Windows[0].Rect
773+ r := l.land(receive(t, scenes))[1].Windows[0].Rect
663774 // The confine region is window-local and clipped to the window.
664775 client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 10, Y: 10, W: 1000, H: 20}}}
665776 scene(t, scenes)
@@ -843,7 +954,9 @@ func TestDialogOverFullscreenHit(t *testing.T) {
843954 // A window that asks for fullscreen as it maps (Wine at a remembered
844955 // monitor size) stays in its column: the windows it opens next are seen.
845956 // A later request is honoured.
846-func TestFullscreenAtMapIgnored(t *testing.T) {
957+func TestFullscreenAtMapIgnored(t *testing.T) { both(t, fullscreenAtMapIgnored) }
958+
959+func fullscreenAtMapIgnored(t *testing.T, animated bool) {
847960 cfg := config.Defaults()
848961 cfg.Layout.Overflow = "fixed"
849962 cfg.Focus.Animation = ports.FocusAnimationOff
@@ -851,11 +964,15 @@ func TestFullscreenAtMapIgnored(t *testing.T) {
851964 output := make(chan ports.OutputEvent, 8)
852965 commands := make(chan ports.ClientCommand, 64)
853966 scenes := make(chan []ports.Scene, 1)
854- // Core reads the time on its goroutine.
855- var now atomic.Int64
856- clock := portsmocks.NewMockClock(t)
857- clock.EXPECT().Now().RunAndReturn(func() time.Time { return time.Unix(now.Load(), 0) })
858- c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock})
967+ // Core reads the time on its goroutine: the clock is the test's.
968+ ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}
969+ l := newLanding(t, animated, &cfg, &ch, "OUT-1")
970+ clk := l.clk
971+ if clk == nil {
972+ clk = newStepClock(t)
973+ ch.Clock = clk.clock
974+ }
975+ c, err := core.New(cfg, ch)
859976 if err != nil {
860977 t.Fatal(err)
861978 }
@@ -879,7 +996,10 @@ func TestFullscreenAtMapIgnored(t *testing.T) {
879996 t.Fatalf("startup fullscreen applied: %+v", s.Windows)
880997 }
881998 }
882- now.Add(1)
999+ // With animations on this lands the maps' springs: the clock moves past
1000+ // the grace, as the next line does anyway.
1001+ l.one(s)
1002+ clk.advance(time.Second)
8831003 // Only the focused window may cover the screen (ADR 011).
8841004 client <- ports.WindowUnmapped{ID: 3}
8851005 scene(t, scenes)
@@ -924,16 +1044,21 @@ func TestFixedFullscreenArrivalWaits(t *testing.T) {
9241044 }
9251045
9261046 // A taskbar's fullscreen request is the user's: it applies at once.
927-func TestExternalFullscreenAtMapApplies(t *testing.T) {
1047+func TestExternalFullscreenAtMapApplies(t *testing.T) { both(t, externalFullscreenAtMapApplies) }
1048+
1049+func externalFullscreenAtMapApplies(t *testing.T, animated bool) {
9281050 cfg := config.Defaults()
9291051 cfg.Focus.Animation = ports.FocusAnimationOff
9301052 client := make(chan ports.ClientEvent, 8)
9311053 output := make(chan ports.OutputEvent, 8)
9321054 commands := make(chan ports.ClientCommand, 64)
9331055 scenes := make(chan []ports.Scene, 1)
934- clock := portsmocks.NewMockClock(t)
935- clock.EXPECT().Now().Return(time.Unix(0, 0))
936- c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock})
1056+ ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}
1057+ l := newLanding(t, animated, &cfg, &ch, "OUT-1")
1058+ if l.clk == nil {
1059+ ch.Clock = newStepClock(t).clock
1060+ }
1061+ c, err := core.New(cfg, ch)
9371062 if err != nil {
9381063 t.Fatal(err)
9391064 }
@@ -943,7 +1068,7 @@ func TestExternalFullscreenAtMapApplies(t *testing.T) {
9431068 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
9441069 scene(t, scenes)
9451070 client <- ports.WindowMapped{ID: 1}
946- scene(t, scenes)
1071+ l.one(scene(t, scenes))
9471072 client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
9481073 s := scene(t, scenes)
9491074 if len(s.Windows) != 1 || !s.Windows[0].Fullscreen {
@@ -1022,18 +1147,20 @@ func TestExternalFullscreenInOverview(t *testing.T) {
10221147 // focus-window-down from a fullscreen tile at the bottom of its column
10231148 // goes to the next workspace and fullscreen stays.
10241149 func TestFocusWindowDownKeepsFullscreen(t *testing.T) {
1025- r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
1026- r.mapWindow(t, 1)
1027- r.mapWindow(t, 2)
1028- r.key(t, "f", ports.ModAlt|ports.ModShift)
1029- set := r.key(t, "Down", ports.ModAlt)
1030- if got, _ := windowsOf(set, "DP-1"); len(got) != 0 {
1031- t.Fatal("still on the first workspace:", got)
1032- }
1033- set = r.key(t, "Up", ports.ModAlt)
1034- if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 {
1035- t.Fatal("fullscreen lost:", got, focused)
1036- }
1150+ both(t, func(t *testing.T, animated bool) {
1151+ r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
1152+ r.mapLanded(t, 1)
1153+ r.mapLanded(t, 2)
1154+ r.keyLanded(t, "f", ports.ModAlt|ports.ModShift)
1155+ set := r.keyLanded(t, "Down", ports.ModAlt)
1156+ if got, _ := windowsOf(set, "DP-1"); len(got) != 0 {
1157+ t.Fatal("still on the first workspace:", got)
1158+ }
1159+ set = r.keyLanded(t, "Up", ports.ModAlt)
1160+ if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 {
1161+ t.Fatal("fullscreen lost:", got, focused)
1162+ }
1163+ })
10371164 }
10381165
10391166 // A refused fullscreen request still gets a configure, unchanged: clients
M internal/core/drag.go +10 -0
@@ -228,12 +228,22 @@ func resized(r Rect, edges ports.ResizeEdges, dx, dy, least int, u Rect) Rect {
228228 }
229229
230230 // endDrag drops the window on release: a tile lands on its target.
231+//
232+// The drop is a transition like a bind's: the snapshot before it is what
233+// the screens draw, the transition after it slides the dropped tile and
234+// re-flows its neighbours. A tile does not follow the pointer during the
235+// drag (dragMotion): the scene draws it in its slot with the drop hints, so
236+// the slot is where the user last saw it and where it slides from. A float
237+// follows the pointer and is already where it lands: no transition.
231238 func (c *Core) endDrag(ctx context.Context) error {
232239 d := c.drag
233240 c.drag = nil
234241 if !d.float {
235242 if t := c.dropAt(d.id, c.cursorX, c.cursorY); t.kind != dropNone {
243+ now := c.now()
244+ shots := c.snapshot(now)
236245 c.drop(d.id, t)
246+ c.transition(shots, now)
237247 // Slots and guests follow the windows, as after a bind.
238248 if err := c.workspaceVisible(ctx, false); err != nil {
239249 return err
M internal/core/drag_test.go +216 -10
@@ -21,11 +21,37 @@ type dragRig struct {
2121 scenes chan []ports.Scene
2222 last []ports.Scene
2323 epoch *atomic.Value
24+ // With a clock the test moves (animated rigs), frames carries the page
25+ // flips and outputs lists the plugged ones: settle lands every spring
26+ // before it reads the scene.
27+ frames chan ports.OutputFrame
28+ clock *stepClock
29+ outputs []string
30+ // lands counts the settles that found a spring running.
31+ lands int
2432 }
2533
2634 // startDrag runs core with Super as Cmd, no border, 10 px gaps, and
2735 // the given outputs. edit may change the config.
2836 func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
37+ t.Helper()
38+ return newDragRig(t, false, edit, outs...)
39+}
40+
41+// startAnimatedDragRig is startDragRig with animations on and a clock the
42+// test moves: settle moves it past every spring and sends the page flips,
43+// so the scene it returns is the settled one, deterministically.
44+func startAnimatedDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
45+ t.Helper()
46+ return newDragRig(t, true, func(c *ports.Config) {
47+ c.Animations.On = true
48+ if edit != nil {
49+ edit(c)
50+ }
51+ }, outs...)
52+}
53+
54+func newDragRig(t *testing.T, animated bool, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
2955 t.Helper()
3056 cfg := config.Defaults()
3157 cfg.Border.Width = 0
@@ -45,7 +71,18 @@ func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn
4571 output: make(chan ports.OutputEvent), commands: make(chan ports.ClientCommand, 4096),
4672 scenes: make(chan []ports.Scene, 1), epoch: epoch,
4773 }
48- c, err := core.New(cfg, core.Channels{Security: gate, Client: r.client, Input: r.input, Output: r.output, Commands: r.commands, Scenes: r.scenes})
74+ ch := core.Channels{Security: gate, Client: r.client, Input: r.input, Output: r.output, Commands: r.commands, Scenes: r.scenes}
75+ if animated {
76+ r.frames, r.clock = make(chan ports.OutputFrame), newStepClock(t)
77+ ch.Frames, ch.Clock = r.frames, r.clock.clock
78+ // An on variant that never saw a spring would be the off variant again.
79+ t.Cleanup(func() {
80+ if r.lands == 0 {
81+ t.Error("no spring ever ran: the animations-on variant checked nothing")
82+ }
83+ })
84+ }
85+ c, err := core.New(cfg, ch)
4986 if err != nil {
5087 t.Fatal(err)
5188 }
@@ -55,6 +92,7 @@ func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn
5592 t.Cleanup(func() { cancel(); <-done })
5693 for _, o := range outs {
5794 r.output <- ports.OutputAdded{Info: o}
95+ r.outputs = append(r.outputs, o.Name)
5896 r.settle(t)
5997 }
6098 return r
@@ -79,9 +117,16 @@ func send[T any](ch chan T, v T) {
79117 const barrier ports.WindowID = 1 << 30
80118
81119 // settle returns the scene set once core handled every event sent so
82-// far. The second barrier is taken only after the first one published.
120+// far. The second barrier is taken only after the first one published. An
121+// animated rig first moves its clock past every spring and flips each
122+// output, so the set is the settled one.
83123 func (r *dragRig) settle(t *testing.T) []ports.Scene {
84124 t.Helper()
125+ if r.frames != nil {
126+ if _, ok := r.clock.settle(t, r.frames, r.scenes, r.outputs...); ok {
127+ r.lands++
128+ }
129+ }
85130 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
86131 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
87132 select {
@@ -255,7 +300,12 @@ func TestDragEscapeRestoresFloat(t *testing.T) {
255300 }
256301
257302 func TestDragTileZonesAndHints(t *testing.T) {
258- r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)
303+ both(t, func(t *testing.T, animated bool) {
304+ dragTileZonesAndHints(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut))
305+ })
306+}
307+
308+func dragTileZonesAndHints(t *testing.T, r *dragRig) {
259309 for id := ports.WindowID(1); id <= 3; id++ {
260310 r.client <- ports.WindowMapped{ID: id}
261311 r.settle(t)
@@ -279,6 +329,8 @@ func TestDragTileZonesAndHints(t *testing.T) {
279329 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
280330 t.Fatal(got)
281331 }
332+ // The drop itself must run a spring: the guard counts from here.
333+ r.lands = 0
282334 r.in(ports.PointerButton{Button: 0x110})
283335 set = r.settle(t)
284336 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
@@ -298,6 +350,8 @@ func TestDragTileZonesAndHints(t *testing.T) {
298350 if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 4 {
299351 t.Fatalf("swap outline %+v", hints)
300352 }
353+ // The drop itself must run a spring: the guard counts from here.
354+ r.lands = 0
301355 r.in(ports.PointerButton{Button: 0x110})
302356 set = r.settle(t)
303357 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
@@ -310,6 +364,8 @@ func TestDragTileZonesAndHints(t *testing.T) {
310364 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
311365 r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 6)
312366 r.settle(t)
367+ // The drop itself must run a spring: the guard counts from here.
368+ r.lands = 0
313369 r.in(ports.PointerButton{Button: 0x110})
314370 set = r.settle(t)
315371 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) {
@@ -332,8 +388,28 @@ func TestDragTileZonesAndHints(t *testing.T) {
332388 }
333389 }
334390
391+// startDragMode is the drag rig with animations off, or on and landed by
392+// settle.
393+func startDragMode(t *testing.T, animated bool, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
394+ t.Helper()
395+ if animated {
396+ return startAnimatedDragRig(t, edit, outs...)
397+ }
398+ return startDragRig(t, func(c *ports.Config) {
399+ if edit != nil {
400+ edit(c)
401+ }
402+ c.Animations.On = false
403+ }, outs...)
404+}
405+
335406 func TestDragTileGapInsert(t *testing.T) {
336- r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)
407+ both(t, func(t *testing.T, animated bool) {
408+ dragTileGapInsert(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut))
409+ })
410+}
411+
412+func dragTileGapInsert(t *testing.T, r *dragRig) {
337413 for id := ports.WindowID(1); id <= 3; id++ {
338414 r.client <- ports.WindowMapped{ID: id}
339415 r.settle(t)
@@ -367,8 +443,114 @@ func TestDragTileGapInsert(t *testing.T) {
367443 }
368444 }
369445
446+// release sends the button release and returns the scene set core
447+// publishes for it, without moving the clock (settle would).
448+func (r *dragRig) release(t *testing.T) []ports.Scene {
449+ t.Helper()
450+ r.in(ports.PointerButton{Button: 0x110})
451+ send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
452+ send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
453+ select {
454+ case r.last = <-r.scenes:
455+ case <-time.After(5 * time.Second):
456+ t.Fatal("no scene")
457+ }
458+ return r.last
459+}
460+
461+// A dropped tile slides from where the drag scene drew it (its own slot:
462+// a tile does not follow the pointer) and its neighbours re-flow; the
463+// client gets one configure, for the final size. With animations off the
464+// drop is instant.
465+func TestDragDropTileSlides(t *testing.T) {
466+ both(t, func(t *testing.T, animated bool) {
467+ r := startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)
468+ for id := ports.WindowID(1); id <= 3; id++ {
469+ r.client <- ports.WindowMapped{ID: id}
470+ r.settle(t)
471+ }
472+ set := r.settle(t)
473+ one0, two0, three0 := dragRect(t, set, "A", 1), dragRect(t, set, "A", 2), dragRect(t, set, "A", 3)
474+ r.moveTo(float64(one0.X+one0.W/2), float64(one0.Y+one0.H/2), 1)
475+ r.settle(t)
476+ r.press(t, 0x110, ports.ModSuper)
477+ // The bottom band of 3: 1 stacks below it.
478+ r.moveTo(float64(three0.X+three0.W/2), float64(three0.Y+three0.H-5), 2)
479+ set = r.settle(t)
480+ if got := dragRect(t, set, "A", 1); got != one0 {
481+ t.Fatalf("tile left its slot during the drag: %+v, was %+v", got, one0)
482+ }
483+ r.drain()
484+ set = r.release(t)
485+ if len(dragScene(set, "A").DropHints) != 0 {
486+ t.Fatal("hints left")
487+ }
488+ if !animated {
489+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
490+ t.Fatal(got)
491+ }
492+ // Instant: the scene right after the drop is the settled one.
493+ if got := dragRect(t, set, "A", 1); got == one0 || got.X != dragRect(t, r.settle(t), "A", 1).X {
494+ t.Fatalf("not dropped at once: %+v", got)
495+ }
496+ return
497+ }
498+ // Right after the release the windows are still where the drag
499+ // scene drew them.
500+ for id, want := range map[ports.WindowID]ports.Rect{1: one0, 2: two0, 3: three0} {
501+ if got := dragRect(t, set, "A", id); got != want {
502+ t.Fatalf("window %d at %+v right after the drop, want its old %+v", id, got, want)
503+ }
504+ }
505+ mid, ok := r.clock.flip(t, r.frames, r.scenes, 30*time.Millisecond, "A")
506+ if !ok {
507+ t.Fatal("no frame after the drop: nothing animates")
508+ }
509+ r.lands++
510+ // The dropped tile is between its old slot and its new one, and the
511+ // column it joined re-flows too.
512+ got := dragRect(t, mid, "A", 1)
513+ if got == one0 || got.X <= one0.X && got.Y <= one0.Y {
514+ t.Fatalf("window 1 at %+v on the first frame, still at its old %+v", got, one0)
515+ }
516+ if got2 := dragRect(t, mid, "A", 2); got2 == two0 {
517+ t.Fatalf("neighbour 2 did not start moving: %+v", got2)
518+ }
519+ set = r.settle(t)
520+ if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
521+ t.Fatal(got)
522+ }
523+ want := map[ports.WindowID]ports.Rect{}
524+ for id := ports.WindowID(1); id <= 3; id++ {
525+ want[id] = dragRect(t, set, "A", id)
526+ }
527+ if want[1].Y <= want[3].Y || want[1].X != want[3].X {
528+ t.Fatalf("1 is not stacked below 3 once settled: %+v", want)
529+ }
530+ // A window is configured at most once by the drop, to its settled
531+ // size, never to a size along the slide; the dropped tile always.
532+ sizes := map[ports.WindowID][][2]int{}
533+ for _, v := range r.drain() {
534+ if v, ok := v.(ports.ConfigureWindow); ok {
535+ sizes[v.ID] = append(sizes[v.ID], [2]int{v.Width, v.Height})
536+ }
537+ }
538+ for id, rect := range want {
539+ got := sizes[id]
540+ if len(got) > 1 || len(got) == 1 && got[0] != [2]int{rect.W, rect.H} || id == 1 && len(got) != 1 {
541+ t.Fatalf("configures of window %d %v, want at most one at %dx%d (exactly one for window 1)", id, got, rect.W, rect.H)
542+ }
543+ }
544+ })
545+}
546+
370547 func TestDragFixedNoGapZone(t *testing.T) {
371- r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut)
548+ both(t, func(t *testing.T, animated bool) {
549+ dragFixedNoGapZone(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut))
550+ })
551+}
552+
553+func dragFixedNoGapZone(t *testing.T, r *dragRig) {
372554 for id := ports.WindowID(1); id <= 2; id++ {
373555 r.client <- ports.WindowMapped{ID: id}
374556 r.settle(t)
@@ -385,6 +567,8 @@ func TestDragFixedNoGapZone(t *testing.T) {
385567 // Top band of 2 stacks 1 above it.
386568 r.moveTo(float64(two.X+two.W/2), float64(two.Y+3), 3)
387569 r.settle(t)
570+ // The drop itself must run a spring: the guard counts from here.
571+ r.lands = 0
388572 r.in(ports.PointerButton{Button: 0x110})
389573 if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1, 2}}, slices.Equal) {
390574 t.Fatal(got)
@@ -392,8 +576,12 @@ func TestDragFixedNoGapZone(t *testing.T) {
392576 }
393577
394578 func TestDragAcrossOutputs(t *testing.T) {
395- b := ports.OutputInfo{Name: "B", Width: 800, Height: 600}
396- r := startDragRig(t, nil, dragOut, b)
579+ both(t, func(t *testing.T, animated bool) {
580+ dragAcrossOutputs(t, startDragMode(t, animated, nil, dragOut, ports.OutputInfo{Name: "B", Width: 800, Height: 600}))
581+ })
582+}
583+
584+func dragAcrossOutputs(t *testing.T, r *dragRig) {
397585 r.client <- ports.WindowMapped{ID: 1}
398586 r.client <- ports.WindowMapped{ID: 2}
399587 r.settle(t)
@@ -414,6 +602,7 @@ func TestDragAcrossOutputs(t *testing.T) {
414602 if len(dragScene(set, "B").DropHints) == 0 || len(dragScene(set, "A").DropHints) != 0 {
415603 t.Fatalf("hints A %+v B %+v", dragScene(set, "A").DropHints, dragScene(set, "B").DropHints)
416604 }
605+ r.lands = 0
417606 r.in(ports.PointerButton{Button: 0x110})
418607 set = r.settle(t)
419608 if _, ok := windowIn(set, "B", 1); !ok {
@@ -484,7 +673,10 @@ func TestLockClearsDrag(t *testing.T) {
484673 }
485674
486675 func TestDragEdgeScroll(t *testing.T) {
487- r := startDragRig(t, nil, dragOut)
676+ both(t, func(t *testing.T, animated bool) { dragEdgeScroll(t, startDragMode(t, animated, nil, dragOut)) })
677+}
678+
679+func dragEdgeScroll(t *testing.T, r *dragRig) {
488680 for id := ports.WindowID(1); id <= 4; id++ {
489681 r.client <- ports.WindowMapped{ID: id}
490682 r.settle(t)
@@ -526,7 +718,12 @@ func TestDragEdgeScroll(t *testing.T) {
526718 }
527719
528720 func TestDragFullFixedStacksInOutlinedColumn(t *testing.T) {
529- r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut)
721+ both(t, func(t *testing.T, animated bool) {
722+ dragFullFixedStacksInOutlinedColumn(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3; c.Layout.Overflow = "fixed" }, dragOut))
723+ })
724+}
725+
726+func dragFullFixedStacksInOutlinedColumn(t *testing.T, r *dragRig) {
530727 for id := ports.WindowID(1); id <= 3; id++ {
531728 r.client <- ports.WindowMapped{ID: id}
532729 r.settle(t)
@@ -542,6 +739,8 @@ func TestDragFullFixedStacksInOutlinedColumn(t *testing.T) {
542739 r.press(t, 0x110, ports.ModSuper)
543740 r.moveTo(float64(one.X+one.W/2), float64(one.Y+3), 2)
544741 r.settle(t)
742+ // The drop itself must run a spring: the guard counts from here.
743+ r.lands = 0
545744 r.in(ports.PointerButton{Button: 0x110})
546745 set = r.settle(t)
547746 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4, 1}, {2}, {3}}, slices.Equal) {
@@ -553,6 +752,8 @@ func TestDragFullFixedStacksInOutlinedColumn(t *testing.T) {
553752 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
554753 r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 4)
555754 r.settle(t)
755+ // The drop itself must run a spring: the guard counts from here.
756+ r.lands = 0
556757 r.in(ports.PointerButton{Button: 0x110})
557758 if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4}, {2, 1}, {3}}, slices.Equal) {
558759 t.Fatal(got)
@@ -623,7 +824,12 @@ func TestClientDragUsesLastPress(t *testing.T) {
623824 }
624825
625826 func TestEdgeScrollPublishesWithoutTarget(t *testing.T) {
626- r := startDragRig(t, nil, dragOut)
827+ both(t, func(t *testing.T, animated bool) {
828+ edgeScrollPublishesWithoutTarget(t, startDragMode(t, animated, nil, dragOut))
829+ })
830+}
831+
832+func edgeScrollPublishesWithoutTarget(t *testing.T, r *dragRig) {
627833 for id := ports.WindowID(1); id <= 4; id++ {
628834 r.client <- ports.WindowMapped{ID: id}
629835 r.settle(t)
A internal/core/frame_output_test.go +67 -0
@@ -0,0 +1,67 @@
1+package core_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func sceneFor(set []ports.Scene, output string) ports.Scene {
11+ for _, s := range set {
12+ if s.Output == output {
13+ return s
14+ }
15+ }
16+ return ports.Scene{}
17+}
18+
19+// A page flip of one output advances only that output's springs: the other
20+// output keeps its scene (and Seq) until its own flip.
21+func TestFlipAdvancesOnlyItsOutput(t *testing.T) {
22+ r := startSwipe(t, nil)
23+ threeColumns(t, r)
24+ second := ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}
25+ r.plug(t, second)
26+ r.input <- ports.PointerMotion{X: 1000, Y: 300}
27+ scene(t, r.scenes)
28+ for id := ports.WindowID(4); id <= 6; id++ {
29+ r.mapWindow(t, id)
30+ }
31+ // A running landing on each output.
32+ r.input <- ports.PointerMotion{X: 100, Y: 300}
33+ scene(t, r.scenes)
34+ r.flick(t, 10, -40, 0)
35+ r.input <- ports.PointerMotion{X: 1000, Y: 300}
36+ scene(t, r.scenes)
37+ r.flick(t, 10, -40, 0)
38+ r.drain()
39+
40+ // The first flip of each output moves its columns.
41+ r.advance(32 * time.Millisecond)
42+ r.frames <- ports.OutputFrame{Output: wide.Name}
43+ set := receive(t, r.scenes)
44+ a, b := sceneFor(set, wide.Name), sceneFor(set, second.Name)
45+ r.advance(32 * time.Millisecond)
46+ r.frames <- ports.OutputFrame{Output: second.Name}
47+ set = receive(t, r.scenes)
48+ a2, b2 := sceneFor(set, wide.Name), sceneFor(set, second.Name)
49+ if a2.Seq != a.Seq || !a2.SameAs(a) {
50+ t.Fatalf("DP-1 changed on a flip of DP-2: Seq %d, was %d", a2.Seq, a.Seq)
51+ }
52+ if b2.Seq <= b.Seq || b2.SameAs(b) {
53+ t.Fatalf("DP-2 did not move on its own flip: Seq %d, was %d", b2.Seq, b.Seq)
54+ }
55+
56+ // And the other way round.
57+ r.advance(32 * time.Millisecond)
58+ r.frames <- ports.OutputFrame{Output: wide.Name}
59+ set = receive(t, r.scenes)
60+ a3, b3 := sceneFor(set, wide.Name), sceneFor(set, second.Name)
61+ if a3.Seq <= a2.Seq || a3.SameAs(a2) {
62+ t.Fatalf("DP-1 did not move on its own flip: Seq %d, was %d", a3.Seq, a2.Seq)
63+ }
64+ if b3.Seq != b2.Seq || !b3.SameAs(b2) {
65+ t.Fatalf("DP-2 changed on a flip of DP-1: Seq %d, was %d", b3.Seq, b2.Seq)
66+ }
67+}
A internal/core/frame_pulse_test.go +69 -0
@@ -0,0 +1,69 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// stepScenes steps only the screen and returns the scene of each output.
12+func stepScenes(t *testing.T, c *Core, only *screen) map[string]ports.Scene {
13+ t.Helper()
14+ if err := c.step(context.Background(), only); err != nil {
15+ t.Fatal(err)
16+ }
17+ out := map[string]ports.Scene{}
18+ for _, s := range <-c.ch.Scenes {
19+ out[s.Output] = s
20+ }
21+ return out
22+}
23+
24+// While the focus pulse runs on the focused output, a flip of another output
25+// keeps the focused output's Seq; its own flip samples the pulse.
26+func TestFlipOfOtherOutputKeepsPulseSeq(t *testing.T) {
27+ c, ic := pulseCore(t)
28+ a := c.cur()
29+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
30+ b := c.screens[1]
31+ for id := WindowID(3); id <= 5; id++ {
32+ b.mon.AddWindow(id)
33+ }
34+ indicatorScene(t, c)
35+
36+ // A pulse on window 1 of A.
37+ a.mon.Current().FocusID(1)
38+ indicatorScene(t, c)
39+ ic.now = ic.now.Add(pulseSettle)
40+ if !c.pulseTick() {
41+ t.Fatal("no pulse after settle")
42+ }
43+ // A camera transition runs on B.
44+ w := b.mon.Current()
45+ w.shift = 100
46+ w.motion = newMotion(viewSpring(w.shift, 0), ic.now, 1)
47+
48+ ic.now = ic.now.Add(pulseRise / 2)
49+ first := stepScenes(t, c, a)
50+ if pulseOf(first["A"], 1) <= 0 {
51+ t.Fatalf("no pulse on A: %v", pulseOf(first["A"], 1))
52+ }
53+ ic.now = ic.now.Add(10 * time.Millisecond)
54+ second := stepScenes(t, c, b)
55+ if second["A"].Seq != first["A"].Seq || !second["A"].SameAs(first["A"]) {
56+ t.Fatalf("A changed on a flip of B: Seq %d, was %d", second["A"].Seq, first["A"].Seq)
57+ }
58+ if second["B"].SameAs(first["B"]) {
59+ t.Fatal("B did not move on its own flip")
60+ }
61+ ic.now = ic.now.Add(10 * time.Millisecond)
62+ third := stepScenes(t, c, a)
63+ if third["A"].Seq <= second["A"].Seq || pulseOf(third["A"], 1) == pulseOf(second["A"], 1) {
64+ t.Fatalf("A did not sample the pulse on its own flip: Seq %d, was %d", third["A"].Seq, second["A"].Seq)
65+ }
66+ if third["B"].Seq != second["B"].Seq {
67+ t.Fatalf("B changed on a flip of A: Seq %d, was %d", third["B"].Seq, second["B"].Seq)
68+ }
69+}
M internal/core/free_core_test.go +21 -19
@@ -21,23 +21,25 @@ func windowIn(set []ports.Scene, output string, id ports.WindowID) (ports.SceneW
2121 }
2222
2323 func TestFreeFloatKeepsPlaceAcrossMonitors(t *testing.T) {
24- r := startMulti(t, func(c *ports.Config) { c.Layout.Gaps = 0 }, left, right)
25- r.mapWindow(t, 1)
26- r.mapWindow(t, 2)
27- r.key(t, "space", ports.ModAlt|ports.ModShift)
28- // Nudge left twice: 100 px left of centre on a 200 px output stops at
29- // the edge.
30- r.key(t, "Left", ports.ModAlt|ports.ModShift)
31- set := r.key(t, "Left", ports.ModAlt|ports.ModShift)
32- before, ok := windowIn(set, "DP-1", 2)
33- if !ok || !before.Floating || before.Rect.X != 0 {
34- t.Fatalf("%+v", before)
35- }
36- // The workspace moves to the 400x200 output: the centre keeps its
37- // fractions of the usable area (1/4, 1/2), the size stays.
38- set = r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
39- after, ok := windowIn(set, "DP-2", 2)
40- if !ok || after.Rect.W != before.Rect.W || after.Rect.X+after.Rect.W/2 != 100 || after.Rect.Y+after.Rect.H/2 != 100 {
41- t.Fatalf("%+v", after)
42- }
24+ both(t, func(t *testing.T, animated bool) {
25+ r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Gaps = 0 }, left, right)
26+ r.mapLanded(t, 1)
27+ r.mapLanded(t, 2)
28+ r.keyLanded(t, "space", ports.ModAlt|ports.ModShift)
29+ // Nudge left twice: 100 px left of centre on a 200 px output stops at
30+ // the edge.
31+ r.keyLanded(t, "Left", ports.ModAlt|ports.ModShift)
32+ set := r.keyLanded(t, "Left", ports.ModAlt|ports.ModShift)
33+ before, ok := windowIn(set, "DP-1", 2)
34+ if !ok || !before.Floating || before.Rect.X != 0 {
35+ t.Fatalf("%+v", before)
36+ }
37+ // The workspace moves to the 400x200 output: the centre keeps its
38+ // fractions of the usable area (1/4, 1/2), the size stays.
39+ set = r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
40+ after, ok := windowIn(set, "DP-2", 2)
41+ if !ok || after.Rect.W != before.Rect.W || after.Rect.X+after.Rect.W/2 != 100 || after.Rect.Y+after.Rect.H/2 != 100 {
42+ t.Fatalf("%+v", after)
43+ }
44+ })
4345 }
M internal/core/gesture.go +115 -22
@@ -36,7 +36,9 @@ const (
3636 swipeWorkspaces
3737 swipeDiscrete
3838 // swipeOverview is a vertical four-finger swipe: up opens the
39- // overview, down closes it on the selection (niri's gesture).
39+ // overview, down closes it on the selection (niri's gesture). With
40+ // the overview closed, down shows or hides the stash, and up hides
41+ // it when it is shown, else opens the overview.
4042 swipeOverview
4143 // swipeDropped ignores the rest of a swipe whose workspace changed.
4244 swipeDropped
@@ -166,12 +168,14 @@ func (c *Core) decide(g *swipeGesture) {
166168 case g.fingers == 4:
167169 // Four fingers sideways do nothing.
168170 g.mode = swipeDropped
169- case m.ov.open:
171+ case m.ov.open || !c.animOn():
170172 // The overview does not slide: the swipe moves its selection.
173+ // With animations off nothing slides either: the swipe runs a
174+ // focus action when the fingers lift.
171175 g.mode, g.snap = swipeDiscrete, newStepSwipe()
172176 case g.horizontal && w.slidable():
173177 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()
174- w.motion = nil
178+ w.motion = motion{}
175179 g.snap = newSnapSwipe(float64(w.ViewX)+w.shift, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W)))
176180 case !g.horizontal && m.shown == nil:
177181 // A landing slide measured in an older list lands at once first.
@@ -180,7 +184,7 @@ func (c *Core) decide(g *swipeGesture) {
180184 }
181185 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
182186 g.list = slices.Clone(m.Workspaces)
183- m.switchMotion, m.switchList = nil, nil
187+ m.switchMotion, m.switchList = motion{}, nil
184188 g.snap = newSnapSwipe(float64(m.Active)+m.switchOff, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)
185189 default:
186190 g.mode, g.snap = swipeDiscrete, newStepSwipe()
@@ -216,7 +220,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
216220 c.keyboard.takeBack()
217221 }
218222 w.shift = shown - float64(w.ViewX)
219- w.motion = newMotion(viewSpring(w.shift, velocity), now)
223+ if c.animOn() {
224+ w.motion = c.spring(viewSpring(w.shift, velocity), now)
225+ } else {
226+ w.stopSlide()
227+ }
220228 case swipeWorkspaces:
221229 if g.listChanged(m) {
222230 m.stopSwitch()
@@ -240,16 +248,28 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
240248 c.keyboard.takeBack()
241249 }
242250 m.switchOff, m.switchList = off, g.list
243- m.switchMotion = newMotion(workspaceSpring(off, velocity), now)
251+ if c.animOn() {
252+ m.switchMotion = c.spring(m.switchSpring(off, velocity), now)
253+ } else {
254+ m.stopSwitch()
255+ }
244256 case swipeOverview:
245257 step := g.snap.step(e.Cancelled, e.Time)
246- if step == 0 || (step < 0) == m.ov.open {
258+ if step == 0 {
259+ return false
260+ }
261+ if !m.ov.open && (step > 0 || m.Current().stashShown()) {
262+ return c.swipeStash(g, now)
263+ }
264+ if (step < 0) == m.ov.open {
247265 return false
248266 }
249267 before := m.Current()
268+ shots := c.snapshot(now)
250269 m.ToggleOverview()
251270 c.focusScreen = c.screenIndex(g.screen.name())
252271 c.keyboard.takeBack()
272+ c.transition(shots, now)
253273 return m.Current() != before
254274 case swipeDiscrete:
255275 step := g.snap.step(e.Cancelled, e.Time)
@@ -271,17 +291,36 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
271291 return false
272292 }
273293 before := c.cur().mon.Current()
294+ shots := c.snapshot(now)
274295 c.keyboard.takeBack()
275296 if mon := c.cur().mon; mon.ov.open {
276297 mon.overviewFocus(a)
277298 } else {
278299 c.applyAction(a)
279300 }
301+ c.transition(shots, now)
280302 return c.cur().mon.Current() != before
281303 }
282304 return shown
283305 }
284306
307+// swipeStash runs toggle-stash-visible for a four-finger swipe down, or up
308+// while the stash is shown, with the overview closed, on the swipe's screen
309+// unless the pointer took the focus to another output meanwhile. It reports
310+// whether the stash showed or hid: an empty stash stays as it is.
311+func (c *Core) swipeStash(g *swipeGesture, now time.Time) bool {
312+ if c.cur() != g.screen {
313+ return false
314+ }
315+ w := g.screen.mon.Current()
316+ hidden, over := w.stashHidden, w.stashOver
317+ shots := c.snapshot(now)
318+ c.keyboard.takeBack()
319+ c.applyAction(ActionToggleStashVisible)
320+ c.transition(shots, now)
321+ return w.stashHidden != hidden || w.stashOver != over
322+}
323+
285324 // swipedWorkspace is the workspace on the swipe's screen, or nil without
286325 // a swipe.
287326 func (c *Core) swipedWorkspace() *Workspace {
@@ -317,11 +356,49 @@ func (c *Core) hasScreen(s *screen) bool {
317356 return false
318357 }
319358
320-// animate moves the running springs to now; settled ones stop.
321-func (c *Core) animate(now time.Time) {
359+// animOn reports whether transitions run (animations = on).
360+func (c *Core) animOn() bool { return c.cfg.Animations.On }
361+
362+// spring starts s at now, stretched by animations.speed (Slowdown). Every motion
363+// core creates goes through it, except a retarget, which keeps the slowdown
364+// of the motion it replaces.
365+func (c *Core) spring(s spring, now time.Time) motion {
366+ return newMotion(s, now, c.cfg.Animations.Slowdown)
367+}
368+
369+// stopAnimations settles every running spring where it is going and stops
370+// the frame timer: a session lock or a switch to animations = off. A swipe
371+// in progress keeps its fingers' view: only running springs stop. Per-window
372+// rect motions stop too.
373+func (c *Core) stopAnimations() {
322374 for _, sc := range c.screens {
375+ sc.stopAnimations()
376+ }
377+ c.stopFrame()
378+}
379+
380+// stopAnimations settles the screen's running springs where they are going.
381+func (s *screen) stopAnimations() {
382+ if s.mon.switchMotion.on {
383+ s.mon.stopSwitch()
384+ }
385+ for w := range s.mon.all() {
386+ if w.motion.on {
387+ w.stopSlide()
388+ }
389+ }
390+ s.stopRects()
391+}
392+
393+// animate moves the running springs to now; settled ones stop. A non-nil only
394+// limits it to that screen (its own page flip); nil moves every screen.
395+func (c *Core) animate(now time.Time, only *screen) {
396+ for _, sc := range c.screens {
397+ if only != nil && sc != only {
398+ continue
399+ }
323400 m := sc.mon
324- if m.switchMotion != nil {
401+ if m.switchMotion.on {
325402 v, done := m.switchMotion.at(now)
326403 m.switchOff = v
327404 if done {
@@ -329,7 +406,7 @@ func (c *Core) animate(now time.Time) {
329406 }
330407 }
331408 for w := range m.all() {
332- if w.motion != nil {
409+ if w.motion.on {
333410 v, done := w.motion.at(now)
334411 w.shift = v
335412 if done {
@@ -337,13 +414,21 @@ func (c *Core) animate(now time.Time) {
337414 }
338415 }
339416 }
417+ for id, rm := range sc.rects {
418+ rm.advance(now)
419+ if rm.on() {
420+ sc.rects[id] = rm
421+ } else {
422+ delete(sc.rects, id)
423+ }
424+ }
340425 }
341426 }
342427
343428 // sliding reports whether a spring runs on the named output.
344429 func (c *Core) sliding(output string) bool {
345430 i := c.screenIndex(output)
346- return i >= 0 && (c.screens[i].mon.springing() || c.pulsing(c.screens[i]))
431+ return i >= 0 && (c.screens[i].springing() || c.pulsing(c.screens[i]))
347432 }
348433
349434 // animating reports whether a spring or the focus pulse runs on any output.
@@ -352,7 +437,7 @@ func (c *Core) animating() bool {
352437 return true
353438 }
354439 for _, sc := range c.screens {
355- if sc.mon.springing() {
440+ if sc.springing() {
356441 return true
357442 }
358443 }
@@ -360,11 +445,11 @@ func (c *Core) animating() bool {
360445 }
361446
362447 func (m *Monitor) springing() bool {
363- if m.switchMotion != nil {
448+ if m.switchMotion.on {
364449 return true
365450 }
366451 for w := range m.all() {
367- if w.motion != nil {
452+ if w.motion.on {
368453 return true
369454 }
370455 }
@@ -376,22 +461,30 @@ func (m *Monitor) springing() bool {
376461 func (c *Core) frameFallback() time.Duration {
377462 refresh := 60000
378463 for _, sc := range c.screens {
379- if r := sc.info.RefreshMilli; r > 0 && (sc.mon.springing() || c.pulsing(sc)) {
464+ if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.pulsing(sc)) {
380465 refresh = min(refresh, r)
381466 }
382467 }
383468 return 2 * time.Second * 1000 / time.Duration(refresh)
384469 }
385470
386-// armFrame starts the fallback timer of a running slide.
471+// armFrame starts the fallback timer of a running slide. The timer is made
472+// on the first arm and reset afterwards (Go 1.23 timers deliver nothing
473+// stale after a Stop or Reset), so a frame costs no allocation.
387474 func (c *Core) armFrame() {
388- c.stopFrame()
389- c.frameC, c.frameStop = newTimer(c.ch.Clock, c.frameFallback())
475+ d := c.frameFallback()
476+ if c.frameTimer == nil {
477+ c.frameTimer = newPortTimer(c.ch.Clock, d)
478+ } else {
479+ c.frameTimer.Stop()
480+ c.frameTimer.Reset(d)
481+ }
482+ c.frameC = c.frameTimer.C()
390483 }
391484
392485 func (c *Core) stopFrame() {
393- if c.frameStop != nil {
394- c.frameStop()
486+ if c.frameC != nil {
487+ c.frameTimer.Stop()
395488 }
396- c.frameC, c.frameStop = nil, nil
489+ c.frameC = nil
397490 }
M internal/core/input.go +17 -0
@@ -125,8 +125,11 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
125125 // A click focuses the window and its output.
126126 s, w := c.screenOf(id)
127127 if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) {
128+ now := c.now()
129+ before := c.snapshot(now)
128130 w.Click(id)
129131 c.focusScreen = c.screenIndex(s.name())
132+ c.transition(before, now)
130133 if err := c.publish(ctx); err != nil {
131134 return err
132135 }
@@ -140,7 +143,12 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
140143 c.scrollStop(v)
141144 // In the overview, scrolling moves the selection.
142145 if c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
146+ // overviewScroll owns which scrolls step; the shot costs no
147+ // allocation once warm.
148+ now := c.now()
149+ shots := c.snapshot(now)
143150 if c.cur().mon.overviewScroll(v) {
151+ c.transition(shots, now)
144152 if err := c.workspaceVisible(ctx, true); err != nil {
145153 return err
146154 }
@@ -189,8 +197,14 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
189197 // run binds, and are not forwarded.
190198 // A launcher or a menu holding the keyboard gets them first.
191199 if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() {
200+ now := c.now()
201+ var shots []viewShot
202+ if key.Pressed {
203+ shots = c.snapshot(now)
204+ }
192205 if key.Pressed && mon.overviewKey(key) {
193206 c.pressed[heldKey(key)] = true
207+ c.transition(shots, now)
194208 if err := c.workspaceVisible(ctx, true); err != nil {
195209 return err
196210 }
@@ -260,6 +274,8 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
260274 }
261275 before := c.cur().mon.Current()
262276 swiped := c.swipedWorkspace()
277+ now := c.now()
278+ shots := c.snapshot(now)
263279 c.keyboard.takeBack() // a bind acts on the windows
264280 effect := c.applyAction(action)
265281 if effect.Quit {
@@ -280,6 +296,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
280296 if c.swipedWorkspace() != swiped {
281297 c.dropSwipe()
282298 }
299+ c.transition(shots, now)
283300 if err := c.workspaceVisible(ctx, c.cur().mon.Current() != before); err != nil {
284301 return err
285302 }
M internal/core/layout.go +112 -23
@@ -122,6 +122,18 @@ type Placement struct {
122122 // Inset reserves room for drawn lines: all sides of a float; for
123123 // tiles without gaps, only the right and bottom shared sides.
124124 Neighbors, Inset ports.Sides
125+ // Leaving marks a window that closed or hid but is still drawn while
126+ // it fades out (transition.go). It stays Hidden, so input, focus,
127+ // popups and configures ignore it; only the scene shows it.
128+ Leaving bool
129+ // Fade, Dim and Zoom are set on shown layouts only (refreshShown),
130+ // animated: the window's fade (0 opaque, 1 invisible), a veil opacity
131+ // added to the one Peek draws (0..1), and the content zoom as drawn
132+ // when a scale motion runs (Preview times the drawn/settled width, 1
133+ // when the content is drawn at its size; 0 without one). Preview
134+ // itself stays the settled one: input, popups and the renderer's paint
135+ // order read it to tell a card from a window.
136+ Fade, Dim, Zoom float64
125137 }
126138
127139 // Overflow says what happens past MaxColumns columns.
@@ -154,7 +166,7 @@ type Workspace struct {
154166 // shift slides the columns on screen past ViewX, in logical pixels,
155167 // while a swipe follows the fingers or its spring (motion) lands.
156168 shift float64
157- motion *motion
169+ motion motion
158170 // Output is the effective viewport in monitor coordinates: the whole
159171 // monitor unless the workspace has a size override, then a centered
160172 // rectangle no larger than the monitor. Fullscreen fills it.
@@ -204,6 +216,14 @@ type Workspace struct {
204216 homePos int
205217 // termAt is when core last spawned a terminal for this workspace.
206218 termAt time.Time
219+ // colBuf, rowBuf and tileBuf are appendLayout's scratch (column rects,
220+ // the rows of one column, the tiles before the floats are merged in),
221+ // reused across calls and never handed out: owner goroutine only, and
222+ // appendLayout does not nest on one workspace.
223+ colBuf, rowBuf []Rect
224+ tileBuf []Placement
225+ // zoomBuf is overviewZoom's scratch for the tiles it measures.
226+ zoomBuf []Placement
207227 }
208228
209229 // origPlace remembers where a stashed window was: its column, its row in a
@@ -242,6 +262,35 @@ func (w *Workspace) empty() bool {
242262 return len(w.Columns) == 0 && len(w.Floats) == 0 && len(w.Stash) == 0
243263 }
244264
265+// columnWindows counts the windows in the columns.
266+func (w *Workspace) columnWindows() int {
267+ n := 0
268+ for _, c := range w.Columns {
269+ n += len(c.Windows)
270+ }
271+ return n
272+}
273+
274+// appendHidden appends every window of w as a Hidden placement, in the order
275+// of windows (columns, stash, then floats when floats is set), without
276+// building the list.
277+func (w *Workspace) appendHidden(dst []Placement, floats bool) []Placement {
278+ for _, c := range w.Columns {
279+ for _, id := range c.Windows {
280+ dst = append(dst, Placement{ID: id, Hidden: true})
281+ }
282+ }
283+ for _, f := range w.Stash {
284+ dst = append(dst, Placement{ID: f.ID, Hidden: true})
285+ }
286+ if floats {
287+ for _, f := range w.Floats {
288+ dst = append(dst, Placement{ID: f.ID, Hidden: true})
289+ }
290+ }
291+ return dst
292+}
293+
245294 // windows lists every window: columns first, then the stash, then native
246295 // floats.
247296 func (w *Workspace) windows() []WindowID {
@@ -1349,15 +1398,22 @@ func (w *Workspace) scroll() {
13491398
13501399 // columnRects returns each column's area on screen, before stacking windows.
13511400 func (w *Workspace) columnRects() []Rect {
1352- return w.columnRectsFor(false)
1401+ return w.columnRectsInto(nil, false)
13531402 }
13541403
13551404 // columnRectsFor shares the fixed layout geometry with the overview's hidden
13561405 // columns card, without changing FullWidth or the clients' saved buffers.
13571406 func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
1407+ return w.columnRectsInto(nil, ignoreFullWidth)
1408+}
1409+
1410+// columnRectsInto is columnRectsFor built in dst's storage (dst[:0] grown
1411+// when too small): the result aliases dst, so a caller passing a reused
1412+// buffer must be done with the previous result.
1413+func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
13581414 g := w.gap()
13591415 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
1360- rects := make([]Rect, len(w.Columns))
1416+ rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
13611417 view := w.ViewX + w.shiftPixels()
13621418 x := w.Usable.X + g
13631419 for i := range w.Columns {
@@ -1371,7 +1427,8 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
13711427 }
13721428 k := max(w.MaxColumns, 1)
13731429 if e := slices.IndexFunc(w.Columns, func(c Column) bool { return c.Expanded }); w.Overflow == OverflowFixed && e >= 0 && k > 1 && len(w.Columns) > 1 {
1374- return w.expandedRects(e, k, y, h)
1430+ // rects is dead here: the expanded rects reuse its storage.
1431+ return w.expandedRects(rects[:0], e, k, y, h)
13751432 }
13761433 if w.Overflow != OverflowFixed || len(w.Columns) <= k {
13771434 return rects
@@ -1388,7 +1445,7 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
13881445 // expandedRects places expanded column e over k-1 cells in its place. The
13891446 // columns before it stack in a strip on its left, those after it on its
13901447 // right; the two strips share the last cell.
1391-func (w *Workspace) expandedRects(e, k, y, h int) []Rect {
1448+func (w *Workspace) expandedRects(rects []Rect, e, k, y, h int) []Rect {
13921449 g := w.gap()
13931450 cell := max((w.Usable.W-g*(k+1))/k, 0)
13941451 wide := (k-1)*cell + (k-2)*g
@@ -1399,15 +1456,14 @@ func (w *Workspace) expandedRects(e, k, y, h int) []Rect {
13991456 leftW = max((rest-g)/2, 0)
14001457 rightW = max(rest-g-leftW, 0)
14011458 }
1402- rects := make([]Rect, 0, len(w.Columns))
14031459 x := w.Usable.X + g
14041460 if len(before) > 0 {
1405- rects = append(rects, stackRects(Rect{X: x, Y: y, W: leftW, H: h}, len(before), g)...)
1461+ rects = stackRectsInto(rects, Rect{X: x, Y: y, W: leftW, H: h}, len(before), g)
14061462 x += leftW + g
14071463 }
14081464 rects = append(rects, Rect{X: x, Y: y, W: wide, H: h})
14091465 if len(after) > 0 {
1410- rects = append(rects, stackRects(Rect{X: x + wide + g, Y: y, W: rightW, H: h}, len(after), g)...)
1466+ rects = stackRectsInto(rects, Rect{X: x + wide + g, Y: y, W: rightW, H: h}, len(after), g)
14111467 }
14121468 // Gaps wider than a narrow output would push columns past its edge.
14131469 right := w.Usable.X + w.Usable.W
@@ -1422,7 +1478,15 @@ func (w *Workspace) expandedRects(e, k, y, h int) []Rect {
14221478 // the rounding remainder. Rows past the bottom (gaps taller than r) are
14231479 // clamped to it, like windows stacked in a column.
14241480 func stackRects(r Rect, n, gap int) []Rect {
1425- rows := make([]Rect, n)
1481+ return stackRectsInto(nil, r, n, gap)
1482+}
1483+
1484+// stackRectsInto appends the n rows of stackRects to dst (reset with dst[:0]
1485+// to reuse its storage).
1486+func stackRectsInto(dst []Rect, r Rect, n, gap int) []Rect {
1487+ base := len(dst)
1488+ dst = slices.Grow(dst, n)[:base+n]
1489+ rows := dst[base:]
14261490 avail := max(r.H-(n-1)*gap, 0)
14271491 height := avail / n
14281492 yy, bottom := r.Y, r.Y+r.H
@@ -1436,17 +1500,25 @@ func stackRects(r Rect, n, gap int) []Rect {
14361500 rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}
14371501 yy += hh + gap
14381502 }
1439- return rows
1503+ return dst
14401504 }
14411505
14421506 // rowRects splits column rect r into the rows of c: by c.Shares when they
14431507 // are valid, else equally (stackRects).
14441508 func rowRects(r Rect, c Column, gap int) []Rect {
1509+ return rowRectsInto(nil, r, c, gap)
1510+}
1511+
1512+// rowRectsInto appends the rows of rowRects to dst (reset with dst[:0] to
1513+// reuse its storage).
1514+func rowRectsInto(dst []Rect, r Rect, c Column, gap int) []Rect {
14451515 n := len(c.Windows)
14461516 if !validShares(c) {
1447- return stackRects(r, n, gap)
1517+ return stackRectsInto(dst, r, n, gap)
14481518 }
1449- rows := make([]Rect, n)
1519+ base := len(dst)
1520+ dst = slices.Grow(dst, n)[:base+n]
1521+ rows := dst[base:]
14501522 avail := max(r.H-(n-1)*gap, 0)
14511523 yy, bottom, used := r.Y, r.Y+r.H, 0
14521524 for i, share := range c.Shares {
@@ -1460,7 +1532,7 @@ func rowRects(r Rect, c Column, gap int) []Rect {
14601532 rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}
14611533 yy += hh + gap
14621534 }
1463- return rows
1535+ return dst
14641536 }
14651537
14661538 // validShares reports whether c.Shares has one share per window, each at
@@ -1489,12 +1561,27 @@ func split(r Rect, gap int, vertical bool) (Rect, Rect) {
14891561 return Rect{X: r.X, Y: r.Y, W: w, H: r.H}, Rect{X: r.X + w + gap, Y: r.Y, W: max(r.W-gap-w, 0), H: r.H}
14901562 }
14911563
1492-func (w *Workspace) Layout() []Placement {
1493- var result []Placement
1564+// Layout places every window of the workspace in a fresh slice the caller
1565+// owns. The per-frame paths use layoutInto instead.
1566+func (w *Workspace) Layout() []Placement { return w.appendLayout(nil) }
1567+
1568+// layoutInto is Layout built in dst[:0]: the result aliases dst's storage
1569+// (a reused buffer's previous result is overwritten), so only a caller that
1570+// is done with the previous one may pass it. It never returns the
1571+// workspace's own scratch.
1572+func (w *Workspace) layoutInto(dst []Placement) []Placement { return w.appendLayout(dst[:0]) }
1573+
1574+// appendLayout appends the workspace's placements to dst.
1575+func (w *Workspace) appendLayout(dst []Placement) []Placement {
14941576 gap := w.gap()
1495- cols := w.columnRects()
1577+ // The column and row rects live in the workspace's scratch: each is dead
1578+ // once the placements it feeds are built (owner goroutine only, and
1579+ // layoutInto does not nest on a workspace).
1580+ w.colBuf = w.columnRectsInto(w.colBuf, false)
1581+ cols := w.colBuf
14961582 focusedID, _ := w.Focused()
14971583 cover := w.cover()
1584+ tiles := w.tileBuf[:0]
14981585 for i, c := range w.Columns {
14991586 col := cols[i]
15001587 n := len(c.Windows)
@@ -1505,7 +1592,8 @@ func (w *Workspace) Layout() []Placement {
15051592 if fullColumn {
15061593 col.W = w.Output.W
15071594 }
1508- rows := rowRects(col, c, gap)
1595+ w.rowBuf = rowRectsInto(w.rowBuf[:0], col, c, gap)
1596+ rows := w.rowBuf
15091597 for j, id := range c.Windows {
15101598 r := rows[j]
15111599 full := w.fullscreen == id && id != 0
@@ -1523,14 +1611,15 @@ func (w *Workspace) Layout() []Placement {
15231611 if hidden {
15241612 r = Rect{}
15251613 }
1526- result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden})
1614+ tiles = append(tiles, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden})
15271615 }
15281616 }
1529- setVisibleNeighbors(result, gap, w.Output)
1617+ setVisibleNeighbors(tiles, gap, w.Output)
1618+ // tiles lives in the workspace's scratch until they are copied below.
1619+ w.tileBuf = tiles
15301620 // The column/stash group sits between demoted covering floats and
15311621 // floats above it. Keep the order within each group stable.
1532- tiles := result
1533- result = nil
1622+ result := dst
15341623 const (
15351624 belowTiles = iota
15361625 coveringFloats
@@ -1570,9 +1659,9 @@ func (w *Workspace) Layout() []Placement {
15701659 // The stash the user showed over a covering window tops it, under
15711660 // the dialogs it may open meanwhile.
15721661 appendFloats(coveringFloats)
1573- result = append(result, w.stashLayout(focusedID, cover)...)
1662+ result = w.appendStash(result, focusedID, cover)
15741663 } else {
1575- result = append(result, w.stashLayout(focusedID, cover)...)
1664+ result = w.appendStash(result, focusedID, cover)
15761665 appendFloats(coveringFloats)
15771666 }
15781667 appendFloats(dialogs)
A internal/core/layout_buffers_test.go +377 -0
@@ -0,0 +1,377 @@
1+package core
2+
3+import (
4+ "maps"
5+ "reflect"
6+ "slices"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// layoutVariants builds monitors covering the layout paths that use scratch:
14+// scroll and fixed overflow, an expanded column, row shares, floats, a stash,
15+// a fullscreen window, a hidden neighbor workspace, the overview and a slide
16+// toward a neighbor workspace.
17+func layoutVariants(t *testing.T) map[string]*Monitor {
18+ t.Helper()
19+ base := func(o Overflow, n int) *Monitor {
20+ m := newMonitor("", "")
21+ m.SetOutput(300, 200)
22+ m.SetOverflow(o)
23+ m.SetGaps(5)
24+ m.SetMaxColumns(3)
25+ for id := WindowID(1); id <= WindowID(n); id++ {
26+ m.AddWindow(id)
27+ }
28+ return m
29+ }
30+ v := map[string]*Monitor{}
31+ v["scroll"] = base(OverflowScroll, 5)
32+ v["fixed"] = base(OverflowFixed, 5)
33+ v["fixed-spiral"] = base(OverflowFixed, 7)
34+
35+ m := base(OverflowFixed, 4)
36+ m.Current().Columns[1].Expanded = true
37+ v["fixed-expanded"] = m
38+
39+ m = base(OverflowScroll, 4)
40+ w := m.Current()
41+ w.Columns[0].Windows = []WindowID{1, 5, 6}
42+ w.Columns[0].Shares = []int{50, 30, 20}
43+ m.AddWindow(5)
44+ v["shares"] = m
45+
46+ m = base(OverflowScroll, 4)
47+ m.AddFloating(10, 100, 80)
48+ m.AddFloating(11, 60, 40)
49+ v["floats"] = m
50+
51+ m = base(OverflowScroll, 4)
52+ m.Current().Apply(ActionToggleWindowStash)
53+ v["stash"] = m
54+
55+ m = base(OverflowScroll, 4)
56+ m.Current().SetFullscreen(2, true)
57+ v["fullscreen"] = m
58+
59+ m = base(OverflowScroll, 3)
60+ m.Apply(ActionFocusWorkspaceDown)
61+ m.AddWindow(20)
62+ m.AddWindow(21)
63+ m.Apply(ActionFocusWorkspaceUp)
64+ v["two-workspaces"] = m
65+
66+ m = base(OverflowScroll, 3)
67+ m.AddFloating(10, 100, 80)
68+ m.ToggleOverview()
69+ v["overview"] = m
70+
71+ m = base(OverflowScroll, 3)
72+ m.Apply(ActionFocusWorkspaceDown)
73+ m.AddWindow(20)
74+ m.Apply(ActionFocusWorkspaceUp)
75+ m.switchOff = 0.4
76+ v["slide"] = m
77+
78+ // The variants must hold what they are named for, on the workspace on
79+ // screen (the one whose scratch the tests exercise).
80+ for name, has := range map[string]func(*Workspace) bool{
81+ "stash": func(w *Workspace) bool { return len(w.Stash) > 0 && len(w.Columns) > 0 },
82+ "floats": func(w *Workspace) bool { return len(w.Floats) == 2 && len(w.Columns) > 0 },
83+ "shares": func(w *Workspace) bool { return validShares(w.Columns[0]) },
84+ "fixed-expanded": func(w *Workspace) bool { return w.Columns[1].Expanded },
85+ "fullscreen": func(w *Workspace) bool { return w.fullscreen == 2 },
86+ "scroll": func(w *Workspace) bool { return len(w.Columns) == 5 },
87+ } {
88+ if w := v[name].Current(); len(w.Columns) == 0 || !has(w) {
89+ t.Fatalf("variant %s: the workspace on screen lacks its feature: %+v", name, w.Columns)
90+ }
91+ }
92+ return v
93+}
94+
95+// TestLayoutIntoMatchesLayout: the buffer-reusing API gives the same
96+// placements as the fresh one, whatever the buffer held before (a larger
97+// stale layout, another monitor's), and repeatedly.
98+func TestLayoutIntoMatchesLayout(t *testing.T) {
99+ variants := layoutVariants(t)
100+ var buf []Placement
101+ for _, name := range slices.Sorted(maps.Keys(variants)) {
102+ m := variants[name]
103+ want := m.Layout()
104+ if len(want) == 0 {
105+ t.Fatalf("%s: empty layout", name)
106+ }
107+ for range 3 {
108+ // Poison the reused buffer: stale entries must not leak.
109+ for i := range buf[:cap(buf)] {
110+ buf[:cap(buf)][i] = Placement{ID: 999, Rect: Rect{X: 1, Y: 1, W: 1, H: 1}, Focused: true, Preview: 3}
111+ }
112+ buf = m.layoutInto(buf)
113+ if !slices.Equal(buf, want) {
114+ t.Fatalf("%s: layoutInto differs\n got %+v\nwant %+v", name, buf, want)
115+ }
116+ if again := m.Layout(); !slices.Equal(again, want) {
117+ t.Fatalf("%s: Layout changed after layoutInto\n got %+v\nwant %+v", name, again, want)
118+ }
119+ }
120+ for w := range m.all() {
121+ ws := w.Layout()
122+ if got := w.layoutInto(buf); !slices.Equal(got, ws) {
123+ t.Fatalf("%s: workspace layoutInto differs", name)
124+ }
125+ }
126+ }
127+}
128+
129+// TestLayoutResultsDoNotAlias: Layout() returns a slice the caller owns. A
130+// retained result stays unchanged after further layouts of the same monitor
131+// or workspace (nested or later, reused-buffer calls included), and two
132+// results never share storage.
133+func TestLayoutResultsDoNotAlias(t *testing.T) {
134+ for name, m := range layoutVariants(t) {
135+ first := m.Layout()
136+ kept := slices.Clone(first)
137+ wfirst := m.Current().Layout()
138+ wkept := slices.Clone(wfirst)
139+
140+ second := m.Layout()
141+ if &first[0] == &second[0] {
142+ t.Fatalf("%s: two Layout() calls share storage", name)
143+ }
144+ var buf []Placement
145+ for range 2 {
146+ buf = m.layoutInto(buf)
147+ buf = m.Current().layoutInto(buf)
148+ m.Current().previewTiles()
149+ m.Current().overviewZoom()
150+ }
151+ // layoutInto builds in the caller's buffer, never in a scratch of
152+ // the workspace: with the capacity there it returns that storage.
153+ w := m.Current()
154+ big := make([]Placement, 0, 2*len(buf)+8)
155+ res := w.layoutInto(big)
156+ if len(res) == 0 || &res[0] != &big[:1][0] {
157+ t.Fatalf("%s: Workspace.layoutInto did not build in the caller's buffer", name)
158+ }
159+ if res = m.layoutInto(big); len(res) == 0 || &res[0] != &big[:1][0] {
160+ t.Fatalf("%s: Monitor.layoutInto did not build in the caller's buffer", name)
161+ }
162+ // A result kept across the calls that use the workspace scratch
163+ // (zoom tiles, preview tiles, columns, rows, a second layout) is
164+ // unchanged.
165+ mine := w.layoutInto(make([]Placement, 0, len(res)))
166+ mineKept := slices.Clone(mine)
167+ w.overviewZoom()
168+ w.peekStep()
169+ w.previewTiles()
170+ w.columnRects()
171+ _ = w.Layout()
172+ _ = w.layoutInto(nil)
173+ _ = m.Layout()
174+ if !slices.Equal(mine, mineKept) {
175+ t.Fatalf("%s: a kept layoutInto result changed across scratch-using calls\n got %+v\nwant %+v", name, mine, mineKept)
176+ }
177+ // Change the layout itself, then lay out again.
178+ if m.ov.open {
179+ m.OverviewMove(1, 0)
180+ } else {
181+ m.Current().Apply(ActionCycleColumnWidth)
182+ }
183+ _ = m.Layout()
184+ _ = m.Current().Layout()
185+ m.switchOff = 0.3
186+ _ = m.Layout()
187+ m.switchOff = 0
188+
189+ if !slices.Equal(first, kept) {
190+ t.Fatalf("%s: a retained Monitor.Layout() changed\n got %+v\nwant %+v", name, first, kept)
191+ }
192+ if !slices.Equal(wfirst, wkept) {
193+ t.Fatalf("%s: a retained Workspace.Layout() changed", name)
194+ }
195+ }
196+}
197+
198+// TestRectHelpersKeepAppendSemantics: the Into helpers append after dst's
199+// length and give the same rows as the fresh ones.
200+func TestRectHelpersKeepAppendSemantics(t *testing.T) {
201+ r := Rect{X: 3, Y: 4, W: 50, H: 103}
202+ pre := []Rect{{W: 1}, {W: 2}}
203+ got := stackRectsInto(slices.Clone(pre), r, 3, 5)
204+ if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], stackRects(r, 3, 5)) {
205+ t.Fatalf("stackRectsInto: %+v", got)
206+ }
207+ c := Column{Windows: []WindowID{1, 2, 3}, Shares: []int{50, 30, 20}}
208+ got = rowRectsInto(slices.Clone(pre), r, c, 5)
209+ if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], rowRects(r, c, 5)) {
210+ t.Fatalf("rowRectsInto: %+v", got)
211+ }
212+ // A reused destination overwrites in place.
213+ buf := make([]Rect, 0, 8)
214+ a := stackRectsInto(buf, r, 3, 5)
215+ b := stackRectsInto(buf, r, 2, 5)
216+ if &a[0] != &b[0] || len(b) != 2 {
217+ t.Fatalf("not reused: %d %d", len(a), len(b))
218+ }
219+}
220+
221+// cloneScene copies every slice a scene carries.
222+func cloneScene(s ports.Scene) ports.Scene {
223+ s.Windows = slices.Clone(s.Windows)
224+ s.Separators = slices.Clone(s.Separators)
225+ s.DropHints = slices.Clone(s.DropHints)
226+ s.Layers = slices.Clone(s.Layers)
227+ s.CaptureIndicators = slices.Clone(s.CaptureIndicators)
228+ return s
229+}
230+
231+// TestPublishedScenesAreNeverMutated: scenes cross to another goroutine and
232+// are immutable, so none may alias the layout scratch that the next publish
233+// rebuilds. Frames run with camera and rect motions, then with the overview
234+// opening; every earlier scene is compared with its copy at the end. Under
235+// -race it also catches a later publish writing what a sent scene reads.
236+func TestPublishedScenesAreNeverMutated(t *testing.T) {
237+ type kept struct{ sent, copy ports.Scene }
238+ var all []kept
239+ // fresh is every screen's layout as a fresh Layout() gives it, taken
240+ // before a step: the scene it publishes lists the same windows in the
241+ // same order, and, where nothing moves (no rect motion, no camera), the
242+ // same rects. A layout scratch shared with the real layouts the overview
243+ // reads would show here.
244+ type expect struct {
245+ out string
246+ layout []Placement
247+ still bool
248+ }
249+ fresh := func(c *Core) []expect {
250+ var e []expect
251+ for _, sc := range c.screens {
252+ if sc.name() != "" {
253+ e = append(e, expect{sc.name(), sc.mon.Layout(), len(sc.rects) == 0 && !sc.mon.Current().motion.on})
254+ }
255+ }
256+ return e
257+ }
258+ check := func(set []ports.Scene, want []expect, frame int) {
259+ t.Helper()
260+ if len(set) != len(want) {
261+ t.Fatalf("frame %d: %d scenes, %d screens", frame, len(set), len(want))
262+ }
263+ for i, s := range set {
264+ e := want[i]
265+ if s.Output != e.out || len(s.Windows) != len(e.layout) {
266+ t.Fatalf("frame %d: scene %s has %d windows, layout %d", frame, s.Output, len(s.Windows), len(e.layout))
267+ }
268+ for k, p := range e.layout {
269+ w := s.Windows[k]
270+ if w.ID != p.ID || w.Hidden != p.Hidden || (e.still && w.Rect != p.Rect) {
271+ t.Fatalf("frame %d: scene %s window %d is %+v, the layout has %+v", frame, s.Output, k, w, p)
272+ }
273+ }
274+ }
275+ }
276+ frame := 0
277+ step := func(c *Core) {
278+ t.Helper()
279+ want := fresh(c)
280+ if err := c.step(t.Context(), nil); err != nil {
281+ t.Fatal(err)
282+ }
283+ select {
284+ case set := <-c.ch.Scenes:
285+ check(set, want, frame)
286+ frame++
287+ for _, s := range set {
288+ all = append(all, kept{s, cloneScene(s)})
289+ }
290+ default:
291+ t.Fatal("no scene published")
292+ }
293+ }
294+
295+ c := publishRig(t, 2)
296+ t0 := time.Now()
297+ for _, sc := range c.screens {
298+ if sc.name() == "" {
299+ continue
300+ }
301+ ws := sc.mon.Current()
302+ ws.motion = c.spring(viewSpring(100, 0), t0)
303+ ws.shift = 100
304+ startRectMotions(c, sc, t0)
305+ }
306+ for range 12 {
307+ step(c)
308+ }
309+
310+ // The overview opening: card motions, the real layouts, separators.
311+ o := publishRig(t, 1)
312+ now := time.Now()
313+ shots := o.snapshot(now)
314+ o.cur().mon.ToggleOverview()
315+ o.transition(shots, now)
316+ for range 12 {
317+ step(o)
318+ }
319+ // The overview settled (no card motion): its scenes list the previews
320+ // exactly as the monitor lays them out, frame after frame.
321+ o.cur().stopRects()
322+ still := 0
323+ for range 3 {
324+ if w := fresh(o); !w[0].still {
325+ t.Fatal("setup: the overview still moves")
326+ }
327+ step(o)
328+ still++
329+ }
330+ if !o.cur().mon.ov.open || still == 0 {
331+ t.Fatal("setup: overview closed")
332+ }
333+
334+ if len(all) < 39 {
335+ t.Fatalf("only %d scenes", len(all))
336+ }
337+ changed := 0
338+ for i := 1; i < len(all); i++ {
339+ if !reflect.DeepEqual(all[i].sent, all[i-1].sent) {
340+ changed++
341+ }
342+ }
343+ if changed == 0 {
344+ t.Fatal("frames never differ: the test would catch nothing")
345+ }
346+ for i, k := range all {
347+ if !reflect.DeepEqual(k.sent, k.copy) {
348+ t.Fatalf("scene %d (%s) was mutated after it was sent", i, k.sent.Output)
349+ }
350+ }
351+}
352+
353+// TestSettledLayoutEmptyFallsBackToLive: an empty layout is nil (as a fresh
354+// Layout() was), so a screen with nothing published yet measures the live
355+// layout: a window added after a publish of an empty monitor is listed by
356+// shownLayout without another publish.
357+func TestSettledLayoutEmptyFallsBackToLive(t *testing.T) {
358+ c := publishRig(t, 1)
359+ sc := c.cur()
360+ for _, p := range slices.Clone(sc.settledLayout) {
361+ sc.mon.RemoveWindow(p.ID)
362+ }
363+ if err := c.publish(t.Context()); err != nil {
364+ t.Fatal(err)
365+ }
366+ if sc.settledLayout != nil || sc.shown != nil {
367+ t.Fatalf("empty layout kept: settled %v shown %v (cap %d)", sc.settledLayout, sc.shown, cap(sc.layoutBuf))
368+ }
369+ if got := sc.shownLayout(); len(got) != 0 {
370+ t.Fatalf("empty monitor lists %+v", got)
371+ }
372+ sc.mon.AddWindow(77)
373+ got := sc.shownLayout()
374+ if len(got) != 1 || got[0].ID != 77 || got[0].Hidden {
375+ t.Fatalf("the window added after the publish is not listed: %+v", got)
376+ }
377+}
A internal/core/map_animation_test.go +112 -0
@@ -0,0 +1,112 @@
1+package core_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// A window that maps through the owner loop appears: the scene right after
11+// the map shows it invisible at 90 %, a frame later it is in between, and it
12+// settles exactly; its neighbour slides over. The client is configured once
13+// per window. With animations off the first scene is the final one.
14+func TestMapAnimates(t *testing.T) {
15+ r := startSwipe(t, nil)
16+ r.mapWindow(t, 1)
17+ for len(r.commands) > 0 {
18+ <-r.commands
19+ }
20+ first := r.multiRig.mapWindow(t, 2)[0]
21+ w := windowOf(t, first, 2)
22+ if w.Fade != 1 || w.Hidden {
23+ t.Fatalf("new window right after the map: %+v", w)
24+ }
25+ if got := rectNow(t, first, 1); got.W != 800 {
26+ t.Fatalf("neighbour right after the map is %d wide, want its old 800", got.W)
27+ }
28+ mid := r.frame(t, 30*time.Millisecond)
29+ if m := windowOf(t, mid, 2); !(m.Fade > 0 && m.Fade < 1 && m.Rect.W > w.Rect.W && m.Rect.W < 400) {
30+ t.Fatalf("new window on the first frame: %+v (was %+v)", m, w)
31+ }
32+ if got := rectNow(t, mid, 1).W; got <= 400 || got >= 800 {
33+ t.Fatalf("neighbour on the first frame is %d wide, want between 400 and 800", got)
34+ }
35+ end := r.settle(t)
36+ if m := windowOf(t, end, 2); m.Fade != 0 || m.Zoom != 0 || m.Rect.W != 400 {
37+ t.Fatalf("settled new window: %+v", m)
38+ }
39+ if got := rectNow(t, end, 1).W; got != 400 {
40+ t.Fatalf("settled neighbour is %d wide", got)
41+ }
42+ configures := map[ports.WindowID]int{}
43+ for len(r.commands) > 0 {
44+ if v, ok := (<-r.commands).(ports.ConfigureWindow); ok {
45+ configures[v.ID]++
46+ if v.Width != 400 {
47+ t.Fatalf("configure to %d wide, want the final 400: %+v", v.Width, v)
48+ }
49+ }
50+ }
51+ if configures[2] != 1 || configures[1] != 1 {
52+ t.Fatalf("configures %v, want one each", configures)
53+ }
54+}
55+
56+// With animations off a map shows the final layout at once.
57+func TestMapAnimationsOffIsInstant(t *testing.T) {
58+ r := startSwipe(t, animationsOff)
59+ r.mapWindow(t, 1)
60+ s := r.multiRig.mapWindow(t, 2)[0]
61+ if w := windowOf(t, s, 2); w.Fade != 0 || w.Zoom != 0 || w.Rect.W != 400 {
62+ t.Fatalf("new window: %+v", w)
63+ }
64+ if got := rectNow(t, s, 1).W; got != 400 {
65+ t.Fatalf("neighbour is %d wide, want 400", got)
66+ }
67+ r.noFrameScene(t)
68+}
69+
70+// A window that goes fullscreen right after its map drops its entrance.
71+func TestMapFullscreenRequestDropsEntrance(t *testing.T) {
72+ r := startSwipe(t, nil)
73+ s := r.multiRig.mapWindow(t, 1)[0]
74+ if windowOf(t, s, 1).Fade != 1 {
75+ t.Fatal("no entrance to drop")
76+ }
77+ r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
78+ s = scene(t, r.scenes)
79+ if w := windowOf(t, s, 1); w.Fade != 0 || w.Zoom != 0 || !w.Fullscreen {
80+ t.Fatalf("fullscreen window still fading: %+v", w)
81+ }
82+}
83+
84+// A fullscreen request inside the grace after the map is ignored (Wine at a
85+// remembered size): the entrance goes on.
86+func TestMapFullscreenRequestWithinGraceKeepsEntrance(t *testing.T) {
87+ r := startSwipe(t, nil)
88+ s := r.multiRig.mapWindow(t, 1)[0]
89+ if windowOf(t, s, 1).Fade != 1 {
90+ t.Fatal("no entrance to keep")
91+ }
92+ r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
93+ // The flip is a barrier: the request is handled, a leftover scene dropped.
94+ set, ok := r.clk.flip(t, r.frames, r.scenes, 30*time.Millisecond, wide.Name)
95+ if !ok {
96+ t.Fatal("no frame: the entrance stopped")
97+ }
98+ if w := windowOf(t, set[0], 1); w.Fullscreen || !(w.Fade > 0 && w.Fade < 1) {
99+ t.Fatalf("ignored fullscreen request changed the window: %+v", w)
100+ }
101+}
102+
103+func windowOf(t *testing.T, s ports.Scene, id ports.WindowID) ports.SceneWindow {
104+ t.Helper()
105+ for _, w := range s.Windows {
106+ if w.ID == id {
107+ return w
108+ }
109+ }
110+ t.Fatalf("window %d not in %+v", id, s.Windows)
111+ return ports.SceneWindow{}
112+}
M internal/core/monitor.go +17 -8
@@ -24,6 +24,10 @@ type Monitor struct {
2424 shown *Workspace // hidden workspace on screen, nil for Workspaces[Active]
2525 back *Workspace // where a named workspace toggle returns to
2626 template Workspace
27+ // slideBuf and slideShown are slideLayout's scratch (the neighbor's
28+ // layout and its placements by window), reused and never handed out.
29+ slideBuf []Placement
30+ slideShown map[WindowID]Placement
2731 nextID *uint64 // shared by the monitors of one Core; owner goroutine only
2832 named []NamedWorkspace
2933 // followMove shows the target workspace after a move to it.
@@ -34,7 +38,7 @@ type Monitor struct {
3438 // at Active+switchOff while a swipe follows the fingers or its spring
3539 // (switchMotion) lands.
3640 switchOff float64
37- switchMotion *motion
41+ switchMotion motion
3842 // switchList is the numbered list a landing slide measures from (the
3943 // one its swipe began on); switchOff is then from the current
4044 // workspace's place in it.
@@ -157,6 +161,7 @@ func (m *Monitor) Current() *Workspace {
157161
158162 func (m *Monitor) newWorkspace() *Workspace {
159163 w := m.template
164+ w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil
160165 w.presets = append([]Width(nil), m.template.presets...)
161166 w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""
162167 w.overviewAfter = nil
@@ -408,20 +413,24 @@ func (m *Monitor) Focused() (WindowID, bool) {
408413 }
409414
410415 // Layout places every window: the workspace on screen laid out, others hidden.
411-func (m *Monitor) Layout() []Placement {
416+// The slice is fresh, the caller's to keep; the per-frame paths use layoutInto.
417+func (m *Monitor) Layout() []Placement { return m.layoutInto(nil) }
418+
419+// layoutInto is Layout built in dst[:0]: the result aliases dst's storage,
420+// so a reused dst's previous result is overwritten and only a caller done
421+// with it may pass it. It is never the monitor's own scratch.
422+func (m *Monitor) layoutInto(dst []Placement) []Placement {
412423 if m.ov.open {
413- return m.overviewLayout()
424+ return m.overviewLayout(dst[:0])
414425 }
415- var result []Placement
426+ result := dst[:0]
416427 cur := m.Current()
417428 for w := range m.all() {
418429 if w == cur {
419- result = append(result, w.Layout()...)
430+ result = w.appendLayout(result)
420431 continue
421432 }
422- for _, id := range w.windows() {
423- result = append(result, Placement{ID: id, Hidden: true})
424- }
433+ result = w.appendHidden(result, true)
425434 }
426435 return m.slideLayout(result)
427436 }
M internal/core/motion.go +112 -7
@@ -70,18 +70,57 @@ type spring struct {
7070 DampingRatio float64
7171 Stiffness float64
7272 Epsilon float64
73+ // Snap, when set, settles the motion as soon as it can no longer
74+ // stray that far from To: a position in pixels whose rounded value
75+ // is already the settled one draws no different frame, so the tail
76+ // of the spring (sub-pixel, hundreds of frames with nothing to show)
77+ // is cut. Fades and veils keep Epsilon alone. Critically damped
78+ // springs only (settled).
79+ Snap float64
7380 }
7481
75-// viewSpring moves a scrolled view; workspaceSpring switches workspaces.
76-// Both are critically damped: fast, with no overshoot.
82+// pixelSnap is the Snap of a spring in logical pixels: offsets are rounded
83+// to whole logical pixels before the renderer scales them (rectMotion.apply,
84+// Workspace.shiftPixels), so a spring that stays under half a pixel from
85+// its target draws the settled rect from then on, at any output scale.
86+const pixelSnap = 0.5
87+
88+// viewSpring moves a scrolled view, or a rect offset, in logical pixels;
89+// workspaceSpring switches workspaces, in workspaces (the caller sets its
90+// Snap from the output height). Both are critically damped: fast, with no
91+// overshoot.
7792 func viewSpring(from, velocity float64) spring {
78- return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 800, Epsilon: 0.0001}
93+ return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 800, Epsilon: 0.0001, Snap: pixelSnap}
94+}
95+
96+// levelSpring is viewSpring for a level in 0..1 (a fade, a veil): no snap,
97+// a fraction of a level still shows.
98+func levelSpring(from, velocity float64) spring {
99+ s := viewSpring(from, velocity)
100+ s.Snap = 0
101+ return s
79102 }
80103
81104 func workspaceSpring(from, velocity float64) spring {
82105 return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 1000, Epsilon: 0.0001}
83106 }
84107
108+// settled reports whether a critically damped spring at pos with speed vel
109+// stays within Snap of To from now on. From there the offset is
110+// x(t) = e^(-βt)(x + (βx+v)t), bounded by |x| + (β|x|+|v|)·max(t·e^(-βt))
111+// = |x|(1+1/e) + |v|/(βe) (the peak is at t = 1/β).
112+func (s spring) settled(pos, vel float64) bool {
113+ if s.Snap <= 0 {
114+ return false
115+ }
116+ beta, omega0 := s.coefficients()
117+ if math.Abs(beta-omega0) > f32Epsilon || beta <= 0 {
118+ return false
119+ }
120+ x := math.Abs(pos - s.To)
121+ return x*(1+1/math.E)+math.Abs(vel)/(beta*math.E) < s.Snap
122+}
123+
85124 // f32Epsilon compares damping terms: float64's epsilon is too small for
86125 // them (libadwaita, niri).
87126 const f32Epsilon = 1.1920929e-07
@@ -150,16 +189,53 @@ func (s spring) duration() time.Duration {
150189
151190 func seconds(s float64) time.Duration { return time.Duration(s * float64(time.Second)) }
152191
192+// velocityAt is the spring speed t after it started, in units per second.
193+func (s spring) velocityAt(t time.Duration) float64 {
194+ sec := t.Seconds()
195+ beta, omega0 := s.coefficients()
196+ x0 := s.From - s.To
197+ envelope := math.Exp(-beta * sec)
198+ switch {
199+ case math.Abs(beta-omega0) <= f32Epsilon:
200+ // Critically damped.
201+ return envelope * (s.Velocity - beta*(beta*x0+s.Velocity)*sec)
202+ case beta < omega0:
203+ w := math.Sqrt(omega0*omega0 - beta*beta)
204+ b := (beta*x0 + s.Velocity) / w
205+ cos, sin := math.Cos(w*sec), math.Sin(w*sec)
206+ return envelope * (-beta*(x0*cos+b*sin) + w*(b*cos-x0*sin))
207+ default:
208+ w := math.Sqrt(beta*beta - omega0*omega0)
209+ b := (beta*x0 + s.Velocity) / w
210+ cosh, sinh := math.Cosh(w*sec), math.Sinh(w*sec)
211+ return envelope * (-beta*(x0*cosh+b*sinh) + w*(x0*sinh+b*cosh))
212+ }
213+}
214+
153215 // motion is a spring started at a given time; a zero start begins at the
154-// first frame.
216+// first frame. It is a value: on tells whether one runs. slow stretches
217+// the spring's time (a slowdown of 2 takes twice as long) and v is the
218+// current speed in units per second, so a retarget can keep it.
155219 type motion struct {
156220 spring spring
157221 start time.Time
158222 end time.Duration
223+ slow float64
224+ v float64
225+ on bool
159226 }
160227
161-func newMotion(s spring, now time.Time) *motion {
162- return &motion{spring: s, start: now, end: s.duration()}
228+// newMotion starts s at now, slowed down slow times (slow <= 0 is 1). The
229+// spring's Velocity is in units per second of real time.
230+func newMotion(s spring, now time.Time, slow float64) motion {
231+ if slow <= 0 {
232+ slow = 1
233+ }
234+ v := s.Velocity
235+ s.Velocity *= slow
236+ // The slowdown is capped by the config, so this cannot overflow.
237+ end := time.Duration(float64(s.duration()) * slow)
238+ return motion{spring: s, start: now, end: end, slow: slow, v: v, on: true}
163239 }
164240
165241 // at is the position at now and whether the motion has settled.
@@ -169,11 +245,40 @@ func (m *motion) at(now time.Time) (float64, bool) {
169245 }
170246 t := now.Sub(m.start)
171247 if t >= m.end {
248+ m.v = 0
249+ return m.spring.To, true
250+ }
251+ tau := time.Duration(float64(max(t, 0)) / m.slow)
252+ pos, vel := m.spring.valueAt(tau), m.spring.velocityAt(tau)
253+ if m.spring.settled(pos, vel) {
254+ m.v = 0
172255 return m.spring.To, true
173256 }
174- return m.spring.valueAt(max(t, 0)), false
257+ m.v = vel / m.slow
258+ return pos, false
259+}
260+
261+// sampleAt is the position and speed m has at now, in units per second,
262+// without moving m: m is a copy, so at's bookkeeping (start, v) stays out.
263+func (m motion) sampleAt(now time.Time) (pos, vel float64) {
264+ pos, _ = m.at(now)
265+ return pos, m.v
266+}
267+
268+// scale multiplies the motion's distance and speed by k: the spring is
269+// linear in its start offset and speed, so the progress is unchanged.
270+func (m *motion) scale(k float64) {
271+ if !m.on {
272+ return
273+ }
274+ m.spring.From *= k
275+ m.spring.Velocity *= k
276+ m.v *= k
175277 }
176278
279+// velocity is the speed of the last sample, in units per second.
280+func (m motion) velocity() float64 { return m.v }
281+
177282 // rubberBand resists movement past the edges: the further out, the less
178283 // it moves, never more than limit.
179284 type rubberBand struct{ stiffness, limit float64 }
M internal/core/motion_test.go +147 -1
@@ -90,7 +90,7 @@ func TestSpringOverdamped(t *testing.T) {
9090
9191 func TestMotionEndsOnTarget(t *testing.T) {
9292 start := time.Unix(10, 0)
93- m := newMotion(workspaceSpring(0.4, 0), start)
93+ m := newMotion(workspaceSpring(0.4, 0), start, 1)
9494 if v, done := m.at(start); done || v != 0.4 {
9595 t.Fatalf("start %v %t", v, done)
9696 }
@@ -366,3 +366,149 @@ func TestSnapFocusWideColumn(t *testing.T) {
366366 }
367367 }
368368 }
369+
370+func TestSpringVelocityAt(t *testing.T) {
371+ springs := map[string]spring{
372+ "critical": {From: -300, DampingRatio: 1, Stiffness: 800, Epsilon: 0.0001},
373+ "under": {From: -300, DampingRatio: 0.5, Stiffness: 800, Epsilon: 0.0001},
374+ "over": {From: -300, DampingRatio: 6, Stiffness: 1200, Epsilon: 0.0001},
375+ }
376+ const h = time.Microsecond
377+ for name, s := range springs {
378+ for _, v0 := range []float64{0, 3000} {
379+ s := s
380+ s.Velocity = v0
381+ if got := s.velocityAt(0); math.Abs(got-v0) > 1e-9*max(1, math.Abs(v0)) {
382+ t.Errorf("%s v0=%v: velocityAt(0) = %v", name, v0, got)
383+ }
384+ for _, at := range []time.Duration{5, 50, 200} {
385+ at *= time.Millisecond
386+ want := (s.valueAt(at+h) - s.valueAt(at-h)) / (2 * h.Seconds())
387+ got := s.velocityAt(at)
388+ if math.Abs(got-want) > 1e-3*max(1, math.Abs(want)) {
389+ t.Errorf("%s v0=%v at %v: velocityAt = %v, derivative %v", name, v0, at, got, want)
390+ }
391+ }
392+ }
393+ }
394+}
395+
396+func TestMotionSlowdownScalesTime(t *testing.T) {
397+ start := time.Unix(10, 0)
398+ s := viewSpring(-300, 0)
399+ one, two := newMotion(s, start, 1), newMotion(s, start, 2)
400+ if two.end != 2*one.end {
401+ t.Fatalf("end %v, want %v", two.end, 2*one.end)
402+ }
403+ for _, d := range []time.Duration{5 * time.Millisecond, 40 * time.Millisecond, 150 * time.Millisecond} {
404+ a, _ := one.at(start.Add(d))
405+ b, _ := two.at(start.Add(2 * d))
406+ if math.Abs(a-b) > 1e-9 {
407+ t.Errorf("at %v: %v vs %v", d, a, b)
408+ }
409+ }
410+ if m := newMotion(s, start, 0); m.slow != 1 || m.end != one.end {
411+ t.Fatalf("slowdown 0 is not 1: %+v", m)
412+ }
413+}
414+
415+func TestMotionVelocity(t *testing.T) {
416+ start := time.Unix(10, 0)
417+ for _, slow := range []float64{1, 2.5} {
418+ m := newMotion(viewSpring(-300, 3000), start, slow)
419+ if v := m.velocity(); v != 3000 {
420+ t.Fatalf("slow %v: initial velocity %v", slow, v)
421+ }
422+ d := 30 * time.Millisecond
423+ m.at(start.Add(d))
424+ tau := time.Duration(float64(d) / slow)
425+ if want := m.spring.velocityAt(tau) / slow; m.velocity() != want {
426+ t.Fatalf("slow %v: velocity %v, want %v", slow, m.velocity(), want)
427+ }
428+ if _, done := m.at(start.Add(time.Hour)); !done || m.velocity() != 0 {
429+ t.Fatalf("slow %v: settled velocity %v", slow, m.velocity())
430+ }
431+ }
432+}
433+
434+func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) {
435+ start := time.Unix(10, 0)
436+ w := &Workspace{ViewX: 100, shift: -300}
437+ w.motion = newMotion(viewSpring(w.shift, 2000), start, 2)
438+ w.motion.at(start.Add(20 * time.Millisecond))
439+ v := w.motion.velocity()
440+ if v <= 0 {
441+ t.Fatalf("velocity %v", v)
442+ }
443+ w.ViewX = 40
444+ w.retarget(100)
445+ if w.shift != -240 || !w.motion.on || w.motion.slow != 2 {
446+ t.Fatalf("shift %v motion %+v", w.shift, w.motion)
447+ }
448+ if got := w.motion.velocity(); got != v {
449+ t.Fatalf("velocity %v after retarget, want %v", got, v)
450+ }
451+ if got := w.motion.spring.Velocity; math.Abs(got-2*v) > 1e-9 {
452+ t.Fatalf("spring velocity %v, want %v in spring time", got, 2*v)
453+ }
454+ var idle Workspace
455+ idle.ViewX = 5
456+ idle.retarget(0)
457+ if idle.motion.on || idle.shift != 0 {
458+ t.Fatal("retarget without a slide must not start one")
459+ }
460+}
461+
462+// A pixel spring settles once its rounded position can no longer differ
463+// from the target: well before its epsilon tail, and from then on the
464+// spring itself (run to its epsilon) never leaves half a pixel of the
465+// target. A level spring (fade, veil) runs to its epsilon.
466+func TestMotionPixelSnapCutsTail(t *testing.T) {
467+ start := time.Unix(10, 0)
468+ for _, tc := range []struct {
469+ name string
470+ from, v0 float64
471+ }{{"from rest", 100, 0}, {"flung past", 100, -3000}, {"flung back", 100, 3000}, {"short", 3, 0}, {"stopped near", 0.4, 60}} {
472+ m := newMotion(viewSpring(tc.from, tc.v0), start, 1)
473+ var settledAt time.Duration
474+ for d := time.Duration(0); d <= m.end; d += time.Millisecond {
475+ if _, done := m.at(start.Add(d)); done {
476+ settledAt = d
477+ break
478+ }
479+ }
480+ if settledAt == 0 || settledAt >= m.end {
481+ t.Fatalf("%s: settled at %v of %v", tc.name, settledAt, m.end)
482+ }
483+ if v, done := m.at(start.Add(settledAt)); !done || v != 0 {
484+ t.Fatalf("%s: settled value %v %v", tc.name, v, done)
485+ }
486+ for d := settledAt; d <= m.end+time.Second; d += 100 * time.Microsecond {
487+ if x := m.spring.valueAt(d); math.Abs(x) >= pixelSnap {
488+ t.Fatalf("%s: %v past the snap at %v (settled at %v)", tc.name, x, d, settledAt)
489+ }
490+ }
491+ t.Logf("%s: settled at %v, epsilon end %v", tc.name, settledAt, m.end)
492+ }
493+ level := newMotion(levelSpring(1, 0), start, 1)
494+ if v, done := level.at(start.Add(level.end - time.Millisecond)); done || v == 0 {
495+ t.Fatalf("a level spring snapped: %v %v", v, done)
496+ }
497+ if _, done := level.at(start.Add(level.end)); !done {
498+ t.Fatal("a level spring never ends")
499+ }
500+}
501+
502+// A workspace slide snaps in output pixels: at a 600 px output half a pixel
503+// is 1/1200 of a workspace.
504+func TestSwitchSpringSnapsAtOutputPixels(t *testing.T) {
505+ m := newMonitorWithIDs("", "", new(uint64))
506+ m.template.Output = Rect{W: 800, H: 600}
507+ s := m.switchSpring(0.5, 0)
508+ if s.Snap != pixelSnap/600 {
509+ t.Fatalf("snap %v", s.Snap)
510+ }
511+ if s.settled(0.5/600, 0) || !s.settled(0.2/600, 0) {
512+ t.Fatal("snap threshold")
513+ }
514+}
M internal/core/overview.go +38 -19
@@ -518,10 +518,9 @@ func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) {
518518 // overviewLayout places the current workspace and its overview neighbors,
519519 // each with its full-width stash pile and covering-float stack. Other rows
520520 // stay hidden; numbered and named workspaces share the same preview geometry.
521-func (m *Monitor) overviewLayout() []Placement {
521+func (m *Monitor) overviewLayout(result []Placement) []Placement {
522522 cur := m.Current()
523523 rows, _ := m.overviewRows()
524- var result []Placement
525524 for w := range m.all() {
526525 ry, shown := rows[w]
527526 if shown {
@@ -535,11 +534,8 @@ func (m *Monitor) overviewLayout() []Placement {
535534 }
536535 }
537536 if !shown {
538- for _, id := range w.windows() {
539- if w.floatIndex(id) < 0 {
540- result = append(result, Placement{ID: id, Hidden: true})
541- }
542- }
537+ // Columns and stash only: the floats were listed above.
538+ result = w.appendHidden(result, false)
543539 }
544540 }
545541 return result
@@ -664,7 +660,9 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
664660 // overviewZoom is the scale that fits every column of w in the usable
665661 // width, within the overview bounds.
666662 func (w *Workspace) overviewZoom() float64 {
667- _, span, _ := w.previewTiles()
663+ // Only the span is read: the tiles go in the zoom scratch, dead on return.
664+ var span int
665+ w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0])
668666 if span <= 0 {
669667 return overviewMaxZoom
670668 }
@@ -705,13 +703,23 @@ func (w *Workspace) rowHeight() int {
705703 // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses
706704 // an unscrolled row. span is the row's width; sel is the focused column.
707705 func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
706+ return w.previewTilesInto(make([]Placement, 0, w.columnWindows()))
707+}
708+
709+// previewTilesInto is previewTiles appending to tiles (dst[:0] to reuse its
710+// storage: the result then aliases it). The column rects use w.colBuf, dead
711+// when it returns.
712+func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) {
713+ var span int
714+ var sel Rect
708715 if w.pinned() {
709716 c := w.cover()
710717 // The row shows the columns a pinned fullscreen window hides,
711718 // laid out as without it; its own tile stays marked fullscreen.
712719 v := *w
713720 v.fullscreen = 0
714- tiles, span, sel = v.previewTiles()
721+ tiles, span, sel = v.previewTilesInto(tiles)
722+ w.colBuf, w.rowBuf = v.colBuf, v.rowBuf
715723 for i := range tiles {
716724 tiles[i].Fullscreen = tiles[i].ID == c
717725 }
@@ -721,7 +729,8 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
721729 h := max(w.Usable.H-2*g, 0)
722730 var rects []Rect
723731 if w.Overflow == OverflowFixed {
724- rects = w.columnRects()
732+ w.colBuf = w.columnRectsInto(w.colBuf, false)
733+ rects = w.colBuf
725734 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
726735 }
727736 maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
@@ -743,7 +752,8 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
743752 }
744753 continue
745754 }
746- for j, t := range rowRects(r, c, g) {
755+ w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
756+ for j, t := range w.rowBuf {
747757 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})
748758 }
749759 if w.Overflow != OverflowFixed {
@@ -810,22 +820,26 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
810820 return nil
811821 }
812822
813-// overviewAt is the preview under the output-local point, if any. Front
814-// cards take priority over peeks, including peeks appended after a front
815-// tile in another row's layout.
816-func (m *Monitor) overviewAt(x, y float64) WindowID {
817- layout := m.Layout()
823+// overviewClickFade is the fade from which a card takes no click.
824+const overviewClickFade = 0.5
825+
826+// overviewIn is the preview under the output-local point in layout, if
827+// any. Front cards take priority over peeks, including peeks appended after
828+// a front tile in another row's layout. The click path passes the shown
829+// layout, so a card is hit where it is drawn while it moves. A card that is
830+// nearly invisible (still fading in) is not clickable.
831+func overviewIn(layout []Placement, x, y float64) WindowID {
818832 for i := len(layout) - 1; i >= 0; i-- {
819833 p := layout[i]
820834 r := p.Rect
821- if p.Preview > 0 && !p.Hidden && !p.Peek && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
835+ if p.Preview > 0 && !p.Hidden && !p.Peek && p.Fade < overviewClickFade && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
822836 return p.ID
823837 }
824838 }
825839 for i := len(layout) - 1; i >= 0; i-- {
826840 p := layout[i]
827841 r := p.Rect
828- if p.Preview > 0 && !p.Hidden && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
842+ if p.Preview > 0 && !p.Hidden && p.Fade < overviewClickFade && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
829843 return p.ID
830844 }
831845 }
@@ -843,11 +857,16 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) {
843857 if !sc.mon.ov.open {
844858 return false, nil
845859 }
846- id := sc.mon.overviewAt(c.cursorX-float64(sc.x), c.cursorY-float64(sc.y))
860+ // The cards are hit where they are drawn: a click during the open or a
861+ // navigation animation picks what moves under the pointer.
862+ id := overviewIn(sc.shownLayout(), c.cursorX-float64(sc.x), c.cursorY-float64(sc.y))
847863 if id == 0 {
848864 return false, nil
849865 }
866+ now := c.now()
867+ shots := c.snapshot(now)
850868 sc.mon.OverviewPick(id)
869+ c.transition(shots, now)
851870 c.focusScreen = i
852871 c.keyboard.takeBack()
853872 if err := c.workspaceVisible(ctx, true); err != nil {
M internal/core/overview_core_test.go +21 -8
@@ -49,12 +49,18 @@ func noButtonSent(t *testing.T, ch chan ports.ClientCommand) {
4949 }
5050
5151 func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) {
52- r := startMulti(t, nil, left, right)
53- r.mapWindow(t, 1)
54- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
55- r.mapWindow(t, 2)
56- r.mapWindow(t, 3)
57- set := r.key(t, "o", ports.ModAlt)
52+ both(t, func(t *testing.T, animated bool) {
53+ overviewClickPicksOnPointerOutput(t, startLanding(t, animated, nil, left, right))
54+ })
55+}
56+
57+func overviewClickPicksOnPointerOutput(t *testing.T, r *landRig) {
58+ r.mapLanded(t, 1)
59+ r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
60+ r.mapLanded(t, 2)
61+ r.mapLanded(t, 3)
62+ // Settled: a card still fading in takes no click.
63+ set := r.keyLanded(t, "o", ports.ModAlt)
5864 p, ok := scenePreview(outputScene(t, set, "DP-2"), 2)
5965 if !ok {
6066 t.Fatal("no preview of window 2 on DP-2")
@@ -82,10 +88,17 @@ func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) {
8288 }
8389
8490 func TestOverviewCoreClickOutsidePreviewKeepsOverview(t *testing.T) {
85- r := startMulti(t, nil, right)
91+ both(t, func(t *testing.T, animated bool) {
92+ overviewClickOutsidePreview(t, startLanding(t, animated, nil, right))
93+ })
94+}
95+
96+func overviewClickOutsidePreview(t *testing.T, r *landRig) {
8697 r.mapWindow(t, 1)
8798 r.mapWindow(t, 2)
88- set := r.key(t, "o", ports.ModAlt)
99+ // The cards settle where the overview lays them out: the opening
100+ // animation starts them at the windows' own rects.
101+ set := r.keyLanded(t, "o", ports.ModAlt)
89102 for _, w := range set[0].Windows {
90103 if w.Preview > 0 && w.Rect.X <= 1 && w.Rect.Y <= 1 && w.Rect.X+w.Rect.W > 1 && w.Rect.Y+w.Rect.H > 1 {
91104 t.Fatalf("(1, 1) is inside preview %+v", w)
M internal/core/overview_named_test.go +1 -1
@@ -304,7 +304,7 @@ func TestOverviewNamedNeighborPick(t *testing.T) {
304304 if p.Hidden {
305305 t.Fatal("named stash neighbor hidden")
306306 }
307- id := m.overviewAt(float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2))
307+ id := overviewIn(m.Layout(), float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2))
308308 if id != 6 {
309309 t.Fatalf("stash hit: %d", id)
310310 }
M internal/core/overview_regression_test.go +1 -1
@@ -62,7 +62,7 @@ func TestOverviewSingleCoveringFloat(t *testing.T) {
6262 }
6363 }
6464 if click {
65- id := m.overviewAt(float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2))
65+ id := overviewIn(m.Layout(), float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2))
6666 if id != 9 {
6767 t.Fatalf("hit %d, want 9", id)
6868 }
M internal/core/overview_stack.go +4 -2
@@ -257,7 +257,8 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
257257 r := rects[i]
258258 r.X -= w.Usable.X
259259 r.Y -= w.Usable.Y
260- for j, t := range rowRects(r, c, g) {
260+ w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
261+ for j, t := range w.rowBuf {
261262 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == selected && j == c.Focus})
262263 }
263264 }
@@ -287,7 +288,8 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
287288 r.X = (w.Usable.W - r.W) / 2
288289 r.Y = (w.Usable.H - r.H) / 2
289290 tiles := make([]Placement, 0, len(c.Windows))
290- for j, t := range rowRects(r, c, g) {
291+ w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
292+ for j, t := range w.rowBuf {
291293 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()})
292294 }
293295 return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r)
M internal/core/overview_stack_test.go +2 -2
@@ -115,12 +115,12 @@ func TestOverviewStackClickAndHitPriority(t *testing.T) {
115115 if frontColumn {
116116 x = front.Rect.X + front.Rect.W/2
117117 }
118- if got := m.overviewAt(float64(x), float64(y)); got != front.ID {
118+ if got := overviewIn(m.Layout(), float64(x), float64(y)); got != front.ID {
119119 t.Fatalf("overlap hit %d want %d (front %+v back %+v)", got, front.ID, front.Rect, back.Rect)
120120 }
121121 // The up/right exposed corner belongs to the peeking card.
122122 x, y = back.Rect.X+back.Rect.W-1, back.Rect.Y+1
123- if got := m.overviewAt(float64(x), float64(y)); got != back.ID {
123+ if got := overviewIn(m.Layout(), float64(x), float64(y)); got != back.ID {
124124 t.Fatalf("peek hit %d want %d", got, back.ID)
125125 }
126126 m.OverviewPick(back.ID)
M internal/core/overview_test.go +4 -4
@@ -116,7 +116,7 @@ func TestOverviewPick(t *testing.T) {
116116 m := overviewMonitor()
117117 m.ToggleOverview()
118118 below := previewOf(t, m.Layout(), 4).Rect
119- id := m.overviewAt(float64(below.X+1), float64(below.Y+1))
119+ id := overviewIn(m.Layout(), float64(below.X+1), float64(below.Y+1))
120120 if id != 4 {
121121 t.Fatalf("hit %d", id)
122122 }
@@ -173,7 +173,7 @@ func TestOverviewFullscreenFloatPickAndCancel(t *testing.T) {
173173 m := gameMonitor(OverflowFixed)
174174 m.ToggleOverview()
175175 r := previewOf(t, m.Layout(), 2).Rect
176- if id := m.overviewAt(float64(r.X+1), float64(r.Y+1)); id != 2 {
176+ if id := overviewIn(m.Layout(), float64(r.X+1), float64(r.Y+1)); id != 2 {
177177 t.Fatalf("hit %d", id)
178178 }
179179 m.OverviewMove(0, 1)
@@ -412,7 +412,7 @@ func TestOverviewPilePick(t *testing.T) {
412412 m := pileMonitor()
413413 m.ToggleOverview()
414414 r := previewOf(t, m.Layout(), 7).Rect
415- id := m.overviewAt(float64(r.X+1), float64(r.Y+r.H-1))
415+ id := overviewIn(m.Layout(), float64(r.X+1), float64(r.Y+r.H-1))
416416 if id != 7 {
417417 t.Fatalf("hit %d", id)
418418 }
@@ -486,7 +486,7 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) {
486486 t.Fatalf("%d overlaps %d", id, other)
487487 }
488488 }
489- if p.Rect.W > 0 && p.Rect.H > 0 && m.overviewAt(float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)) != id {
489+ if p.Rect.W > 0 && p.Rect.H > 0 && overviewIn(m.Layout(), float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)) != id {
490490 t.Fatalf("%d not hit at %+v", id, p.Rect)
491491 }
492492 }
M internal/core/persist_test.go +1 -0
@@ -28,6 +28,7 @@ func steppingClock(t *testing.T) *portsmocks.MockClock {
2828 timer := portsmocks.NewMockTimer(t)
2929 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
3030 timer.EXPECT().Stop().Return(true).Maybe()
31+ timer.EXPECT().Reset(mock.Anything).Return(false).Maybe()
3132 return timer
3233 }).Maybe()
3334 return clock
M internal/core/popup.go +2 -2
@@ -144,7 +144,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
144144 return sc, l.Rect, true
145145 }
146146 for _, sc := range c.screens {
147- for _, pl := range sc.mon.Layout() {
147+ for _, pl := range sc.shownLayout() {
148148 if pl.ID == id {
149149 // Hidden or scrolled off: not on screen, like its popups.
150150 // A preview takes no input and shows no popups.
@@ -161,7 +161,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
161161 // peeking reports whether id is a stashed window peeking in on screen.
162162 func (c *Core) peeking(id WindowID) bool {
163163 for _, sc := range c.screens {
164- for _, pl := range sc.mon.Layout() {
164+ for _, pl := range sc.shownLayout() {
165165 if pl.ID == id {
166166 return pl.Peek
167167 }
A internal/core/publish_alloc_test.go +229 -0
@@ -0,0 +1,229 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// Allocation budgets of one animation frame (step) on two screens of six
12+// windows each, with camera and rect motions (position, fade, dim, scale)
13+// running, and on one screen with the overview opening (its cards' scale
14+// and fade motions running). Every scene sent is freshly built (immutable
15+// once sent), so the floor is not 0. Measured: 43 before presizing the scene
16+// windows and separators, 36 after, 32 once the fallback timer is made once
17+// and reset per frame (the fallback path, step(ctx, nil), re-arms it; the
18+// flip path with another screen animating keeps it and costs the same), then
19+// 6 once the layouts are built in the owner's reused buffers (layoutInto:
20+// the screens' layout, the workspaces' column, row and tile rects) instead of
21+// fresh slices. What is left is the scenes themselves (windows, separators,
22+// the scene set) and the screen placements. Leaving entries (a closed window
23+// still fading) add none: 4, less than a frame with six moving windows
24+// (fewer configure targets). The opening overview costs 18 (the stack
25+// items, the row map and the neighbor list it rebuilds every frame; its
26+// layouts reuse the buffers too).
27+const (
28+ publishAllocBudget = 6
29+ publishLeavingAllocBudget = 4
30+ publishOverviewAllocBudget = 18
31+)
32+
33+// publishRig is a Core on the real clock (a mock clock would count its own
34+// bookkeeping; the fallback timer is then the system one) with screens
35+// screens of six windows each. The Slowdown cap keeps every motion running
36+// for seconds, so none settles during a measure even under -race or load.
37+func publishRig(t *testing.T, screens int) *Core {
38+ t.Helper()
39+ var cfg ports.Config
40+ cfg.Keyboard.CmdKey = "super"
41+ cfg.Layout.MaxColumns = 2
42+ cfg.Border.Width = 2
43+ cfg.Animations.On = true
44+ cfg.Animations.Slowdown = 10
45+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)})
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ next := WindowID(1)
50+ for i := range screens {
51+ c.addScreen(ports.OutputInfo{Name: string(rune('A' + i)), Width: 300, Height: 200})
52+ }
53+ for _, sc := range c.screens {
54+ if sc.name() == "" {
55+ continue
56+ }
57+ for range 6 {
58+ sc.mon.AddWindow(next)
59+ next++
60+ }
61+ sc.arrange()
62+ }
63+ c.refreshShown()
64+ return c
65+}
66+
67+// publishStep is one animation frame of only (nil: the fallback timer's,
68+// every screen) whose scene is taken from the channel.
69+func publishStep(t *testing.T, c *Core, only *screen) func() {
70+ ctx := context.Background()
71+ return func() {
72+ if err := c.step(ctx, only); err != nil {
73+ t.Fatal(err)
74+ }
75+ select {
76+ case <-c.ch.Scenes:
77+ default:
78+ t.Fatal("no scene published")
79+ }
80+ }
81+}
82+
83+// startRectMotions gives every window of sc a rect motion on each
84+// component: position and size, fade, dim and content scale.
85+func startRectMotions(c *Core, sc *screen, t0 time.Time) {
86+ sc.rects = make(map[WindowID]rectMotion, 6)
87+ sp := viewSpring(100, 0)
88+ for _, p := range sc.settledLayout {
89+ sc.rects[p.ID] = rectMotion{
90+ x: c.spring(sp, t0), y: c.spring(sp, t0),
91+ w: c.spring(sp, t0), h: c.spring(sp, t0),
92+ dx: 100, dy: 100, dw: 100, dh: 100,
93+ fade: c.spring(levelSpring(0.5, 0), t0), df: 0.5,
94+ dim: c.spring(levelSpring(0.5, 0), t0), ddim: 0.5,
95+ scale: true,
96+ }
97+ }
98+ sc.rectsWS = sc.mon.Current()
99+}
100+
101+// checkRunning fails unless every real screen still has six rect motions
102+// and its camera motion.
103+func checkRunning(t *testing.T, c *Core, when string) {
104+ t.Helper()
105+ for _, sc := range c.screens {
106+ if sc.name() == "" {
107+ continue
108+ }
109+ if len(sc.rects) != 6 || !sc.mon.Current().motion.on || len(sc.shown) < 6 {
110+ t.Fatalf("%s: rects %d camera %v shown %d", when, len(sc.rects), sc.mon.Current().motion.on, len(sc.shown))
111+ }
112+ }
113+}
114+
115+// TestPublishAllocations pins the cost of publishing an animation frame, on
116+// the fallback timer (every screen) and on a page flip of one screen while
117+// the other still animates (the flip path keeps the timer).
118+func TestPublishAllocations(t *testing.T) {
119+ c := publishRig(t, 2)
120+ t0 := time.Now()
121+ for _, sc := range c.screens {
122+ if sc.name() == "" {
123+ continue
124+ }
125+ ws := sc.mon.Current()
126+ ws.motion = c.spring(viewSpring(100, 0), t0)
127+ ws.shift = 100
128+ startRectMotions(c, sc, t0)
129+ }
130+ for _, tc := range []struct {
131+ name string
132+ only *screen
133+ }{{"fallback", nil}, {"flip", c.screens[1]}} {
134+ step := publishStep(t, c, tc.only)
135+ step()
136+ checkRunning(t, c, "setup")
137+ if tc.only != nil && !c.animatingOther(tc.only) {
138+ t.Fatal("setup: the other screen does not animate")
139+ }
140+ n := testing.AllocsPerRun(50, step)
141+ t.Logf("%s allocs per step = %v", tc.name, n)
142+ checkRunning(t, c, "motions settled during the measure")
143+ if n > publishAllocBudget {
144+ t.Errorf("%s publish allocs per frame = %v, budget %d", tc.name, n, publishAllocBudget)
145+ }
146+ }
147+}
148+
149+// TestPublishOverviewAllocations pins the cost of publishing while the
150+// overview opens: its cards fly from the full rects (scale and fade motions
151+// on every card), which guards the reuse of Core.overviewReal.
152+func TestPublishOverviewAllocations(t *testing.T) {
153+ c := publishRig(t, 1)
154+ sc := c.cur()
155+ now := time.Now()
156+ shots := c.snapshot(now)
157+ sc.mon.ToggleOverview()
158+ c.transition(shots, now)
159+ step := publishStep(t, c, nil)
160+ step()
161+ if !sc.mon.ov.open || len(c.overviewReal) != 6 || len(sc.rects) == 0 || !c.animating() {
162+ t.Fatalf("setup: overview %v, real layouts %d, rects %d, animating %v", sc.mon.ov.open, len(c.overviewReal), len(sc.rects), c.animating())
163+ }
164+ n := testing.AllocsPerRun(50, step)
165+ t.Logf("overview allocs per step = %v", n)
166+ if len(sc.rects) == 0 {
167+ t.Fatal("card motions settled during the measure")
168+ }
169+ if n > publishOverviewAllocBudget {
170+ t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)
171+ }
172+}
173+
174+// TestPublishLeavingAllocations pins the cost of a frame with leaving
175+// entries on every screen (two of the six windows closed, fading out, the
176+// other four with rect motions): the measured 4, under a frame without
177+// them (the leaving entries take no configure target).
178+func TestPublishLeavingAllocations(t *testing.T) {
179+ c := publishRig(t, 2)
180+ t0 := time.Now()
181+ for _, sc := range c.screens {
182+ if sc.name() == "" {
183+ continue
184+ }
185+ c.refreshShown()
186+ layout := sc.settledLayout
187+ sc.rects = make(map[WindowID]rectMotion, 6)
188+ sp := viewSpring(100, 0)
189+ for i, p := range layout {
190+ if i < 2 {
191+ sc.mon.RemoveWindow(p.ID)
192+ c.leaveFrom(sc, p, nil, t0)
193+ continue
194+ }
195+ sc.rects[p.ID] = rectMotion{
196+ x: c.spring(sp, t0), y: c.spring(sp, t0),
197+ w: c.spring(sp, t0), h: c.spring(sp, t0),
198+ dx: 100, dy: 100, dw: 100, dh: 100,
199+ }
200+ }
201+ sc.rectsWS = sc.mon.Current()
202+ }
203+ step := publishStep(t, c, nil)
204+ step()
205+ for _, sc := range c.screens {
206+ if sc.name() == "" {
207+ continue
208+ }
209+ leaving := 0
210+ for _, p := range sc.shown {
211+ if p.Leaving {
212+ leaving++
213+ }
214+ }
215+ if len(sc.rects) != 6 || leaving != 2 || len(sc.shown) != 6 {
216+ t.Fatalf("setup: rects %d leaving %d shown %d", len(sc.rects), leaving, len(sc.shown))
217+ }
218+ }
219+ n := testing.AllocsPerRun(50, step)
220+ t.Logf("leaving allocs per step = %v", n)
221+ for _, sc := range c.screens {
222+ if sc.name() != "" && len(sc.rects) != 6 {
223+ t.Fatalf("motions settled during the measure: rects %d", len(sc.rects))
224+ }
225+ }
226+ if n > publishLeavingAllocBudget {
227+ t.Errorf("publish allocs per frame with leaving entries = %v, budget %d", n, publishLeavingAllocBudget)
228+ }
229+}
M internal/core/pulse.go +18 -12
@@ -32,18 +32,23 @@ const (
3232 // are the previous pulse, for the cooldown. drawable is whether the last
3333 // scene could show a pulse on target.
3434 type focusPulse struct {
35- target WindowID
36- since time.Time
37- id WindowID
38- start time.Time
39- last WindowID
40- lastAt time.Time
41- drawable bool
35+ target WindowID
36+ since time.Time
37+ id WindowID
38+ start time.Time
39+ last WindowID
40+ lastAt time.Time
41+ drawable bool
42+ // value is the effect of the last sample, kept for scenes published
43+ // between the focused output's own flips.
44+ value float64
4245 timerC <-chan time.Time
4346 timerStop func() bool
4447 }
4548
46-func (c *Core) pulseOn() bool { return c.cfg.Focus.Animation == ports.FocusAnimationPulse }
49+func (c *Core) pulseOn() bool {
50+ return c.animOn() && c.cfg.Focus.Animation == ports.FocusAnimationPulse
51+}
4752
4853 // pulseFocus follows the window focus. held is false while something else
4954 // (a popup grab, a layer, a lock surface) has the keyboard: the running pulse
@@ -99,7 +104,7 @@ func (c *Core) pulseTick() bool {
99104 // A swipe or its landing slide still shows the neighbours: decide on
100105 // the settled layout, or a window left alone would start a pulse cut
101106 // short.
102- if !c.cur().mon.settled() {
107+ if !c.cur().settled() {
103108 p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseRecheck)
104109 return false
105110 }
@@ -132,13 +137,14 @@ func (c *Core) advancePulse(now time.Time) float64 {
132137 return 0
133138 }
134139
135-// settled reports whether no swipe or slide moves the monitor's layout.
140+// settled reports whether no swipe or slide moves the monitor's layout
141+// (screen.settled adds the rect motions).
136142 func (m *Monitor) settled() bool {
137- if m.switchMotion != nil || m.switchOff != 0 {
143+ if m.switchMotion.on || m.switchOff != 0 {
138144 return false
139145 }
140146 for w := range m.all() {
141- if w.motion != nil || w.shift != 0 {
147+ if w.motion.on || w.shift != 0 {
142148 return false
143149 }
144150 }
M internal/core/pulse_test.go +49 -7
@@ -12,6 +12,7 @@ import (
1212 func pulseCore(t *testing.T) (*Core, *indicatorClock) {
1313 t.Helper()
1414 c, ic := indicatorCore(t)
15+ c.cfg.Animations.On = true
1516 c.cfg.Focus.Animation = ports.FocusAnimationPulse
1617 c.cfg.Focus.Strength = 0.05
1718 c.cur().mon.AddWindow(1)
@@ -184,30 +185,54 @@ func TestFocusPulseSkipsAloneOnScreen(t *testing.T) {
184185 }
185186 }
186187
187-// A swipe or its landing slide still shows the neighbours: the pulse waits
188-// for the layout to settle, then decides on it.
188+// A swipe, its landing slide or a transition still shows the neighbours: the
189+// pulse waits for the layout to settle, then decides on it.
189190 func TestFocusPulseWaitsForSlide(t *testing.T) {
190191 for _, tc := range []struct {
191192 name string
192- slide func(*Monitor, time.Time)
193+ slide func(*testing.T, *Core, time.Time)
193194 alone bool
194195 }{
195- {"workspace landing, alone", func(m *Monitor, now time.Time) { m.switchMotion = newMotion(workspaceSpring(0.5, 0), now) }, true},
196- {"workspace drag, alone", func(m *Monitor, _ time.Time) { m.switchOff = 0.3 }, true},
197- {"column drag, beside another", func(m *Monitor, _ time.Time) { m.Current().shift = 40 }, false},
196+ {"workspace landing, alone", func(_ *testing.T, c *Core, now time.Time) {
197+ c.cur().mon.switchMotion = newMotion(workspaceSpring(0.5, 0), now, 1)
198+ }, true},
199+ {"workspace drag, alone", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.switchOff = 0.3 }, true},
200+ {"column drag, beside another", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.Current().shift = 40 }, false},
201+ {"rect motion, beside another", func(_ *testing.T, c *Core, now time.Time) {
202+ sc := c.cur()
203+ sc.rects = map[WindowID]rectMotion{1: {x: c.spring(viewSpring(40, 0), now), dx: 40}}
204+ sc.rectsWS = sc.mon.Current()
205+ }, false},
206+ {"camera transition of a key, beside another", func(t *testing.T, c *Core, now time.Time) {
207+ m := c.cur().mon
208+ for id := WindowID(3); id <= 4; id++ {
209+ m.AddWindow(id)
210+ }
211+ indicatorScene(t, c)
212+ before := c.snapshot(now)
213+ for range 3 {
214+ c.applyAction(ActionFocusColumnLeft)
215+ }
216+ c.transition(before, now)
217+ if !m.Current().motion.on || m.settled() {
218+ t.Fatal("no camera transition after the focus moved the view")
219+ }
220+ }, false},
198221 } {
199222 t.Run(tc.name, func(t *testing.T) {
200223 c, ic := pulseCore(t)
201224 m := c.cur().mon
202225 m.Current().FocusID(1)
203226 indicatorScene(t, c)
204- tc.slide(m, ic.now)
227+ tc.slide(t, c, ic.now)
228+ indicatorScene(t, c)
205229 ic.now = ic.now.Add(pulseSettle)
206230 if c.pulseTick() || c.pulse.timerC == nil {
207231 t.Fatal("pulse decided during a slide")
208232 }
209233 m.stopSwitch()
210234 m.Current().stopSlide()
235+ c.cur().stopRects()
211236 if tc.alone {
212237 m.Current().ToggleFullWidth()
213238 }
@@ -259,3 +284,20 @@ func TestFocusPulseOff(t *testing.T) {
259284 t.Fatal("enabling the pulse pulses the focused window")
260285 }
261286 }
287+
288+func TestFocusPulseOffWithAnimationsOff(t *testing.T) {
289+ c, ic := pulseCore(t)
290+ c.cfg.Animations.On = false
291+ if c.pulseOn() {
292+ t.Fatal("pulse on with animations off")
293+ }
294+ c.cur().mon.Current().FocusID(1)
295+ indicatorScene(t, c)
296+ if c.pulse.timerC != nil || c.pulse.id != 0 {
297+ t.Fatal("pulse armed with animations off")
298+ }
299+ ic.now = ic.now.Add(pulseSettle)
300+ if c.pulseTick() || c.animating() {
301+ t.Fatal("pulse with animations off")
302+ }
303+}
A internal/core/refresh_bench_test.go +61 -0
@@ -0,0 +1,61 @@
1+package core
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// BenchmarkRefreshShown is one publish's layout build on a screen of 24
11+// windows, 20 of them with a rect motion and 6 more leaving: the cost of
12+// matching the motions to the layout every frame. The layout is built in the
13+// screen's reused buffers: 0 allocs/op (33 before).
14+func BenchmarkRefreshShown(b *testing.B) {
15+ var cfg ports.Config
16+ cfg.Keyboard.CmdKey = "super"
17+ cfg.Layout.MaxColumns = 4
18+ cfg.Animations.On = true
19+ cfg.Animations.Slowdown = 10
20+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)})
21+ if err != nil {
22+ b.Fatal(err)
23+ }
24+ c.addScreen(ports.OutputInfo{Name: "A", Width: 3000, Height: 2000})
25+ sc := c.cur()
26+ for id := WindowID(1); id <= 30; id++ {
27+ sc.mon.AddWindow(id)
28+ }
29+ sc.arrange()
30+ c.refreshShown()
31+ t0 := time.Now()
32+ sc.rects = make(map[WindowID]rectMotion, 26)
33+ sp := viewSpring(100, 0)
34+ for i, p := range sc.settledLayout {
35+ if i >= 20 {
36+ break
37+ }
38+ sc.rects[p.ID] = rectMotion{
39+ x: c.spring(sp, t0), y: c.spring(sp, t0), w: c.spring(sp, t0), h: c.spring(sp, t0),
40+ dx: 100, dy: 100, dw: 100, dh: 100,
41+ fade: c.spring(levelSpring(0.5, 0), t0), df: 0.5, scale: true,
42+ }
43+ }
44+ for _, p := range sc.settledLayout[24:] {
45+ sc.mon.RemoveWindow(p.ID)
46+ c.leaveFrom(sc, p, nil, t0)
47+ }
48+ sc.rectsWS = sc.mon.Current()
49+ c.refreshShown()
50+ if len(sc.rects) != 26 {
51+ b.Fatalf("rects %d", len(sc.rects))
52+ }
53+ b.ReportAllocs()
54+ b.ResetTimer()
55+ for range b.N {
56+ c.refreshShown()
57+ }
58+ if len(sc.rects) != 26 {
59+ b.Fatalf("rects %d after", len(sc.rects))
60+ }
61+}
A internal/core/scene_seq_test.go +78 -0
@@ -0,0 +1,78 @@
1+package core_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// A publish that changes nothing on an output keeps that output's Seq, so
10+// its owner does not recompose; a real change takes a new one.
11+func TestSceneSeqKeptWhenNothingChanged(t *testing.T) {
12+ r := startSwipe(t, nil)
13+ first := r.mapWindow(t, 1)[0]
14+ if first.Seq == 0 {
15+ t.Fatal("scene without Seq")
16+ }
17+ // Focus-column-left at the left edge changes nothing but still publishes.
18+ again := r.key(t, "Left", ports.ModAlt)[0]
19+ if again.Seq != first.Seq || !again.SameAs(first) {
20+ t.Fatalf("Seq %d, want the held %d", again.Seq, first.Seq)
21+ }
22+ next := r.mapWindow(t, 2)[0]
23+ if next.Seq <= first.Seq || len(next.Windows) != 2 {
24+ t.Fatalf("Seq %d after mapping a window (was %d), %d windows", next.Seq, first.Seq, len(next.Windows))
25+ }
26+}
27+
28+// A key that starts a transition can publish a scene that draws what the
29+// previous one did (the springs start there): it still gets a fresh Seq, or
30+// the outputs would drop it and the motion would wait for the fallback timer
31+// to begin.
32+func TestAnimatingKeyGetsFreshSeq(t *testing.T) {
33+ tests := []struct {
34+ name string
35+ edit func(*ports.Config)
36+ // setup returns the scene before the key.
37+ setup func(*testing.T, *swipeRig) ports.Scene
38+ sym string
39+ mods ports.Mods
40+ // same: the key's scene draws what the previous one did.
41+ same bool
42+ }{
43+ {"width change", func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" },
44+ func(t *testing.T, r *swipeRig) ports.Scene {
45+ twoColumns(t, r)
46+ return r.key(t, "Left", ports.ModAlt)[0]
47+ }, "Right", ports.ModAlt | ports.ModCtrl, true},
48+ {"focus scroll", nil, func(t *testing.T, r *swipeRig) ports.Scene {
49+ threeColumns(t, r)
50+ return r.key(t, "Left", ports.ModAlt)[0] // 2 is on screen: no scroll
51+ }, "Left", ports.ModAlt, false},
52+ }
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ r := startSwipe(t, tt.edit)
56+ prev := tt.setup(t, r)
57+ s := r.key(t, tt.sym, tt.mods)[0]
58+ if s.SameAs(prev) != tt.same {
59+ t.Fatalf("scene right after the key same as the previous one: %t, want %t", s.SameAs(prev), tt.same)
60+ }
61+ if s.Seq <= prev.Seq {
62+ t.Fatalf("Seq %d right after the key, want a new one (was %d)", s.Seq, prev.Seq)
63+ }
64+ })
65+ }
66+}
67+
68+// A key that animates nothing keeps its output's Seq.
69+func TestNoOpKeyKeepsSeqWithAnimationsOn(t *testing.T) {
70+ r := startSwipe(t, nil)
71+ threeColumns(t, r)
72+ first := r.key(t, "Right", ports.ModAlt)[0] // 3 is the last column
73+ for range 2 {
74+ if s := r.key(t, "Right", ports.ModAlt)[0]; s.Seq != first.Seq || !s.SameAs(first) {
75+ t.Fatalf("Seq %d on a key at the edge, want the held %d", s.Seq, first.Seq)
76+ }
77+ }
78+}
M internal/core/screens.go +55 -0
@@ -28,6 +28,57 @@ type screen struct {
2828 // CaptureIndicators): immutable, shared by the next one while unchanged.
2929 capScene *ports.SceneCapture
3030 capMarks []ports.CaptureIndicator
31+ // last is the scene published for this screen: an identical next one
32+ // keeps its Seq (Scene.SameAs). Zero after a protected publish.
33+ last ports.Scene
34+ // settledLayout is the monitor's layout as of the last publish: what
35+ // configures are sized from. shown is the same with the rect motions applied: what is
36+ // drawn and hit-tested. They are the same slice while no rect motion
37+ // runs. Both are built once per publish (refreshShown); shown is nil
38+ // before the first publish, then callers measure the monitor's layout.
39+ settledLayout, shown []Placement
40+ // layoutBuf backs settledLayout (and shown, while no rect motion runs),
41+ // rebuilt in place by every refreshShown; shownBuf and settledBuf back
42+ // shown and settledLayout while rect motions run. All three are owner
43+ // goroutine scratch reused across publishes: a Scene never aliases them
44+ // (its windows are copied from the placements), and a caller that
45+ // keeps a layout past the next publish copies it (Layout() is fresh).
46+ layoutBuf, shownBuf, settledBuf []Placement
47+ // leaveIDs is withLeaving's scratch list.
48+ leaveIDs []WindowID
49+ // rects are the running per-window rect motions (transition.go) of
50+ // rectsWS, the workspace they began on (nil: the overview); created
51+ // lazily.
52+ rects map[WindowID]rectMotion
53+ rectsWS *Workspace
54+}
55+
56+// springing reports whether a camera or rect spring runs on the screen.
57+func (s *screen) springing() bool { return s.mon.springing() || len(s.rects) > 0 }
58+
59+// settled reports whether nothing moves the screen's layout: no swipe,
60+// no slide, no rect motion.
61+func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 }
62+
63+// stopRects drops the rect motions: the windows are drawn where they are.
64+func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil }
65+
66+// rectsOwner is what the screen's rect motions belong to: its workspace on
67+// screen, or nil in the overview, where the rows change under the cards.
68+func (s *screen) rectsOwner() *Workspace {
69+ if s.mon.ov.open {
70+ return nil
71+ }
72+ return s.mon.Current()
73+}
74+
75+// shownLayout is the layout as drawn, for hit-testing and popups; before
76+// the first publish it is the monitor's.
77+func (s *screen) shownLayout() []Placement {
78+ if s.shown != nil {
79+ return s.shown
80+ }
81+ return s.mon.Layout()
3182 }
3283
3384 func (s *screen) name() string { return s.info.Name }
@@ -293,6 +344,10 @@ func (c *Core) removeScreen(name string) {
293344 if len(c.screens) == 1 {
294345 c.screens[0].info = ports.OutputInfo{}
295346 c.screens[0].layers = nil
347+ // Nothing draws the placeholder: its springs would only wake the
348+ // fallback timer, and start from the old output's geometry if an
349+ // output came back.
350+ c.screens[0].stopAnimations()
296351 return
297352 }
298353 gone := c.screens[i]
M internal/core/screens_test.go +135 -98
@@ -127,15 +127,21 @@ func TestOutputsLeftToRight(t *testing.T) {
127127 }
128128
129129 func TestLayoutPerOutput(t *testing.T) {
130- r := startMulti(t, func(c *ports.Config) {
131- c.Layout.MaxColumns = 2
132- c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", MaxColumns: 4, Overflow: "fixed"}}
133- }, left, right)
134- r.mapWindow(t, 1)
135- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
130+ both(t, func(t *testing.T, animated bool) {
131+ r := startLanding(t, animated, func(c *ports.Config) {
132+ c.Layout.MaxColumns = 2
133+ c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", MaxColumns: 4, Overflow: "fixed"}}
134+ }, left, right)
135+ layoutPerOutput(t, r)
136+ })
137+}
138+
139+func layoutPerOutput(t *testing.T, r *landRig) {
140+ r.mapLanded(t, 1)
141+ r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
136142 var set []ports.Scene
137143 for id := ports.WindowID(2); id <= 6; id++ {
138- set = r.mapWindow(t, id)
144+ set = r.mapLanded(t, id)
139145 }
140146 // DP-2: four columns of 100; the fifth window splits the last one
141147 // (fixed) instead of scrolling.
@@ -166,26 +172,32 @@ func widths(set []ports.Scene, output string) []int {
166172 }
167173
168174 func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) {
169- r := startMulti(t, func(c *ports.Config) {
170- c.Layout.MaxColumns = 1
171- c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", LayoutRules: ports.LayoutRules{MaxColumns: 2}}}
172- }, right)
173- r.mapWindow(t, 1)
174- if got := widths(r.mapWindow(t, 2), "DP-2"); !slices.Equal(got, []int{200, 200}) {
175+ both(t, func(t *testing.T, animated bool) {
176+ r := startLanding(t, animated, func(c *ports.Config) {
177+ c.Layout.MaxColumns = 1
178+ c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", LayoutRules: ports.LayoutRules{MaxColumns: 2}}}
179+ }, right)
180+ layoutPerOutputFollowsMonitorAndReload(t, r)
181+ })
182+}
183+
184+func layoutPerOutputFollowsMonitorAndReload(t *testing.T, r *landRig) {
185+ r.mapLanded(t, 1)
186+ if got := widths(r.mapLanded(t, 2), "DP-2"); !slices.Equal(got, []int{200, 200}) {
175187 t.Fatal(got)
176188 }
177189 // Another monitor on DP-2: the key rule no longer applies.
178190 other := right
179191 other.Serial = "3"
180- if got := widths(r.plug(t, other), "DP-2"); !slices.Equal(got, []int{400, 400}) {
192+ if got := widths(r.land(r.plug(t, other)), "DP-2"); !slices.Equal(got, []int{400, 400}) {
181193 t.Fatal(got)
182194 }
183195 // Back to the first monitor, then a reload drops the rule.
184- r.plug(t, right)
196+ r.land(r.plug(t, right))
185197 cfg := r.cfg
186198 cfg.Layout.Outputs = nil
187199 r.reload <- ports.ConfigChanged{Config: cfg}
188- if got := widths(receive(t, r.scenes), "DP-2"); !slices.Equal(got, []int{400, 400}) {
200+ if got := widths(r.land(receive(t, r.scenes)), "DP-2"); !slices.Equal(got, []int{400, 400}) {
189201 t.Fatal(got)
190202 }
191203 }
@@ -301,10 +313,15 @@ func TestFocusColumnCrossesOutputs(t *testing.T) {
301313 }
302314
303315 func TestPointerCrossesOutputs(t *testing.T) {
304- r := startMulti(t, nil, left, right)
305- r.mapWindow(t, 1)
306- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
307- r.mapWindow(t, 2)
316+ both(t, func(t *testing.T, animated bool) {
317+ pointerCrossesOutputs(t, startLanding(t, animated, nil, left, right))
318+ })
319+}
320+
321+func pointerCrossesOutputs(t *testing.T, r *landRig) {
322+ r.mapLanded(t, 1)
323+ r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
324+ r.mapLanded(t, 2)
308325 for len(r.commands) > 0 {
309326 <-r.commands
310327 }
@@ -400,33 +417,35 @@ func TestMonitorActionsFollowGeometry(t *testing.T) {
400417 }
401418
402419 func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {
403- r := startMulti(t, nil, left, right)
404- r.mapWindow(t, 1)
405- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
406- r.mapWindow(t, 2)
407- // Unplug DP-2 while it is focused: its workspace joins DP-1 below its own.
408- r.output <- ports.OutputRemoved{Name: "DP-2"}
409- set := receive(t, r.scenes)
410- if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 {
411- t.Fatal(shown(set))
412- }
413- // Cmd+2 shows the guest.
414- r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
415- set = receive(t, r.scenes)
416- r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt}
417- if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 {
418- t.Fatal(got)
419- }
420- // Replugged while on screen: the guest stays until the user leaves it.
421- set = r.plug(t, right)
422- if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 {
423- t.Fatal(got)
424- }
425- r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true}
426- set = receive(t, r.scenes)
427- if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
428- t.Fatal(got)
429- }
420+ both(t, func(t *testing.T, animated bool) {
421+ r := startLanding(t, animated, nil, left, right)
422+ r.mapLanded(t, 1)
423+ r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
424+ r.mapLanded(t, 2)
425+ // Unplug DP-2 while it is focused: its workspace joins DP-1 below its own.
426+ r.output <- ports.OutputRemoved{Name: "DP-2"}
427+ set := r.land(receive(t, r.scenes))
428+ if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 {
429+ t.Fatal(shown(set))
430+ }
431+ // Cmd+2 shows the guest.
432+ r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
433+ set = r.land(receive(t, r.scenes))
434+ r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt}
435+ if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 {
436+ t.Fatal(got)
437+ }
438+ // Replugged while on screen: the guest stays until the user leaves it.
439+ set = r.land(r.plug(t, right))
440+ if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 {
441+ t.Fatal(got)
442+ }
443+ r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true}
444+ set = r.land(receive(t, r.scenes))
445+ if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
446+ t.Fatal(got)
447+ }
448+ })
430449 }
431450
432451 func TestReplugReturnsHiddenGuestAtOnce(t *testing.T) {
@@ -763,38 +782,40 @@ func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) {
763782 }
764783
765784 func TestStateSnapshot(t *testing.T) {
766- r := startMulti(t, nil, left, right)
767- r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}
768- receive(t, r.scenes)
769- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
770- <-r.state // the channel holds the latest snapshot only
771- r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200}
772- receive(t, r.scenes)
773- // Snapshots from before window 2 may still be queued: wait for it.
774- st := receive(t, r.state)
775- for len(st.Windows) < 2 {
785+ both(t, func(t *testing.T, animated bool) {
786+ r := startLanding(t, animated, nil, left, right)
787+ r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}
788+ r.land(receive(t, r.scenes))
789+ r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
790+ <-r.state // the channel holds the latest snapshot only
791+ r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200}
792+ r.land(receive(t, r.scenes))
793+ // Snapshots from before window 2 may still be queued: wait for it.
794+ st := receive(t, r.state)
795+ for len(st.Windows) < 2 {
796+ st = receive(t, r.state)
797+ }
798+ want := ports.State{
799+ Output: "DP-2",
800+ Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}},
801+ Windows: []ports.WindowState{
802+ {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true},
803+ {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true},
804+ },
805+ }
806+ want.Window = &want.Windows[1]
807+ if !reflect.DeepEqual(st, want) {
808+ t.Fatalf("got %+v\nwant %+v", st, want)
809+ }
810+ // Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen.
811+ r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
812+ r.land(receive(t, r.scenes))
776813 st = receive(t, r.state)
777- }
778- want := ports.State{
779- Output: "DP-2",
780- Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}},
781- Windows: []ports.WindowState{
782- {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true},
783- {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true},
784- },
785- }
786- want.Window = &want.Windows[1]
787- if !reflect.DeepEqual(st, want) {
788- t.Fatalf("got %+v\nwant %+v", st, want)
789- }
790- // Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen.
791- r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
792- receive(t, r.scenes)
793- st = receive(t, r.state)
794- // The window keeps its workspace ID; the output now shows the new one.
795- if o := st.Outputs[1]; o.Name != "DP-2" || o.Active != 2 || o.Count != 2 || o.WorkspaceID == 0 || o.WorkspaceID == 2 || st.Windows[1].WorkspaceID != 2 || st.Windows[1].Visible || st.Window != nil {
796- t.Fatalf("%+v", st)
797- }
814+ // The window keeps its workspace ID; the output now shows the new one.
815+ if o := st.Outputs[1]; o.Name != "DP-2" || o.Active != 2 || o.Count != 2 || o.WorkspaceID == 0 || o.WorkspaceID == 2 || st.Windows[1].WorkspaceID != 2 || st.Windows[1].Visible || st.Window != nil {
816+ t.Fatalf("%+v", st)
817+ }
818+ })
798819 }
799820
800821 func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
@@ -1003,18 +1024,24 @@ func TestFocusColumnFromFloatStaysOnMonitor(t *testing.T) {
10031024 // and their popups wait, a click selects a peek, and the script state
10041025 // tells the stash and whether it is hidden.
10051026 func TestStashEndToEnd(t *testing.T) {
1006- r := startMulti(t, func(c *ports.Config) { c.Stash.Gap, c.Stash.Dim = 0, 0.5 }, left)
1027+ both(t, func(t *testing.T, animated bool) {
1028+ r := startLanding(t, animated, func(c *ports.Config) { c.Stash.Gap, c.Stash.Dim = 0, 0.5 }, left)
1029+ stashEndToEnd(t, r)
1030+ })
1031+}
1032+
1033+func stashEndToEnd(t *testing.T, r *landRig) {
10071034 for id := ports.WindowID(1); id <= 3; id++ {
1008- r.mapWindow(t, id)
1035+ r.mapLanded(t, id)
10091036 }
1010- r.key(t, "s", ports.ModAlt|ports.ModShift) // 3 stashed
1011- r.key(t, "Left", ports.ModAlt) // stays on 3: its stash's only window
1012- sc := r.key(t, "s", ports.ModAlt)
1037+ r.keyLanded(t, "s", ports.ModAlt|ports.ModShift) // 3 stashed
1038+ r.keyLanded(t, "Left", ports.ModAlt) // stays on 3: its stash's only window
1039+ sc := r.keyLanded(t, "s", ports.ModAlt)
10131040 if got := shown(sc)["DP-1"]; !slices.Equal(got, []ports.WindowID{1, 2}) {
10141041 t.Fatalf("hidden stash: %v", got)
10151042 }
1016- r.key(t, "Left", ports.ModAlt) // tiles: from 2 to 1
1017- sc = r.key(t, "s", ports.ModAlt|ports.ModShift)
1043+ r.keyLanded(t, "Left", ports.ModAlt) // tiles: from 2 to 1
1044+ sc = r.keyLanded(t, "s", ports.ModAlt|ports.ModShift)
10181045 // Stash 3 1: 1 selected in the middle, 3 peeking left, dimmed.
10191046 var peek, sel ports.SceneWindow
10201047 for _, w := range sc[0].Windows {
@@ -1040,7 +1067,7 @@ func TestStashEndToEnd(t *testing.T) {
10401067 // A click on the peek (x 0..19 of 200) selects it.
10411068 r.input <- ports.PointerMotion{X: 5, Y: 50}
10421069 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
1043- receive(t, r.scenes)
1070+ r.land(receive(t, r.scenes))
10441071 st := receive(t, r.state)
10451072 for st.Window == nil || st.Window.ID != 3 {
10461073 st = receive(t, r.state)
@@ -1056,7 +1083,7 @@ func TestStashEndToEnd(t *testing.T) {
10561083 t.Fatalf("tile %+v", w)
10571084 }
10581085 r.input <- ports.PointerButton{Button: 0x110}
1059- r.key(t, "s", ports.ModAlt)
1086+ r.keyLanded(t, "s", ports.ModAlt)
10601087 st = receive(t, r.state)
10611088 for !st.Windows[0].Hidden {
10621089 st = receive(t, r.state)
@@ -1182,11 +1209,16 @@ func TestDialogFocusStaysOnMonitor(t *testing.T) {
11821209 // A click on a tile behind the shown stash hides the stash and focuses
11831210 // the tile.
11841211 func TestStashHidesOnBackgroundClick(t *testing.T) {
1185- // Gap 10 at 80%: no peeks, the margins show the tile behind.
1186- r := startMulti(t, func(c *ports.Config) { c.Stash.Gap = 10 }, left)
1187- r.mapWindow(t, 1)
1188- r.mapWindow(t, 2)
1189- r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed and focused, over tile 1
1212+ both(t, func(t *testing.T, animated bool) {
1213+ // Gap 10 at 80%: no peeks, the margins show the tile behind.
1214+ stashHidesOnBackgroundClick(t, startLanding(t, animated, func(c *ports.Config) { c.Stash.Gap = 10 }, left))
1215+ })
1216+}
1217+
1218+func stashHidesOnBackgroundClick(t *testing.T, r *landRig) {
1219+ r.mapLanded(t, 1)
1220+ r.mapLanded(t, 2)
1221+ r.keyLanded(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed and focused, over tile 1
11901222 r.input <- ports.PointerMotion{X: 5, Y: 50}
11911223 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
11921224 st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 1 })
@@ -1510,12 +1542,17 @@ func TestTransformReload(t *testing.T) {
15101542 // A reload that shrinks the layout under a still pointer brings it back onto
15111543 // an output.
15121544 func TestTransformReloadClampsPointer(t *testing.T) {
1513- r := startMulti(t, func(c *ports.Config) {
1514- c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}}
1515- }, left, right)
1516- r.mapWindow(t, 1)
1517- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1518- r.mapWindow(t, 2)
1545+ both(t, func(t *testing.T, animated bool) {
1546+ transformReloadClampsPointer(t, startLanding(t, animated, func(c *ports.Config) {
1547+ c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}}
1548+ }, left, right))
1549+ })
1550+}
1551+
1552+func transformReloadClampsPointer(t *testing.T, r *landRig) {
1553+ r.mapLanded(t, 1)
1554+ r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
1555+ r.mapLanded(t, 2)
15191556 // DP-2 spans x 200..600: (550, 50) is (350, 50) on window 2.
15201557 for len(r.commands) > 0 {
15211558 <-r.commands
M internal/core/security.go +4 -7
@@ -33,13 +33,7 @@ func (c *Core) syncSecurity() bool {
3333 }
3434 c.dropSwipe()
3535 c.swipe = nil
36- for _, sc := range c.screens {
37- sc.mon.stopSwitch()
38- for w := range sc.mon.all() {
39- w.stopSlide()
40- }
41- }
42- c.stopFrame()
36+ c.stopAnimations()
4337 c.stopPulse()
4438 if state.Protected {
4539 c.dropCaptureSessions()
@@ -185,6 +179,9 @@ func (c *Core) publishProtected(ctx context.Context) error {
185179 scenes := make([]ports.Scene, 0, len(c.screens))
186180 for _, sc := range c.screens {
187181 c.seq++
182+ // The desktop scene that follows must not reuse a Seq from before.
183+ sc.last = ports.Scene{}
184+ sc.settledLayout, sc.shown = nil, nil
188185 o := sc.mon.Output()
189186 scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: "#000000"}
190187 if s, ok := c.lockSurface(sc); ok {
M internal/core/separators.go +8 -1
@@ -38,13 +38,20 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa
3838 if p.Floating {
3939 id = p.ID
4040 }
41+ if out == nil {
42+ // One line per side of every tile, and the focused one's four
43+ // lit lines: the common case grows no further.
44+ out = make([]ports.Separator, 0, 4*len(ps)+4)
45+ }
4146 out = append(out, ports.Separator{Rect: r, Active: active, Window: id})
4247 }
4348 var focused *Placement
4449 tiles := 0
4550 for i := range ps {
4651 p := &ps[i]
47- if p.Fullscreen || !onScreen(*p, o) {
52+ // A leaving float keeps its own lines while it fades; a leaving
53+ // tile's were shared with its neighbours, which re-flowed.
54+ if p.Fullscreen || !(onScreen(*p, o) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
4855 continue
4956 }
5057 if !p.Floating {
M internal/core/slide.go +22 -6
@@ -17,21 +17,32 @@ func (w *Workspace) slidable() bool {
1717 return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat()
1818 }
1919
20-func (w *Workspace) stopSlide() { w.shift, w.motion = 0, nil }
20+func (w *Workspace) stopSlide() { w.shift, w.motion = 0, motion{} }
2121
2222 // retarget keeps the view where it is on screen when ViewX moved from
2323 // before during a landing slide: the slide heads for the new view.
2424 // Without a landing slide the view moves at once, as it always has.
2525 func (w *Workspace) retarget(before int) {
26- if w.ViewX == before || w.motion == nil {
26+ if w.ViewX == before || !w.motion.on {
2727 return
2828 }
2929 w.shift += float64(before - w.ViewX)
30- w.motion = newMotion(viewSpring(w.shift, 0), time.Time{})
30+ w.motion = newMotion(viewSpring(w.shift, w.motion.velocity()), time.Time{}, w.motion.slow)
3131 }
3232
3333 func (w *Workspace) shiftPixels() int { return int(math.Round(w.shift)) }
3434
35+// switchSpring is the workspace slide's spring from off: it snaps once it
36+// is under half a logical pixel of the output height from its target
37+// (slideLayout rounds the offset times the height).
38+func (m *Monitor) switchSpring(off, velocity float64) spring {
39+ s := workspaceSpring(off, velocity)
40+ if h := m.template.Output.H; h > 0 {
41+ s.Snap = pixelSnap / float64(h)
42+ }
43+ return s
44+}
45+
3546 // swipeScale turns touchpad distance into pixels: viewSwipeMovement
3647 // scrolls one usable width.
3748 func (w *Workspace) swipeScale() float64 {
@@ -105,7 +116,7 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) {
105116 return focus
106117 }
107118
108-func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, nil, nil }
119+func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, motion{}, nil }
109120
110121 // framedSwitch keeps transitions involving a sized workspace settled. A
111122 // monitor-wide animation has no per-workspace crop; it must not expose
@@ -163,12 +174,17 @@ func (m *Monitor) slideLayout(cur []Placement) []Placement {
163174 if k == base || k < 0 || k >= len(list) || !m.has(list[k]) {
164175 continue
165176 }
166- next := list[k].Layout()
177+ next := list[k].layoutInto(m.slideBuf)
178+ m.slideBuf = next
167179 for i := range next {
168180 next[i].Focused = false
169181 }
170182 // Windows of the neighbor are already listed hidden: replace them.
171- shown := map[WindowID]Placement{}
183+ if m.slideShown == nil {
184+ m.slideShown = map[WindowID]Placement{}
185+ }
186+ shown := m.slideShown
187+ clear(shown)
172188 for _, p := range offset(next, k) {
173189 shown[p.ID] = p
174190 }
M internal/core/slots_test.go +53 -5
@@ -21,14 +21,30 @@ type slotRig struct {
2121 workspaces chan ports.Workspaces
2222 errs chan error
2323 cfg ports.Config
24+ // With animations on and a clock the test moves (startSlotsMode),
25+ // land flips the output past every spring.
26+ clk *stepClock
27+ frames chan ports.OutputFrame
28+ lands int
2429 }
2530
2631 // startSlots runs core with a hidden "dev" workspace of two slots, toggled
2732 // by Alt+d, on a 100x80 output. One flag makes dev numbered, two also set
2833 // fixed overflow.
2934 func startSlots(t *testing.T, numbered ...bool) *slotRig {
35+ t.Helper()
36+ return startSlotsMode(t, nil, numbered...)
37+}
38+
39+// startSlotsMode is startSlots with animations forced off (*false) or on
40+// with a clock the test moves (*true), whose springs land through land;
41+// nil keeps the default config on the system clock.
42+func startSlotsMode(t *testing.T, animated *bool, numbered ...bool) *slotRig {
3043 t.Helper()
3144 cfg := altCmdDefaults()
45+ if animated != nil {
46+ cfg.Animations.On = *animated
47+ }
3248 cfg.Border.Width = 0
3349 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{
3450 {Index: 1, Width: "70%", Argv: []string{"code"}},
@@ -45,7 +61,17 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
4561 }
4662 output := make(chan ports.OutputEvent, 1)
4763 commands := make(chan ports.ClientCommand, 1024)
48- c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, Workspaces: r.workspaces, ConfigErrors: r.errs})
64+ ch := core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, Workspaces: r.workspaces, ConfigErrors: r.errs}
65+ if animated != nil && *animated {
66+ r.clk, r.frames = newStepClock(t), make(chan ports.OutputFrame)
67+ ch.Clock, ch.Frames = r.clk.clock, r.frames
68+ t.Cleanup(func() {
69+ if r.lands == 0 {
70+ t.Error("no spring ever ran: the animations-on variant checked nothing")
71+ }
72+ })
73+ }
74+ c, err := core.New(cfg, ch)
4975 if err != nil {
5076 t.Fatal(err)
5177 }
@@ -62,6 +88,22 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
6288 return r
6389 }
6490
91+// land is s once every spring has landed: the scene the flips publish, or s
92+// itself when nothing animates (always with animations off or on the
93+// system clock).
94+func (r *slotRig) land(t *testing.T, s ports.Scene) ports.Scene {
95+ t.Helper()
96+ if r.clk == nil {
97+ return s
98+ }
99+ set, ok := r.clk.settle(t, r.frames, r.scenes, "OUT-1")
100+ if !ok {
101+ return s
102+ }
103+ r.lands++
104+ return set[0]
105+}
106+
65107 // token returns the SlotEnv value of a spawn request.
66108 func token(t *testing.T, req ports.SpawnRequest) string {
67109 t.Helper()
@@ -194,7 +236,10 @@ func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
194236 }
195237
196238 func TestSlotRefilledOnlyWhenShown(t *testing.T) {
197- r := startSlots(t)
239+ both(t, func(t *testing.T, animated bool) { slotRefilledOnlyWhenShown(t, startSlotsMode(t, &animated)) })
240+}
241+
242+func slotRefilledOnlyWhenShown(t *testing.T, r *slotRig) {
198243 // Wait for both windows: a one-window scene from before the unmap
199244 // would let the key below race it.
200245 _, foot := r.fill(t)
@@ -209,7 +254,7 @@ func TestSlotRefilledOnlyWhenShown(t *testing.T) {
209254 }
210255 r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)}
211256 s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 })
212- if got := visible(s); got[4].X != 70 {
257+ if got := visible(r.land(t, s)); got[4].X != 70 {
213258 t.Fatal(got)
214259 }
215260 // Leaving and showing dev again with full slots spawns nothing.
@@ -344,7 +389,10 @@ func TestSlotOldTokenIsNormal(t *testing.T) {
344389 // A slot whose command never maps a window gets one show to do it; on the
345390 // next show it is respawned and its old token no longer fills it.
346391 func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {
347- r := startSlots(t)
392+ both(t, func(t *testing.T, animated bool) { pendingSlotRespawnedOnSecondShow(t, startSlotsMode(t, &animated)) })
393+}
394+
395+func pendingSlotRespawnedOnSecondShow(t *testing.T, r *slotRig) {
348396 code, foot := receive(t, r.spawn), receive(t, r.spawn)
349397 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
350398 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
@@ -363,7 +411,7 @@ func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {
363411 }
364412 r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)}
365413 s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
366- if got := visible(s); got[8].W != 30 {
414+ if got := visible(r.land(t, s)); got[8].W != 30 {
367415 t.Fatalf("respawned window not in its slot: %v", got)
368416 }
369417 }
M internal/core/stash.go +8 -3
@@ -191,6 +191,12 @@ func (w *Workspace) stashOverCover() bool {
191191 return w.stashOver != 0 && w.stashOver == w.cover() && !w.stashHidden && len(w.Stash) > 0
192192 }
193193
194+// stashShown reports whether the stash is on screen: it holds windows, is
195+// not hidden, and no covering fullscreen window hides it.
196+func (w *Workspace) stashShown() bool {
197+ return len(w.Stash) > 0 && !w.stashHidden && (w.cover() == 0 || w.stashOverCover())
198+}
199+
194200 // dropStashOver ends the stash override when the focus leaves it: the
195201 // stash hides again under the covering window, as it was.
196202 func (w *Workspace) dropStashOver() {
@@ -217,18 +223,17 @@ func (w *Workspace) stashRect() Rect {
217223 return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh}
218224 }
219225
220-// stashLayout places the stash: the selected window centred, its
226+// appendStash appends the placements of the stash to out: the selected window centred, its
221227 // neighbors gap% of the usable width away, showing what the margins
222228 // leave of them, the others hidden. cover is the covering fullscreen
223229 // window, if any.
224-func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement {
230+func (w *Workspace) appendStash(out []Placement, focusedID, cover WindowID) []Placement {
225231 u := w.Usable
226232 center := w.stashRect()
227233 fw, fh := center.W, center.H
228234 // Peeks stay in the margins: they never overlap the selected window.
229235 gap := u.W * w.stashGap / 100
230236 peek := max((u.W-fw)/2-gap, 0)
231- out := make([]Placement, 0, len(w.Stash))
232237 for i, f := range w.Stash {
233238 p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll}
234239 switch {
M internal/core/state.go +4 -1
@@ -38,7 +38,10 @@ func (c *Core) state() ports.State {
3838 st.Outputs = append(st.Outputs, o)
3939 // Visible follows the configures: drawn on the output.
4040 shown := map[WindowID]bool{}
41- for _, p := range m.Layout() {
41+ // Read at once, into publish's scratch: the snapshot holds no
42+ // placement.
43+ c.realBuf = m.layoutInto(c.realBuf)
44+ for _, p := range c.realBuf {
4245 shown[p.ID] = onScreen(p, m.Frame())
4346 }
4447 for w := range m.all() {
A internal/core/step_clock_test.go +150 -0
@@ -0,0 +1,150 @@
1+package core_test
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/stretchr/testify/mock"
11+)
12+
13+// stepClock is a clock the test moves: springs sample it, so a test lands
14+// them by advancing it and sending page flips, never by sleeping. Its
15+// timers never fire: frames come from the test.
16+type stepClock struct {
17+ mu sync.Mutex
18+ now time.Time
19+ clock ports.Clock
20+}
21+
22+func newStepClock(t *testing.T) *stepClock {
23+ t.Helper()
24+ s := &stepClock{now: time.Unix(100, 0)}
25+ clock := portsmocks.NewMockClock(t)
26+ clock.EXPECT().Now().RunAndReturn(func() time.Time {
27+ s.mu.Lock()
28+ defer s.mu.Unlock()
29+ return s.now
30+ }).Maybe()
31+ // One timer built before core runs: a mock created by core while the
32+ // test ends would race its own cleanup check.
33+ timer := portsmocks.NewMockTimer(t)
34+ timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
35+ timer.EXPECT().Stop().Return(true).Maybe()
36+ timer.EXPECT().Reset(mock.Anything).Return(false).Maybe()
37+ clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()
38+ s.clock = clock
39+ return s
40+}
41+
42+func (s *stepClock) advance(d time.Duration) {
43+ s.mu.Lock()
44+ s.now = s.now.Add(d)
45+ s.mu.Unlock()
46+}
47+
48+// flip moves the clock by d and sends a page flip for each output, then
49+// returns the scene set core published for them (ok false: none, so no
50+// spring ran). Core publishes before it takes its next event, so a flip that
51+// names no output is a barrier: once its send returns, every earlier event
52+// is handled and the scene channel holds the result. One is sent first too,
53+// and a scene left over from earlier events is dropped, so a scene found
54+// afterwards is the flips' own.
55+func (s *stepClock) flip(t *testing.T, frames chan ports.OutputFrame, scenes chan []ports.Scene, d time.Duration, outputs ...string) ([]ports.Scene, bool) {
56+ t.Helper()
57+ send(frames, ports.OutputFrame{Output: "sync"})
58+ select {
59+ case <-scenes:
60+ default:
61+ }
62+ s.advance(d)
63+ for _, o := range outputs {
64+ send(frames, ports.OutputFrame{Output: o})
65+ }
66+ send(frames, ports.OutputFrame{Output: "sync"})
67+ select {
68+ case set := <-scenes:
69+ return set, true
70+ default:
71+ return nil, false
72+ }
73+}
74+
75+// settle is flip past every spring.
76+func (s *stepClock) settle(t *testing.T, frames chan ports.OutputFrame, scenes chan []ports.Scene, outputs ...string) ([]ports.Scene, bool) {
77+ t.Helper()
78+ return s.flip(t, frames, scenes, 5*time.Second, outputs...)
79+}
80+
81+// landRig is a multiRig whose settle lands every running spring: with
82+// animations on it runs on a clock the test moves (swipeRig) and sends the
83+// page flips; with animations off it is the plain rig and land is the
84+// identity. Each test body runs once per mode, so the off variant keeps
85+// its meaning and the on variant asserts the same settled facts.
86+type landRig struct {
87+ *multiRig
88+ land func([]ports.Scene) []ports.Scene
89+ // lands counts the settles that found a spring running.
90+ lands *int
91+}
92+
93+// startLanding runs core on the given outputs (gaps and terminal policy as
94+// in startMulti) with animations on or off.
95+func startLanding(t *testing.T, animated bool, edit func(*ports.Config), outs ...ports.OutputInfo) *landRig {
96+ t.Helper()
97+ if !animated {
98+ r := startMulti(t, func(c *ports.Config) {
99+ if edit != nil {
100+ edit(c)
101+ }
102+ c.Animations.On = false
103+ }, outs...)
104+ return &landRig{multiRig: r, land: func(set []ports.Scene) []ports.Scene { return set }, lands: new(int)}
105+ }
106+ defaults := altCmdDefaults()
107+ r := startSwipeOn(t, func(c *ports.Config) {
108+ c.Layout.Gaps, c.Terminal.AutoOpen = defaults.Layout.Gaps, defaults.Terminal.AutoOpen
109+ if edit != nil {
110+ edit(c)
111+ }
112+ c.Animations.On = true
113+ }, outs...)
114+ lands := new(int)
115+ // An on variant that never saw a spring would be the off variant again.
116+ t.Cleanup(func() {
117+ if *lands == 0 {
118+ t.Error("no spring ever ran: the animations-on variant checked nothing")
119+ }
120+ })
121+ return &landRig{multiRig: r.multiRig, lands: lands, land: func(set []ports.Scene) []ports.Scene {
122+ landed, ok := r.settleAll(t, r.outs...)
123+ if !ok {
124+ return set
125+ }
126+ *lands++
127+ return landed
128+ }}
129+}
130+
131+// both runs body with animations off, then on. A rig of the on run fails the
132+// test when it never settled a running spring (startLanding,
133+// startAnimatedDragRig).
134+func both(t *testing.T, body func(t *testing.T, animated bool)) {
135+ t.Helper()
136+ t.Run("off", func(t *testing.T) { body(t, false) })
137+ t.Run("on", func(t *testing.T) { body(t, true) })
138+}
139+
140+// keyLanded presses a key and returns the settled scene set.
141+func (r *landRig) keyLanded(t *testing.T, sym string, mods ports.Mods) []ports.Scene {
142+ t.Helper()
143+ return r.land(r.key(t, sym, mods))
144+}
145+
146+// mapLanded maps a window and returns the settled scene set.
147+func (r *landRig) mapLanded(t *testing.T, id ports.WindowID) []ports.Scene {
148+ t.Helper()
149+ return r.land(r.mapWindow(t, id))
150+}
M internal/core/swipe_test.go +312 -73
@@ -2,14 +2,12 @@ package core_test
22
33 import (
44 "context"
5- "sync"
5+ "slices"
66 "testing"
77 "time"
88
99 "github.com/bnema/neferwl/internal/core"
10- portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1110 "github.com/bnema/neferwl/internal/ports"
12- "github.com/stretchr/testify/mock"
1311 )
1412
1513 // swipeRig runs core on one 800x600 output with no gaps or border, a
@@ -17,43 +15,40 @@ import (
1715 type swipeRig struct {
1816 *multiRig
1917 frames chan ports.OutputFrame
20- mu sync.Mutex
21- now time.Time
18+ clk *stepClock
2219 // at is the device time of the next swipe event.
2320 at time.Duration
21+ // outs are the plugged outputs, for landed.
22+ outs []string
2423 }
2524
2625 var wide = ports.OutputInfo{Name: "DP-1", Width: 800, Height: 600, RefreshMilli: 60000}
2726
2827 func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig {
28+ t.Helper()
29+ return startSwipeOn(t, edit, wide)
30+}
31+
32+// startSwipeOn is startSwipe with the given outputs plugged in order.
33+func startSwipeOn(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *swipeRig {
2934 t.Helper()
3035 cfg := altCmdDefaults()
3136 cfg.Border.Width = 0
3237 cfg.Layout.Gaps = 0
3338 cfg.Terminal.AutoOpen = "never"
39+ // The rig steps animations frame by frame: they are on unless edit
40+ // turns them off.
41+ cfg.Animations.On = true
3442 if edit != nil {
3543 edit(&cfg)
3644 }
37- r := &swipeRig{frames: make(chan ports.OutputFrame), now: time.Unix(100, 0)}
45+ r := &swipeRig{frames: make(chan ports.OutputFrame), clk: newStepClock(t)}
3846 r.multiRig = &multiRig{
3947 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 64),
4048 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
4149 commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,
4250 }
43- clock := portsmocks.NewMockClock(t)
44- clock.EXPECT().Now().RunAndReturn(func() time.Time {
45- r.mu.Lock()
46- defer r.mu.Unlock()
47- return r.now
48- }).Maybe()
49- // The fallback timer never fires: frames come from the test.
50- clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
51- timer := portsmocks.NewMockTimer(t)
52- timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
53- timer.EXPECT().Stop().Return(true).Maybe()
54- return timer
55- }).Maybe()
56- c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Clock: clock, Frames: r.frames})
51+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Clock: r.clk.clock, Frames: r.frames})
5752 if err != nil {
5853 t.Fatal(err)
5954 }
@@ -61,14 +56,28 @@ func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig {
6156 done := make(chan error, 1)
6257 go func() { done <- c.Run(ctx) }()
6358 t.Cleanup(func() { cancel(); <-done })
64- r.plug(t, wide)
59+ for _, o := range outs {
60+ r.plug(t, o)
61+ r.outs = append(r.outs, o.Name)
62+ }
6563 return r
6664 }
6765
6866 func (r *swipeRig) advance(d time.Duration) {
69- r.mu.Lock()
70- r.now = r.now.Add(d)
71- r.mu.Unlock()
67+ r.clk.advance(d)
68+}
69+
70+// mapWindow maps a window and, with animations on, lands its entrance (and
71+// the neighbours' re-flow) so the scene is the settled one: the tests of a
72+// swipe rig look at what the map leaves. The entrance itself is checked on
73+// the raw multiRig.mapWindow (TestMap*).
74+func (r *swipeRig) mapWindow(t *testing.T, id ports.WindowID) []ports.Scene {
75+ t.Helper()
76+ set := r.multiRig.mapWindow(t, id)
77+ if landed, ok := r.settleAll(t, r.outs...); ok {
78+ return landed
79+ }
80+ return set
7281 }
7382
7483 // begin starts a swipe; nothing moves until it picks an axis.
@@ -105,6 +114,13 @@ func (r *swipeRig) settle(t *testing.T) ports.Scene {
105114 return r.frame(t, 5*time.Second)
106115 }
107116
117+// settleAll moves the clock past every spring, flips the outputs and
118+// returns the scene set that results (ok false: nothing was animating).
119+func (r *swipeRig) settleAll(t *testing.T, outputs ...string) ([]ports.Scene, bool) {
120+ t.Helper()
121+ return r.clk.settle(t, r.frames, r.scenes, outputs...)
122+}
123+
108124 // flick sends a quick swipe of n updates of (dx, dy) and lifts the
109125 // fingers; it returns the scene right after the lift.
110126 func (r *swipeRig) flick(t *testing.T, n int, dx, dy float64) ports.Scene {
@@ -121,9 +137,19 @@ func (r *swipeRig) flick(t *testing.T, n int, dx, dy float64) ports.Scene {
121137 func (r *swipeRig) workspaces(t *testing.T) {
122138 t.Helper()
123139 r.mapWindow(t, 1)
124- r.key(t, "Next", ports.ModAlt)
140+ r.keySettled(t, "Next")
125141 r.mapWindow(t, 2)
126- r.key(t, "Prior", ports.ModAlt)
142+ r.keySettled(t, "Prior")
143+}
144+
145+// keySettled presses a workspace key and waits for the slide it starts, so
146+// the next step begins from the settled view.
147+func (r *swipeRig) keySettled(t *testing.T, sym string) {
148+ t.Helper()
149+ r.key(t, sym, ports.ModAlt)
150+ if r.cfg.Animations.On {
151+ r.settle(t)
152+ }
127153 }
128154
129155 func rectOf(s ports.Scene, id ports.WindowID) (ports.Rect, bool) {
@@ -324,8 +350,8 @@ func TestSwipeFromEmptyWorkspaceLandsWithoutJump(t *testing.T) {
324350 r := startSwipe(t, nil)
325351 r.workspaces(t)
326352 // Workspace 3 is the trailing empty one; start there and swipe up to 2.
327- r.key(t, "Next", ports.ModAlt)
328- r.key(t, "Next", ports.ModAlt)
353+ r.keySettled(t, "Next")
354+ r.keySettled(t, "Next")
329355 r.begin()
330356 var s ports.Scene
331357 for range 6 {
@@ -382,6 +408,10 @@ func TestSwipeDropsWhenWorkspacesChange(t *testing.T) {
382408 r.move(t, 0, 30)
383409 // A key switches workspace mid-swipe: the swipe lets go at once.
384410 s := r.key(t, "Next", ports.ModAlt)[0]
411+ if got, ok := rectOf(s, 2); !ok || got.Y == 0 {
412+ t.Fatalf("workspace 2 at %v %t after the key, want the old position", got, ok)
413+ }
414+ s = r.settle(t)
385415 if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
386416 t.Fatalf("workspace 2 at %v %t after the key", got, ok)
387417 }
@@ -406,16 +436,8 @@ func TestSlideMovesPointerFocus(t *testing.T) {
406436 r.settle(t)
407437 var focus ports.PointerFocus
408438 var motion ports.PointerMotionTo
409- // Core sends the pointer update right after the scene: wait for it.
410- for {
411- var v ports.ClientCommand
412- select {
413- case v = <-r.commands:
414- case <-time.After(50 * time.Millisecond):
415- }
416- if v == nil {
417- break
418- }
439+ // Core sends the pointer update right after the scene.
440+ for _, v := range r.sent() {
419441 switch v := v.(type) {
420442 case ports.PointerFocus:
421443 focus = v
@@ -478,7 +500,11 @@ func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) {
478500 // The pointer goes to DP-2 and a bind switches its workspace.
479501 r.input <- ports.PointerMotion{X: 1000, Y: 300}
480502 scene(t, r.scenes)
481- for _, sc := range r.key(t, "Next", ports.ModAlt) {
503+ r.key(t, "Next", ports.ModAlt)
504+ // DP-2 slides to its other workspace on its own flips.
505+ r.advance(5 * time.Second)
506+ r.frames <- ports.OutputFrame{Output: "DP-2"}
507+ for _, sc := range receive(t, r.scenes) {
482508 if _, ok := rectOf(sc, 4); ok {
483509 t.Fatal("DP-2 did not switch workspace")
484510 }
@@ -527,15 +553,7 @@ func TestDiscreteSwipeSkippedWhenPointerChangesOutput(t *testing.T) {
527553 scene(t, r.scenes)
528554 r.drain()
529555 r.input <- ports.SwipeEnd{Time: r.at}
530- for {
531- var v ports.ClientCommand
532- select {
533- case v = <-r.commands:
534- case <-time.After(50 * time.Millisecond):
535- }
536- if v == nil {
537- break
538- }
556+ for _, v := range r.sent() {
539557 if f, ok := v.(ports.FocusWindow); ok && f.ID == 3 {
540558 t.Fatal("swipe from DP-1 moved the focus on DP-2")
541559 }
@@ -544,22 +562,22 @@ func TestDiscreteSwipeSkippedWhenPointerChangesOutput(t *testing.T) {
544562
545563 // drain waits for core to handle what was sent, then empties commands.
546564 func (r *swipeRig) drain() {
547- for {
548- select {
549- case <-r.commands:
550- case <-time.After(50 * time.Millisecond):
551- return
552- }
565+ for range r.sent() {
553566 }
554567 }
555568
556-func TestKeyWithoutSlideMovesAtOnce(t *testing.T) {
557- r := startSwipe(t, nil)
558- threeColumns(t, r)
559- r.key(t, "Left", ports.ModAlt)
560- s := r.key(t, "Left", ports.ModAlt)[0]
561- if got, _ := rectOf(s, 1); got.X != 0 {
562- t.Fatalf("column 1 at %d", got.X)
569+// sent waits for core to handle what was sent and returns the commands it
570+// sent meanwhile (the sync flip is a barrier, see published).
571+func (r *swipeRig) sent() []ports.ClientCommand {
572+ send(r.frames, ports.OutputFrame{Output: "sync"})
573+ var out []ports.ClientCommand
574+ for {
575+ select {
576+ case v := <-r.commands:
577+ out = append(out, v)
578+ default:
579+ return out
580+ }
563581 }
564582 }
565583
@@ -650,6 +668,179 @@ func TestFourFingerSwipeOverview(t *testing.T) {
650668 }
651669 }
652670
671+// four lifts a four-finger swipe of n updates of (dx, dy) after the
672+// fingers went down, and returns the scene right after the lift.
673+func (r *swipeRig) four(t *testing.T, dx, dy float64, n int) ports.Scene {
674+ t.Helper()
675+ r.at += time.Second
676+ r.input <- ports.SwipeBegin{Fingers: 4, Time: r.at}
677+ for range n {
678+ r.at += 8 * time.Millisecond
679+ r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at}
680+ }
681+ return r.end(t, false)
682+}
683+
684+// stashedThird maps three windows and stashes the third (shown, focused),
685+// settled.
686+func stashedThird(t *testing.T, r *swipeRig) ports.Scene {
687+ t.Helper()
688+ threeColumns(t, r)
689+ s := r.key(t, "s", ports.ModAlt|ports.ModShift)[0]
690+ if r.cfg.Animations.On {
691+ s = r.settle(t)
692+ }
693+ return s
694+}
695+
696+// A four-finger swipe down with the overview closed hides the stash, and
697+// the next one shows it again with its windows fading in; the stash's
698+// windows stay where the swipe found them otherwise.
699+func TestFourFingerSwipeDownTogglesStash(t *testing.T) {
700+ r := startSwipe(t, nil)
701+ if !r.cfg.Animations.On {
702+ t.Fatal("animations are off in the rig")
703+ }
704+ if w := windowOf(t, stashedThird(t, r), 3); w.Hidden {
705+ t.Fatalf("setup: the stash is hidden: %+v", w)
706+ }
707+ r.four(t, 0, 40, 10)
708+ if w := windowOf(t, r.frame(t, 15*time.Millisecond), 3); w.Hidden || !(w.Fade > 0 && w.Fade < 1) {
709+ t.Fatalf("hiding: window 3 hidden %t, fade %v, want it leaving", w.Hidden, w.Fade)
710+ }
711+ if w := windowOf(t, r.settle(t), 3); !w.Hidden {
712+ t.Fatalf("swipe down left the stash shown: %+v", w)
713+ }
714+ r.four(t, 0, 40, 10)
715+ if w := windowOf(t, r.frame(t, 15*time.Millisecond), 3); w.Hidden || !(w.Fade > 0 && w.Fade < 1) {
716+ t.Fatalf("showing: window 3 hidden %t, fade %v, want it appearing", w.Hidden, w.Fade)
717+ }
718+ if w := windowOf(t, r.settle(t), 3); w.Hidden || w.Fade != 0 {
719+ t.Fatalf("second swipe down did not show the stash: %+v", w)
720+ }
721+}
722+
723+// A four-finger swipe up hides the shown stash, with the same fade as a
724+// swipe down, instead of opening the overview.
725+func TestFourFingerSwipeUpHidesShownStash(t *testing.T) {
726+ r := startSwipe(t, nil)
727+ stashedThird(t, r)
728+ s := r.four(t, 0, -40, 10)
729+ for _, w := range s.Windows {
730+ if w.Preview > 0 {
731+ t.Fatal("swipe up opened the overview with the stash shown")
732+ }
733+ }
734+ if w := windowOf(t, r.frame(t, 15*time.Millisecond), 3); w.Hidden || !(w.Fade > 0 && w.Fade < 1) {
735+ t.Fatalf("hiding: window 3 hidden %t, fade %v, want it leaving", w.Hidden, w.Fade)
736+ }
737+ s = r.settle(t)
738+ if w := windowOf(t, s, 3); !w.Hidden {
739+ t.Fatalf("swipe up left the stash shown: %+v", w)
740+ }
741+ for _, w := range s.Windows {
742+ if w.Preview > 0 {
743+ t.Fatal("overview open after the stash hid")
744+ }
745+ }
746+}
747+
748+// With the stash hidden or empty a swipe up opens the overview.
749+func TestFourFingerSwipeUpOpensOverviewWithoutShownStash(t *testing.T) {
750+ inOverview := func(s ports.Scene) bool {
751+ for _, w := range s.Windows {
752+ if w.Preview > 0 {
753+ return true
754+ }
755+ }
756+ return false
757+ }
758+ for _, name := range []string{"hidden", "empty"} {
759+ r := startSwipe(t, nil)
760+ if name == "hidden" {
761+ stashedThird(t, r)
762+ r.key(t, "s", ports.ModAlt)
763+ r.settle(t)
764+ } else {
765+ threeColumns(t, r)
766+ }
767+ r.four(t, 0, -40, 10)
768+ if !inOverview(r.settle(t)) {
769+ t.Fatalf("%s stash: swipe up did not open the overview", name)
770+ }
771+ }
772+}
773+
774+// The stash shown over a covering fullscreen window is on screen: a swipe
775+// up hides it and leaves the window fullscreen.
776+func TestFourFingerSwipeUpHidesStashOverCover(t *testing.T) {
777+ r := startSwipe(t, nil)
778+ stashedThird(t, r)
779+ r.key(t, "s", ports.ModAlt) // hide
780+ r.settle(t)
781+ r.key(t, "f", ports.ModAlt|ports.ModShift)
782+ r.settleAll(t, r.outs...)
783+ shown := r.key(t, "s", ports.ModAlt)[0] // show over the cover
784+ sc, _ := r.settleAll(t, r.outs...)
785+ if len(sc) == 0 {
786+ sc = []ports.Scene{shown}
787+ }
788+ if w := windowOf(t, sc[0], 3); w.Hidden {
789+ t.Fatalf("setup: the stash is hidden over the cover: %+v", w)
790+ }
791+ s := r.four(t, 0, -40, 10)
792+ for _, w := range s.Windows {
793+ if w.Preview > 0 {
794+ t.Fatal("swipe up opened the overview with the stash over the cover")
795+ }
796+ }
797+ // Over a cover the stash hides without a fade: the scene is final.
798+ if w := windowOf(t, s, 3); !w.Hidden {
799+ t.Fatalf("swipe up left the stash over the cover: %+v", w)
800+ }
801+ if w := windowOf(t, s, 2); !w.Fullscreen {
802+ t.Fatalf("the covering window left fullscreen: %+v", w)
803+ }
804+}
805+
806+// With nothing in the stash a swipe down changes nothing: the scene is the
807+// same one, with no new Seq and no animation.
808+func TestFourFingerSwipeDownEmptyStash(t *testing.T) {
809+ r := startSwipe(t, nil)
810+ before := threeColumns(t, r)
811+ after := r.four(t, 0, 40, 10)
812+ if after.Seq != before.Seq || !after.SameAs(before) {
813+ t.Fatalf("empty stash: scene changed, seq %d then %d", before.Seq, after.Seq)
814+ }
815+ if _, ok := r.settleAll(t, r.outs...); ok {
816+ t.Fatal("empty stash: the swipe started an animation")
817+ }
818+}
819+
820+// With the overview open a swipe down closes it and does not touch the
821+// stash, shown or hidden.
822+func TestFourFingerSwipeDownInOverviewKeepsStash(t *testing.T) {
823+ for _, hidden := range []bool{false, true} {
824+ r := startSwipe(t, nil)
825+ stashedThird(t, r)
826+ if hidden {
827+ r.key(t, "s", ports.ModAlt)
828+ r.settle(t)
829+ }
830+ r.four(t, 0, -40, 10)
831+ r.settle(t)
832+ s := r.four(t, 0, 40, 10)
833+ for _, w := range s.Windows {
834+ if w.Preview > 0 {
835+ t.Fatalf("hidden=%v: swipe down left the overview open", hidden)
836+ }
837+ }
838+ if w := windowOf(t, r.settle(t), 3); w.Hidden != hidden {
839+ t.Fatalf("hidden=%v: window 3 hidden %t after the overview closed", hidden, w.Hidden)
840+ }
841+ }
842+}
843+
653844 // Opening the overview during a column swipe drops the swipe: the rest
654845 // of it neither slides the columns under the overview nor lands them,
655846 // also once the overview closed again (escape before the rest).
@@ -661,28 +852,40 @@ func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) {
661852 r.move(t, -40, 0)
662853 r.move(t, -40, 0)
663854 r.key(t, "o", ports.ModAlt)
855+ // s is the latest scene.
856+ var s ports.Scene
664857 if closeFirst {
665- r.key(t, "Escape", 0)
858+ s = r.key(t, "Escape", 0)[0]
666859 }
667860 for range 20 {
668861 r.at += 8 * time.Millisecond
669862 r.input <- ports.SwipeUpdate{DX: -80, Time: r.at}
670863 }
864+ // The updates publish nothing (the overview gesture, or a dropped
865+ // swipe); the lift always publishes, so its scene is received
866+ // before the next event: core is idle when the escape and the
867+ // flips arrive, each waits for its own scene.
671868 r.input <- ports.SwipeEnd{Time: r.at}
869+ s = scene(t, r.scenes)
672870 if !closeFirst {
673- r.input <- ports.KeyEvent{Keysym: "Escape", Pressed: true}
871+ s = r.key(t, "Escape", 0)[0]
674872 }
675- // Past any spring: the view shows where it settled.
676- r.advance(5 * time.Second)
677- r.frames <- ports.OutputFrame{Output: wide.Name}
678- s := sceneMatch(t, r.scenes, func(s ports.Scene) bool {
679- for _, w := range s.Windows {
680- if w.Preview > 0 {
681- return false
682- }
873+ // Past any spring: the escape's own springs (the cards zooming
874+ // back) may chain after the swipe's landing, so settle until
875+ // none runs.
876+ for range 4 {
877+ set, ok := r.settleAll(t, wide.Name)
878+ if !ok {
879+ break
683880 }
684- return true
685- })
881+ s = set[0]
882+ }
883+ if len(s.Windows) == 0 || slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool {
884+ // Neither a card nor a window still zooming back.
885+ return w.Preview > 0 || w.Zoom > 0 || w.Fade > 0
886+ }) {
887+ t.Fatalf("closeFirst=%v: the scene never settled: %+v", closeFirst, s.Windows)
888+ }
686889 for _, id := range []ports.WindowID{1, 2, 3} {
687890 before, _ := rectOf(settled, id)
688891 if after, _ := rectOf(s, id); before != after {
@@ -825,3 +1028,39 @@ func TestSwipeWindowMappedMidSwipe(t *testing.T) {
8251028 }
8261029 }
8271030 }
1031+
1032+// A press that moves the view during a landing slide redirects the slide
1033+// without stopping or reversing it: the view keeps its speed.
1034+func TestSwipeRetargetKeepsVelocity(t *testing.T) {
1035+ r := startSwipe(t, nil)
1036+ for id := ports.WindowID(1); id <= 4; id++ {
1037+ r.mapWindow(t, id)
1038+ }
1039+ // Focus on 4, the view shows 3 and 4. Go back to the first column.
1040+ for range 3 {
1041+ r.key(t, "Left", ports.ModAlt)
1042+ }
1043+ r.settle(t)
1044+ // A flick toward the right lands on a later column: the view moves
1045+ // right, so column 1 moves left.
1046+ r.flick(t, 4, 40, 0)
1047+ mid := r.frame(t, 16*time.Millisecond)
1048+ prev, ok := rectOf(mid, 1)
1049+ if !ok {
1050+ t.Fatal("column 1 hidden during the landing")
1051+ }
1052+ if prev.X >= 0 {
1053+ t.Fatalf("view did not move right: column 1 at %d", prev.X)
1054+ }
1055+ r.key(t, "Right", ports.ModAlt)
1056+ // The retargeted spring keeps the speed of the landing: the view moves
1057+ // on from the first frame and never goes back.
1058+ for i := range 2 {
1059+ s := r.frame(t, 16*time.Millisecond)
1060+ got, _ := rectOf(s, 1)
1061+ if got.X > prev.X || (i == 1 && got.X == prev.X) {
1062+ t.Fatalf("frame %d: column 1 at %d after %d, the view reversed or stopped", i, got.X, prev.X)
1063+ }
1064+ prev = got
1065+ }
1066+}
A internal/core/transition.go +911 -0
@@ -0,0 +1,911 @@
1+package core
2+
3+import (
4+ "math"
5+ "slices"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// Transitions start after a user action (a bind, a click that focuses, a
12+// discrete swipe step): a snapshot of what each screen shows is taken before
13+// the action and diffed after it. The state the action leaves is the
14+// settled one; the transition only adds presentation springs over it
15+// (Workspace.shift, Monitor.switchOff), starting from what was on screen.
16+// Nothing here runs for events that come from clients or outputs.
17+//
18+// A window's rect transition (presentation-only rect transition) is presentation too: configures keep
19+// using the settled rect, so the client resizes once to its final size; what
20+// is drawn and hit-tested is the settled rect plus an offset that a spring
21+// takes to zero. The offsets are relative to the camera-shifted layout, so a
22+// window the view scrolls moves with the camera spring only.
23+
24+// viewShot is what one screen showed before an action.
25+type viewShot struct {
26+ sc *screen
27+ ws *Workspace
28+ // overview is set when the screen showed its overview: ws is then the
29+ // row on screen, the camera fields stay zero, and rects are the cards.
30+ overview bool
31+ // viewX is ws's settled view and view where its columns were on screen
32+ // (viewX plus shift).
33+ viewX int
34+ view float64
35+ // viewV and switchV are the speeds the running view and workspace
36+ // springs have at the snapshot's time (units per second, 0 without
37+ // one), not at their last frame. An action may stop them
38+ // (Monitor.Focus), so the transition reads them here.
39+ viewV, switchV float64
40+ // list and pos are the numbered workspaces and the monitor's fractional
41+ // position in them (index of the current one plus switchOff); ok is
42+ // false when there is none (a shown stash workspace).
43+ list []*Workspace
44+ pos float64
45+ ok bool
46+ // rects are the windows the screen drew (cards in the overview),
47+ // hidden windows left out. The slice is reused like list.
48+ rects []rectShot
49+ // stash holds the settled placements of the stash windows the screen
50+ // drew, for the ones an action hides (transitionStash). Reused too.
51+ stash []Placement
52+}
53+
54+// rectOffsets are the four components of a rect motion: x, y, w, h.
55+type rectOffsets struct{ x, y, w, h float64 }
56+
57+// rectShot is where a window was drawn: its settled rect plus the float
58+// offsets of its running rect motion (zero without one), which the drawn
59+// rect only rounds. vel is the speed of each offset component, in units per
60+// second. Offsets and speeds are sampled at the snapshot's time.
61+type rectShot struct {
62+ id WindowID
63+ rect Rect
64+ off, vel rectOffsets
65+ // fade and fadeV are the fade offset of a running fade motion and its
66+ // speed, so an overview toggle carries it over (transitionOverview).
67+ fade, fadeV float64
68+ // dim and dimV are the dim offset of a running dim motion and its
69+ // speed; veil is the peek veil the window's settled placement drew
70+ // (Stash.Dim, 0 for another window). A stash navigation takes the
71+ // veil from what was on screen to the new one (transitionRects).
72+ dim, dimV, veil float64
73+}
74+
75+// rectMotion takes a window's drawn rect to its settled one: each component
76+// is an offset from the settled value, sprung to 0 (dx, dy, dw, dh hold the
77+// current offsets, updated by advance). fade and dim are offsets too, from
78+// the settled Fade (0, or 1 for a leaving window) and the settled Dim.
79+//
80+// With scale, the window's content follows its drawn size: the shown
81+// placement gets a Zoom of the settled zoom (its Preview, or 1) times
82+// shown.W / settled.W (refreshShown), which the scene carries as Preview.
83+// The walk shrinks content toward the content rect's top-left, so this
84+// suits aspect-preserving changes: the zoom follows the smaller of the width
85+// and height ratios, so content never exceeds its frame when the aspect
86+// differs a little (rounding) or a lot (the frame is then letterboxed). An overview card's settled zoom is
87+// its Preview: a window that opens into a card goes from 1 to Preview with
88+// its width, and back.
89+//
90+// A leaving motion draws a window that closed or hid while it fades out:
91+// left is its last settled placement, which refreshShown appends (Hidden
92+// and Leaving) to the settled and shown layouts, keeping them aligned.
93+type rectMotion struct {
94+ x, y, w, h motion
95+ dx, dy, dw, dh float64
96+ fade, dim motion
97+ df, ddim float64
98+ scale bool
99+ leaving bool
100+ left Placement
101+}
102+
103+// apply is r moved by the current offsets; a size never drops below 1.
104+func (m *rectMotion) apply(r Rect) Rect {
105+ return Rect{
106+ X: r.X + int(math.Round(m.dx)), Y: r.Y + int(math.Round(m.dy)),
107+ W: max(r.W+int(math.Round(m.dw)), 1), H: max(r.H+int(math.Round(m.dh)), 1),
108+ }
109+}
110+
111+// sample is where the offsets and their speeds would be at now, without
112+// moving the motion: a running component is sampled on a copy.
113+func (m *rectMotion) sample(now time.Time) (off, vel rectOffsets) {
114+ off = rectOffsets{m.dx, m.dy, m.dw, m.dh}
115+ sampleComponent(m.x, &off.x, &vel.x, now)
116+ sampleComponent(m.y, &off.y, &vel.y, now)
117+ sampleComponent(m.w, &off.w, &vel.w, now)
118+ sampleComponent(m.h, &off.h, &vel.h, now)
119+ return off, vel
120+}
121+
122+func sampleComponent(m motion, off, vel *float64, now time.Time) {
123+ if m.on {
124+ *off, *vel = m.sampleAt(now)
125+ }
126+}
127+
128+// advance moves the offsets to now; a settled component stops at 0.
129+func (m *rectMotion) advance(now time.Time) {
130+ m.dx = stepComponent(&m.x, m.dx, now)
131+ m.dy = stepComponent(&m.y, m.dy, now)
132+ m.dw = stepComponent(&m.w, m.dw, now)
133+ m.dh = stepComponent(&m.h, m.dh, now)
134+ m.df = stepComponent(&m.fade, m.df, now)
135+ m.ddim = stepComponent(&m.dim, m.ddim, now)
136+}
137+
138+func stepComponent(m *motion, cur float64, now time.Time) float64 {
139+ if !m.on {
140+ return cur
141+ }
142+ v, done := m.at(now)
143+ if done {
144+ *m = motion{}
145+ return 0
146+ }
147+ return v
148+}
149+
150+// on reports whether any component still runs.
151+func (m *rectMotion) on() bool {
152+ return m.x.on || m.y.on || m.w.on || m.h.on || m.fade.on || m.dim.on
153+}
154+
155+// settledFade is the Fade the motion springs to: a leaving window fades
156+// out, any other is opaque.
157+func (m *rectMotion) settledFade() float64 {
158+ if m.leaving {
159+ return 1
160+ }
161+ return 0
162+}
163+
164+// show is p as drawn: its rect moved by the offsets, its fade and dim
165+// offsets added, and its content zoomed with its size when scale is set.
166+func (m *rectMotion) show(p *Placement) {
167+ settled := p.Rect
168+ p.Rect = m.apply(settled)
169+ p.Fade = max(0, min(1, m.settledFade()+m.df))
170+ // A peek's Dim is an offset from its veil (publish adds Stash.Dim and
171+ // clamps the sum): it may go below 0, down to cancelling the veil.
172+ lo := 0.0
173+ if p.Peek {
174+ lo = -1
175+ }
176+ p.Dim = max(lo, min(1, p.Dim+m.ddim))
177+ if m.scale && settled.W > 0 && settled.H > 0 {
178+ zoom := p.Preview
179+ if zoom == 0 {
180+ zoom = 1
181+ if m.leaving {
182+ // A leaving window settles shrunk, its content with it.
183+ zoom = appearScale
184+ }
185+ }
186+ zoom *= min(float64(p.Rect.W)/float64(settled.W), float64(p.Rect.H)/float64(settled.H))
187+ if zoom >= 0.999 {
188+ // Drawn at its size: content is never magnified. publish
189+ // emits no Zoom for a window, and Zoom 1 for a card (whose
190+ // Preview would shrink it).
191+ zoom = 1
192+ }
193+ p.Zoom = zoom
194+ }
195+}
196+
197+// retargetComponent starts m for an offset of off from its settled value at
198+// now, with the speed v it had then. A component with nothing to move and no
199+// speed stops.
200+func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now time.Time) {
201+ c.retargetWith(m, cur, viewSpring(off, v), now)
202+}
203+
204+// retargetLevel is retargetComponent for a fade or veil offset (a level in
205+// 0..1): its spring runs to its fine epsilon, no pixel snap.
206+func (c *Core) retargetLevel(m *motion, cur *float64, off, v float64, now time.Time) {
207+ c.retargetWith(m, cur, levelSpring(off, v), now)
208+}
209+
210+func (c *Core) retargetWith(m *motion, cur *float64, s spring, now time.Time) {
211+ *cur = s.From
212+ if s.From == 0 && s.Velocity == 0 {
213+ *m = motion{}
214+ return
215+ }
216+ *m = c.spring(s, now)
217+}
218+
219+// refreshShown builds the layouts of every screen once per publish: the
220+// settled one (what configures are sized from) and the shown one (the same
221+// with the rect motions applied: what is drawn and hit-tested). Rect motions
222+// belong to the workspace they began on, or to the open overview (rectsOwner):
223+// any other change drops them.
224+//
225+// Leaving motions add an entry for a window the layout no longer shows:
226+// appended to both layouts in ID order, Hidden (no input, focus, popups or
227+// configure) and Leaving (drawn, fading). The two layouts stay
228+// index-aligned: publish reads the settled entry at the shown one's index.
229+func (c *Core) refreshShown() {
230+ for _, sc := range c.screens {
231+ m := sc.mon
232+ if len(sc.rects) > 0 && sc.rectsWS != sc.rectsOwner() {
233+ sc.stopRects()
234+ }
235+ sc.layoutBuf = m.layoutInto(sc.layoutBuf)
236+ sc.settledLayout = sc.layoutBuf
237+ if len(sc.settledLayout) == 0 {
238+ // Empty stays nil, as a fresh Layout() was: shown nil falls
239+ // back to the live layout until the next publish.
240+ sc.settledLayout = nil
241+ }
242+ if len(sc.rects) > 0 {
243+ // A window that left the layout (or is hidden) keeps no motion:
244+ // it would ask for frames with nothing to move. A leaving one
245+ // is the opposite: it goes once the layout shows the window.
246+ for id, rm := range sc.rects {
247+ shown := slices.ContainsFunc(sc.settledLayout, func(p Placement) bool {
248+ return p.ID == id && !p.Hidden
249+ })
250+ if shown == rm.leaving {
251+ delete(sc.rects, id)
252+ }
253+ }
254+ }
255+ if len(sc.rects) == 0 {
256+ sc.shown = sc.settledLayout
257+ continue
258+ }
259+ sc.settledLayout = sc.withLeaving(sc.settledLayout)
260+ buf := append(sc.shownBuf[:0], sc.settledLayout...)
261+ for i := range buf {
262+ p := &buf[i]
263+ if p.Hidden && !p.Leaving {
264+ continue
265+ }
266+ if rm, ok := sc.rects[p.ID]; ok {
267+ rm.show(p)
268+ }
269+ }
270+ sc.shownBuf, sc.shown = buf, buf
271+ }
272+}
273+
274+// withLeaving is layout with the leaving windows' last placements added,
275+// Hidden and Leaving, into the screen's reused buffer (the layout itself
276+// is left alone). A window the layout still lists, Hidden (a hidden stash),
277+// is replaced where it is. A leaving tile goes where the tiles are painted: before
278+// the first window that opens the floats (a float above the tiles, as the
279+// renderer orders them), so it never flashes over a float; a leaving float
280+// goes last, over the tiles. Within a group the absent ones go smallest ID
281+// first: a stable order keeps the scenes comparable frame to frame.
282+func (s *screen) withLeaving(layout []Placement) []Placement {
283+ out := append(s.settledBuf[:0], layout...)
284+ // Pass 1: an entry the layout still lists, Hidden (a stash that hid),
285+ // replaces it where it is, never twice. Where the pass goes through the
286+ // map does not matter: every entry has its own place. The absent ones
287+ // are collected for pass 2.
288+ ids := s.leaveIDs[:0]
289+ for id, rm := range s.rects {
290+ if !rm.leaving {
291+ continue
292+ }
293+ j := slices.IndexFunc(out, func(q Placement) bool { return q.ID == id && q.Hidden && !q.Leaving })
294+ if j < 0 {
295+ ids = append(ids, id)
296+ continue
297+ }
298+ out[j] = rm.leavingPlacement()
299+ }
300+ // Pass 2: the absent ones in ID order, each at the end of its group
301+ // (tiles before the first float, a leaving stash float included;
302+ // floats last), so the order never depends on the map's.
303+ floats := len(out)
304+ for i, p := range out {
305+ if p.Floating && !p.Below && p.Preview == 0 && (!p.Hidden || p.Leaving) {
306+ floats = i
307+ break
308+ }
309+ }
310+ slices.Sort(ids)
311+ for _, id := range ids {
312+ p := s.rects[id].leavingPlacement()
313+ if p.Floating {
314+ out = append(out, p)
315+ continue
316+ }
317+ out = slices.Insert(out, floats, p)
318+ floats++
319+ }
320+ s.leaveIDs, s.settledBuf = ids, out
321+ return out
322+}
323+
324+// leavingPlacement is the placement a leaving motion draws: its last
325+// settled one, Hidden and Leaving (the scene draws it; input, focus,
326+// popups and configures see a hidden window, as the C0 design has it).
327+// Hidden stays set on purpose: the leaving entry never takes input, even
328+// though its stash window is still in the layout.
329+func (m rectMotion) leavingPlacement() Placement {
330+ p := m.left
331+ p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0
332+ return p
333+}
334+
335+// appearAt starts the entrance of a window the action made visible on sc,
336+// whose settled placement is p: it fades in from invisible and grows from
337+// 90 % of its settled rect, around its centre. Nothing with animations off
338+// or for a hidden placement. A leaving motion of the same window (a show
339+// during a hide) is continued from, not replaced: the entrance starts at
340+// the fade, rect and speeds it was drawn with.
341+func (c *Core) appearAt(sc *screen, p Placement, now time.Time) {
342+ if !c.animOn() || p.Hidden {
343+ return
344+ }
345+ if sc.rects == nil {
346+ sc.rects = make(map[WindowID]rectMotion)
347+ }
348+ var rm rectMotion
349+ rm.scale = true
350+ if old, ok := sc.rects[p.ID]; ok && old.leaving {
351+ c.appearFrom(&rm, p, &old, now)
352+ } else {
353+ c.scaleFrom(&rm, p.Rect, appearScale, now)
354+ c.retargetLevel(&rm.fade, &rm.df, 1, 0, now)
355+ }
356+ sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()
357+}
358+
359+// appearFrom starts rm, the entrance to the settled placement p, from where
360+// the leaving motion old draws its window at now: its rect (old.left.Rect
361+// plus its offsets, sampled), fade, veil and their speeds. The leaving fade
362+// is an offset from 1, the entrance's from 0.
363+func (c *Core) appearFrom(rm *rectMotion, p Placement, old *rectMotion, now time.Time) {
364+ settled := p.Rect
365+ off, vel := old.sample(now)
366+ base := old.left.Rect
367+ fade, fadeV := old.df, 0.0
368+ sampleComponent(old.fade, &fade, &fadeV, now)
369+ dim, dimV := old.ddim, 0.0
370+ sampleComponent(old.dim, &dim, &dimV, now)
371+ // The veil the leaving entry drew (its peek's, plus the offset) to the
372+ // one the settled placement draws.
373+ dim += c.peekDim(old.left) - c.peekDim(p)
374+ c.retargetComponent(&rm.x, &rm.dx, float64(base.X-settled.X)+off.x, vel.x, now)
375+ c.retargetComponent(&rm.y, &rm.dy, float64(base.Y-settled.Y)+off.y, vel.y, now)
376+ c.retargetComponent(&rm.w, &rm.dw, float64(base.W-settled.W)+off.w, vel.w, now)
377+ c.retargetComponent(&rm.h, &rm.dh, float64(base.H-settled.H)+off.h, vel.h, now)
378+ c.retargetLevel(&rm.fade, &rm.df, 1+fade, fadeV, now)
379+ c.retargetLevel(&rm.dim, &rm.ddim, dim, dimV, now)
380+}
381+
382+// mapWindow handles a window's map: it joins its workspace, the tiles the
383+// new one displaces re-flow with rect motions, and a window that comes on
384+// screen appears (appearMapped). A map is the one client event that
385+// animates; the settled layout is the same with animations off, so the
386+// client is configured once, to its final size.
387+func (c *Core) mapWindow(v ports.WindowMapped) {
388+ now := c.now()
389+ c.windows.mapped(v, now)
390+ if s, _ := c.screenOf(v.ID); s != nil || !c.animOn() || c.security.Protected {
391+ // Mapped before (a re-map keeps its place), animations off or a
392+ // session lock: nothing to animate.
393+ c.placement.place(c, v)
394+ return
395+ }
396+ shots := c.snapshot(now)
397+ for i := range shots {
398+ // A screen with its overview open re-lays its cards at once: a map
399+ // is not an overview action (transition skips a shot without ws).
400+ if shots[i].overview {
401+ shots[i].ws = nil
402+ }
403+ }
404+ c.placement.place(c, v)
405+ c.transition(shots, now)
406+ c.appearMapped(v.ID, now)
407+}
408+
409+// appearMapped starts the entrance of a window that just mapped when it
410+// shows on its screen's current workspace as a tile or an ordinary float:
411+// not with the overview open or a session lock, not fullscreen (it keeps
412+// the direct scanout path), not a float below the columns, not a stash
413+// peek.
414+func (c *Core) appearMapped(id WindowID, now time.Time) {
415+ sc, w := c.screenOf(id)
416+ if sc == nil || c.security.Protected || sc.mon.ov.open || sc.mon.Current() != w {
417+ return
418+ }
419+ for _, p := range sc.mon.Layout() {
420+ if p.ID != id {
421+ continue
422+ }
423+ if !p.Hidden && !p.Fullscreen && !p.Below && !p.Peek && p.Preview == 0 {
424+ c.appearAt(sc, p, now)
425+ }
426+ return
427+ }
428+}
429+
430+// unmapWindow handles a window's unmap: it leaves its workspace, the tiles
431+// around it re-flow with rect motions, and a window that was drawn as a
432+// tile or an ordinary float keeps fading out from where it was drawn
433+// (leaveFrom with the snapshot's record of it: a window closed mid-motion
434+// leaves from its drawn rect, fade and speed, not from its settled rect).
435+// Like a map, an unmap is a client event that animates; the settled layout
436+// is the same with animations off. A fullscreen window, one under a
437+// session lock or with the overview open just goes.
438+func (c *Core) unmapWindow(v ports.WindowUnmapped) {
439+ // The window is unmapped: a toplevel that maps again keeps its ID and
440+ // must be configured from nothing (a leaving entry keeps no configure).
441+ c.configures.forget(v.ID)
442+ sc, w := c.screenOf(v.ID)
443+ if sc == nil {
444+ return
445+ }
446+ if !c.animOn() || c.security.Protected || sc.mon.ov.open {
447+ sc.mon.RemoveWindow(v.ID)
448+ delete(sc.rects, v.ID)
449+ return
450+ }
451+ now := c.now()
452+ // The placement that leaves is the settled one of the last publish:
453+ // where the window was going.
454+ var left Placement
455+ if sc.mon.Current() == w {
456+ for _, p := range sc.settledLayout {
457+ if p.ID == v.ID {
458+ left = p
459+ break
460+ }
461+ }
462+ }
463+ shots := c.snapshot(now)
464+ var shot *rectShot
465+ for i := range shots {
466+ if shots[i].overview {
467+ shots[i].ws = nil
468+ }
469+ if shots[i].sc == sc {
470+ if j := slices.IndexFunc(shots[i].rects, func(r rectShot) bool { return r.id == v.ID }); j >= 0 {
471+ shot = &shots[i].rects[j]
472+ }
473+ }
474+ }
475+ sc.mon.RemoveWindow(v.ID)
476+ // A window already leaving (a stash hide still fading) keeps its
477+ // motion: the fade goes on from where it is, drawn from the content
478+ // the renderer kept. Any other motion goes with the window.
479+ if rm, ok := sc.rects[v.ID]; !ok || !rm.leaving {
480+ delete(sc.rects, v.ID)
481+ }
482+ c.transition(shots, now)
483+ if left.ID == v.ID && !left.Leaving && !left.Fullscreen && !left.Below && !left.Peek {
484+ c.leaveFrom(sc, left, shot, now)
485+ }
486+}
487+
488+// resizeFloating applies a floating window's committed size. A float maps
489+// before its first commit, so core first lays it out at a default size;
490+// when its entrance runs, the settled rect changing under it would break
491+// the aspect of the 90 % rect. The entrance's rect offsets (and their
492+// springs, which are linear in their start and speed) are scaled by the
493+// settled size ratio: the progress and speed carry over, and the window
494+// still grows from 90 % of its real size around its centre.
495+func (c *Core) resizeFloating(v ports.WindowResized) {
496+ sc, w := c.screenOf(v.ID)
497+ if w == nil {
498+ return
499+ }
500+ rm, running := sc.rects[v.ID]
501+ running = running && rm.scale && !rm.leaving
502+ var old Rect
503+ if running {
504+ old = settledRect(sc.mon, v.ID)
505+ }
506+ w.ResizeFloating(v.ID, v.Width, v.Height)
507+ if !running {
508+ return
509+ }
510+ cur := settledRect(sc.mon, v.ID)
511+ if old.W <= 0 || old.H <= 0 || cur.W <= 0 || cur.H <= 0 || old == cur {
512+ return
513+ }
514+ kw, kh := float64(cur.W)/float64(old.W), float64(cur.H)/float64(old.H)
515+ rm.dx, rm.dw = rm.dx*kw, rm.dw*kw
516+ rm.dy, rm.dh = rm.dy*kh, rm.dh*kh
517+ rm.x.scale(kw)
518+ rm.w.scale(kw)
519+ rm.y.scale(kh)
520+ rm.h.scale(kh)
521+ sc.rects[v.ID] = rm
522+}
523+
524+// settledRect is where m's layout puts the visible window id, zero if it
525+// shows none.
526+func settledRect(m *Monitor, id WindowID) Rect {
527+ for _, p := range m.Layout() {
528+ if p.ID == id && !p.Hidden {
529+ return p.Rect
530+ }
531+ }
532+ return Rect{}
533+}
534+
535+// leaveFrom keeps drawing a window the action closed or hid, from its last
536+// settled placement p: it fades out and shrinks to 90 % around its centre,
537+// then its entry goes. Nothing with animations off, or for a placement that
538+// was not drawn. shot is the snapshot's record of how the window was drawn
539+// (nil for a window at rest): the exit starts at the rect, fade and dim the
540+// window had on screen, with their speeds, so a hide
541+// during an entrance or a slide does not jump.
542+func (c *Core) leaveFrom(sc *screen, p Placement, shot *rectShot, now time.Time) {
543+ if !c.animOn() || p.Hidden || p.Preview > 0 || p.Rect.W <= 0 || p.Rect.H <= 0 {
544+ return
545+ }
546+ if sc.rects == nil {
547+ sc.rects = make(map[WindowID]rectMotion)
548+ }
549+ rm := rectMotion{scale: true, leaving: true, left: p}
550+ rm.left.Hidden, rm.left.Leaving, rm.left.Focused = true, true, false
551+ var off, vel rectOffsets
552+ fade, fadeV, dim, dimV := 0.0, 0.0, 0.0, 0.0
553+ if shot != nil {
554+ off, vel, fade, fadeV, dim, dimV = shot.off, shot.vel, shot.fade, shot.fadeV, shot.dim, shot.dimV
555+ }
556+ // The entry settles at 90 % of the rect it left, its content zoomed
557+ // to match (show): the offsets run from the rect it was drawn at (the
558+ // full one at rest) to that.
559+ end := scaledRect(p.Rect, appearScale)
560+ c.retargetComponent(&rm.x, &rm.dx, float64(p.Rect.X-end.X)+off.x, vel.x, now)
561+ c.retargetComponent(&rm.y, &rm.dy, float64(p.Rect.Y-end.Y)+off.y, vel.y, now)
562+ c.retargetComponent(&rm.w, &rm.dw, float64(p.Rect.W-end.W)+off.w, vel.w, now)
563+ c.retargetComponent(&rm.h, &rm.dh, float64(p.Rect.H-end.H)+off.h, vel.h, now)
564+ rm.left.Rect = end
565+ // The leaving fade is an offset from 1: the window drawn at fade f is
566+ // at f-1.
567+ c.retargetLevel(&rm.fade, &rm.df, fade-1, fadeV, now)
568+ c.retargetLevel(&rm.dim, &rm.ddim, dim, dimV, now)
569+ sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()
570+}
571+
572+// appearScale is the size, relative to the settled rect, a window appears
573+// from and leaves to.
574+const appearScale = 0.9
575+
576+// scaledRect is r scaled by k around its centre, in float.
577+func scaledRect(r Rect, k float64) Rect {
578+ w, h := float64(r.W)*k, float64(r.H)*k
579+ x := float64(r.X) + (float64(r.W)-w)/2
580+ y := float64(r.Y) + (float64(r.H)-h)/2
581+ return Rect{X: int(math.Round(x)), Y: int(math.Round(y)), W: int(math.Round(w)), H: int(math.Round(h))}
582+}
583+
584+// scaleFrom starts rm's rect components from settled scaled by k around
585+// its centre, toward settled.
586+func (c *Core) scaleFrom(rm *rectMotion, settled Rect, k float64, now time.Time) {
587+ w, h := float64(settled.W)*k, float64(settled.H)*k
588+ c.retargetComponent(&rm.x, &rm.dx, (float64(settled.W)-w)/2, 0, now)
589+ c.retargetComponent(&rm.y, &rm.dy, (float64(settled.H)-h)/2, 0, now)
590+ c.retargetComponent(&rm.w, &rm.dw, w-float64(settled.W), 0, now)
591+ c.retargetComponent(&rm.h, &rm.dh, h-float64(settled.H), 0, now)
592+}
593+
594+// snapshot records what every screen without an open overview shows at now.
595+// Running motions are sampled at now on a copy, so a transition that
596+// follows chains from their speed at the time of the action, not from the
597+// last frame's. The view is the one drawn (the layouts it is measured
598+// against are), only its speed is sampled. The returned slice and the lists
599+// in it belong to Core and are reused by the next snapshot: use it before
600+// the next action only.
601+func (c *Core) snapshot(now time.Time) []viewShot {
602+ full := c.shots[:cap(c.shots)]
603+ out := c.shots[:0]
604+ for _, sc := range c.screens {
605+ m := sc.mon
606+ var s viewShot
607+ if len(out) < len(full) {
608+ s.list = full[len(out)].list[:0]
609+ s.rects = full[len(out)].rects[:0]
610+ s.stash = full[len(out)].stash[:0]
611+ }
612+ s.sc = sc
613+ w := m.Current()
614+ s.ws, s.overview = w, m.ov.open
615+ if !m.ov.open {
616+ // The overview does not scroll or slide: no camera.
617+ s.viewX, s.view = w.ViewX, float64(w.ViewX)+w.shift
618+ }
619+ if w.motion.on {
620+ _, s.viewV = w.motion.sampleAt(now)
621+ }
622+ if m.switchMotion.on {
623+ _, s.switchV = m.switchMotion.sampleAt(now)
624+ }
625+ // settledLayout and shown are index-aligned and differ only by
626+ // the offsets of sc.rects: the shot keeps both unrounded. The
627+ // overview's cards are recorded like windows.
628+ for _, p := range sc.settledLayout {
629+ if p.Hidden {
630+ continue
631+ }
632+ r := rectShot{id: p.ID, rect: p.Rect}
633+ if rm, ok := sc.rects[p.ID]; ok {
634+ r.off, r.vel = rm.sample(now)
635+ r.fade = rm.df
636+ sampleComponent(rm.fade, &r.fade, &r.fadeV, now)
637+ r.dim = rm.ddim
638+ sampleComponent(rm.dim, &r.dim, &r.dimV, now)
639+ }
640+ r.veil = c.peekDim(p)
641+ s.rects = append(s.rects, r)
642+ if !m.ov.open && w.stashIndex(p.ID) >= 0 {
643+ s.stash = append(s.stash, p)
644+ }
645+ }
646+ if m.shown == nil && !m.ov.open {
647+ // A landing slide measures in the list it began on.
648+ list := m.Workspaces
649+ if m.switchList != nil {
650+ list = m.switchList
651+ }
652+ if i := indexOf(list, w); i >= 0 {
653+ s.list = append(s.list, list...)
654+ s.pos, s.ok = float64(i)+m.switchOff, true
655+ }
656+ }
657+ out = append(out, s)
658+ }
659+ for i := len(out); i < len(full); i++ {
660+ // Shots of unplugged screens keep nothing of them alive.
661+ full[i].sc, full[i].ws = nil, nil
662+ clear(full[i].list[:cap(full[i].list)])
663+ full[i].list = full[i].list[:0]
664+ }
665+ c.shots = out
666+ return out
667+}
668+
669+// transition starts the springs that take each screen from what before
670+// showed to what the action left, at now. It does nothing with animations
671+// off, or while a swipe follows the fingers on that screen. Per-window rect
672+// motions join the camera here; with the overview open before or after
673+// there is no camera, only the cards' (transitionOverview).
674+//
675+// A workspace moved to another monitor (move-workspace-to-monitor-*, or a
676+// named workspace pulled to the focused one by a "workspace <name>" bind
677+// while it shows on another) is animated on both screens: the destination's
678+// windows slide in from where the source drew them (no workspace slide
679+// there), the source slides to its next workspace. The focus-monitor-*
680+// actions move nothing: no window changes place, so they are not animated
681+// (the focus pulse marks the new focus).
682+func (c *Core) transition(before []viewShot, now time.Time) {
683+ if !c.animOn() {
684+ return
685+ }
686+ for i := range before {
687+ b := &before[i]
688+ if b.ws == nil || !c.hasScreen(b.sc) || c.following(b.sc) {
689+ continue
690+ }
691+ if b.overview || b.sc.mon.ov.open {
692+ c.transitionOverview(b, now)
693+ continue
694+ }
695+ c.transitionCamera(b, before, now)
696+ c.transitionRects(b, before, now)
697+ c.transitionStash(b, now)
698+ }
699+}
700+
701+// following reports whether a swipe moves sc's view with the fingers.
702+func (c *Core) following(sc *screen) bool {
703+ g := c.swipe
704+ return g != nil && g.screen == sc && (g.mode == swipeColumns || g.mode == swipeWorkspaces)
705+}
706+
707+// transitionCamera slides the columns or the monitor from what b showed.
708+func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) {
709+ m := b.sc.mon
710+ w := m.Current()
711+ switch {
712+ case w == b.ws:
713+ if w.ViewX == b.viewX || !w.slidable() {
714+ return
715+ }
716+ // A running landing slide was retargeted by scroll(): its velocity
717+ // carries over, and the spring starts now like every action's.
718+ w.shift = b.view - float64(w.ViewX)
719+ w.motion = c.spring(viewSpring(w.shift, b.viewV), now)
720+ case b.ok && m.shown == nil && movedFrom(b, before) == nil:
721+ // A screen that received its workspace from another monitor does
722+ // not slide to it: a running slide's list (switchList) may still
723+ // hold the workspace, which would start a slide from nowhere. Only
724+ // its windows move (transitionRects).
725+ j := indexOf(b.list, w)
726+ if j < 0 {
727+ return
728+ }
729+ off := b.pos - float64(j)
730+ // The snapshot's list is reused by the next action: the slide keeps
731+ // its own copy.
732+ m.switchOff, m.switchList = off, slices.Clone(b.list)
733+ if off == 0 || m.framedSwitch() {
734+ m.stopSwitch()
735+ return
736+ }
737+ m.switchMotion = c.spring(m.switchSpring(off, b.switchV), now)
738+ }
739+}
740+
741+// transitionRects starts or retargets the rect motions of the windows whose
742+// layout the action changed. The new layout is measured with the camera as
743+// transitionCamera left it (the view where it was on screen), so a window
744+// the view scrolls differs only by what the layout itself changed. Windows
745+// that were not drawn before (new, or hidden) or are hidden now get none.
746+//
747+// A workspace that arrived from another monitor starts from the rects its
748+// source screen drew (found in before), translated to this screen's
749+// coordinates; a source whose workspace left, or any other change of
750+// workspace, gets none.
751+func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) {
752+ sc := b.sc
753+ m := sc.mon
754+ from, dx, dy := b, 0, 0
755+ if m.Current() != b.ws {
756+ // The motions are those of the workspace this screen left.
757+ sc.stopRects()
758+ if from = movedFrom(b, before); from == nil {
759+ return
760+ }
761+ dx, dy = from.sc.x-sc.x, from.sc.y-sc.y
762+ }
763+ for _, p := range m.Layout() {
764+ if p.Hidden || p.Preview > 0 {
765+ continue
766+ }
767+ i := slices.IndexFunc(from.rects, func(r rectShot) bool { return r.id == p.ID })
768+ if i < 0 {
769+ continue
770+ }
771+ old := &from.rects[i]
772+ oldRect := Rect{X: old.rect.X + dx, Y: old.rect.Y + dy, W: old.rect.W, H: old.rect.H}
773+ // A window that becomes a peek or stops being one (a stash
774+ // navigation) changes its veil: the offset takes the one drawn to
775+ // the new one. Otherwise a running dim motion goes on as it is.
776+ dimOff := old.dim + old.veil - c.peekDim(p)
777+ dimMoves := old.veil != c.peekDim(p)
778+ if oldRect == p.Rect && !dimMoves {
779+ // The settled rect did not change: a running motion goes on
780+ // as it is, and there is none to start.
781+ continue
782+ }
783+ if sc.rects == nil {
784+ sc.rects = make(map[WindowID]rectMotion)
785+ }
786+ // Where the window was drawn, unrounded, relative to where it goes.
787+ rm := sc.rects[p.ID]
788+ if oldRect != p.Rect {
789+ // A re-flow may change the aspect: the content zoom (appear)
790+ // stops following the frame and snaps to the settled size; the
791+ // fade goes on.
792+ rm.scale = false
793+ c.retargetComponent(&rm.x, &rm.dx, float64(oldRect.X-p.Rect.X)+old.off.x, old.vel.x, now)
794+ c.retargetComponent(&rm.y, &rm.dy, float64(oldRect.Y-p.Rect.Y)+old.off.y, old.vel.y, now)
795+ c.retargetComponent(&rm.w, &rm.dw, float64(oldRect.W-p.Rect.W)+old.off.w, old.vel.w, now)
796+ c.retargetComponent(&rm.h, &rm.dh, float64(oldRect.H-p.Rect.H)+old.off.h, old.vel.h, now)
797+ }
798+ if dimMoves {
799+ c.retargetLevel(&rm.dim, &rm.ddim, dimOff, old.dimV, now)
800+ }
801+ if rm.on() {
802+ sc.rects[p.ID], sc.rectsWS = rm, m.Current()
803+ } else {
804+ delete(sc.rects, p.ID)
805+ }
806+ }
807+}
808+
809+// transitionStash animates the stash windows an action shows or hides on
810+// the workspace b's screen keeps on screen (a toggle-stash-visible, a click
811+// that closes the stash, a navigation that brings a window to a margin or
812+// sends it off): one drawn after and not before appears (appearAt), one
813+// drawn before and hidden after leaves from its last settled placement
814+// (leave). A window that is gone from the layout (unstashed, moved) or
815+// fullscreen is not touched: its rect motion, if any, is transitionRects's.
816+// Nothing during a session lock or with a covering fullscreen window (it
817+// keeps its direct scanout path).
818+func (c *Core) transitionStash(b *viewShot, now time.Time) {
819+ sc := b.sc
820+ m := sc.mon
821+ w := m.Current()
822+ if w != b.ws || len(w.Stash) == 0 || c.security.Protected || w.cover() != 0 {
823+ return
824+ }
825+ layout := m.Layout()
826+ for _, p := range b.stash {
827+ i := slices.IndexFunc(layout, func(q Placement) bool { return q.ID == p.ID })
828+ if i >= 0 && layout[i].Hidden && !p.Fullscreen {
829+ var shot *rectShot
830+ if j := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }); j >= 0 {
831+ shot = &b.rects[j]
832+ }
833+ c.leaveFrom(sc, p, shot, now)
834+ }
835+ }
836+ for _, p := range layout {
837+ if p.Hidden || p.Fullscreen || w.stashIndex(p.ID) < 0 || slices.ContainsFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) {
838+ continue
839+ }
840+ c.appearAt(sc, p, now)
841+ }
842+}
843+
844+// peekDim is the veil the settled placement p draws: the configured stash
845+// dim for a peek, none for another window. The placement's Dim is an
846+// offset from it.
847+func (c *Core) peekDim(p Placement) float64 {
848+ if p.Peek {
849+ return c.cfg.Stash.Dim
850+ }
851+ return 0
852+}
853+
854+// transitionOverview starts the motions of an action that opened or closed
855+// the overview, or moved inside it (the selection, the rows). The camera
856+// springs stay stopped (ToggleOverview): every window drawn before and
857+// after moves from its shown rect to its settled one, its content scaled
858+// with it (a card's Zoom, rectMotion.show). A window drawn after and not
859+// before (another row, a column the overview hid) fades in with no motion
860+// of its own; one drawn before and not after just goes. Motions of the
861+// other state are dropped first: their rects are not the ones snapshot
862+// measured from. A fade that ran on a window drawn in both states carries
863+// over (picking a card that still fades in does not pop it).
864+func (c *Core) transitionOverview(b *viewShot, now time.Time) {
865+ sc := b.sc
866+ changed := b.overview != sc.mon.ov.open
867+ if changed {
868+ sc.stopRects()
869+ }
870+ for _, p := range sc.mon.Layout() {
871+ if p.Hidden {
872+ continue
873+ }
874+ rm := sc.rects[p.ID]
875+ i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })
876+ if i >= 0 && changed {
877+ c.retargetLevel(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now)
878+ }
879+ if i < 0 {
880+ c.retargetLevel(&rm.fade, &rm.df, 1, 0, now)
881+ } else if old := &b.rects[i]; old.rect != p.Rect {
882+ rm.scale = true
883+ c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now)
884+ c.retargetComponent(&rm.y, &rm.dy, float64(old.rect.Y-p.Rect.Y)+old.off.y, old.vel.y, now)
885+ c.retargetComponent(&rm.w, &rm.dw, float64(old.rect.W-p.Rect.W)+old.off.w, old.vel.w, now)
886+ c.retargetComponent(&rm.h, &rm.dh, float64(old.rect.H-p.Rect.H)+old.off.h, old.vel.h, now)
887+ } else {
888+ continue
889+ }
890+ if !rm.on() {
891+ delete(sc.rects, p.ID)
892+ continue
893+ }
894+ if sc.rects == nil {
895+ sc.rects = make(map[WindowID]rectMotion)
896+ }
897+ sc.rects[p.ID], sc.rectsWS = rm, sc.rectsOwner()
898+ }
899+}
900+
901+// movedFrom is the shot of the other screen that showed the workspace b's
902+// screen shows now (moved to it by an action), or nil.
903+func movedFrom(b *viewShot, before []viewShot) *viewShot {
904+ w := b.sc.mon.Current()
905+ for i := range before {
906+ if o := &before[i]; o.ws == w && o.sc != b.sc && !o.overview {
907+ return o
908+ }
909+ }
910+ return nil
911+}
A internal/core/transition_chain_internal_test.go +435 -0
@@ -0,0 +1,435 @@
1+package core
2+
3+import (
4+ "context"
5+ "math"
6+ "slices"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// A bind that stops a spring while it acts (Monitor.Focus) must not lose its
14+// speed: the transition that follows starts from what the snapshot recorded.
15+func TestChainedActionsKeepVelocity(t *testing.T) {
16+ for _, tt := range chainCases() {
17+ t.Run(tt.name, func(t *testing.T) {
18+ c, ic := indicatorCore(t)
19+ c.cfg.Animations.On = true
20+ tt.setup(c)
21+ settleShown(t, c)
22+ act := func(actions []Action) {
23+ before := c.snapshot(ic.now)
24+ for _, a := range actions {
25+ c.applyAction(a)
26+ }
27+ c.transition(before, ic.now)
28+ settleShown(t, c)
29+ }
30+ act(tt.first)
31+ if !c.animating() {
32+ t.Fatal("the first action started no motion")
33+ }
34+ ic.now = ic.now.Add(30 * time.Millisecond)
35+ c.animate(ic.now, nil)
36+ settleShown(t, c)
37+ want := tt.velocity(c)
38+ if want == 0 {
39+ t.Fatal("no speed to carry over")
40+ }
41+ act(tt.second)
42+ if got := tt.velocity(c); got != want {
43+ t.Fatalf("velocity %v after the second action, want the carried %v", got, want)
44+ }
45+ })
46+ }
47+}
48+
49+// An action between two frames chains from the speed its motion has at the
50+// action's time (1 ms after the last frame), within 1e-6, not the last
51+// frame's.
52+func TestChainedActionsSampleVelocityAtActionTime(t *testing.T) {
53+ for _, tt := range chainCases() {
54+ t.Run(tt.name, func(t *testing.T) {
55+ c, ic := indicatorCore(t)
56+ c.cfg.Animations.On = true
57+ tt.setup(c)
58+ settleShown(t, c)
59+ before := c.snapshot(ic.now)
60+ for _, a := range tt.first {
61+ c.applyAction(a)
62+ }
63+ c.transition(before, ic.now)
64+ settleShown(t, c)
65+ ic.now = ic.now.Add(30 * time.Millisecond)
66+ c.animate(ic.now, nil)
67+ settleShown(t, c)
68+ m := tt.motion(c)
69+ last := m.velocity()
70+ ic.now = ic.now.Add(time.Millisecond)
71+ // The spring's own speed at now, computed without motion.at.
72+ tau := time.Duration(float64(ic.now.Sub(m.start)) / m.slow)
73+ want := m.spring.velocityAt(tau) / m.slow
74+ if math.Abs(want-last) < 1e-3 {
75+ t.Fatalf("speed %v did not change in 1 ms (%v): the case checks nothing", last, want)
76+ }
77+ before = c.snapshot(ic.now)
78+ for _, a := range tt.second {
79+ c.applyAction(a)
80+ }
81+ c.transition(before, ic.now)
82+ if got := tt.velocity(c); math.Abs(got-want) > 1e-6 {
83+ t.Fatalf("velocity %v after the second action, want the sampled %v (last frame's %v)", got, want, last)
84+ }
85+ })
86+ }
87+}
88+
89+type chainCase struct {
90+ name string
91+ setup func(*Core)
92+ first []Action
93+ second []Action
94+ // motion is the motion the first action started.
95+ motion func(*Core) motion
96+}
97+
98+// velocity is the speed of the motion the first action started.
99+func (tt chainCase) velocity(c *Core) float64 { return tt.motion(c).velocity() }
100+
101+func chainCases() []chainCase {
102+ return []chainCase{
103+ {"camera", func(c *Core) {
104+ m := c.cur().mon
105+ for id := WindowID(1); id <= 4; id++ {
106+ m.AddWindow(id)
107+ }
108+ }, []Action{ActionFocusColumnLeft, ActionFocusColumnLeft, ActionFocusColumnLeft},
109+ []Action{ActionFocusColumnRight, ActionFocusColumnRight, ActionFocusColumnRight},
110+ func(c *Core) motion { return c.cur().mon.Current().motion }},
111+ {"workspace switch", func(c *Core) {
112+ m := c.cur().mon
113+ m.AddWindow(1)
114+ m.Focus(1)
115+ m.AddWindow(2)
116+ m.Focus(0)
117+ }, []Action{ActionFocusWorkspaceDown}, []Action{ActionFocusWorkspaceDown},
118+ func(c *Core) motion { return c.cur().mon.switchMotion }},
119+ {"rect", func(c *Core) {
120+ m := c.cur().mon
121+ m.AddWindow(1)
122+ m.AddWindow(2)
123+ }, []Action{ActionMoveColumnLeft}, []Action{ActionMoveColumnRight},
124+ func(c *Core) motion { return c.cur().rects[1].x }},
125+ }
126+}
127+
128+// The fallback timer guards every animating output: a page flip of one
129+// output (step with only set) keeps it while another output still animates.
130+func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) {
131+ c, ic := indicatorCore(t)
132+ c.cfg.Animations.On = true
133+ a := c.cur()
134+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
135+ b := c.screens[1]
136+ a.mon.AddWindow(1)
137+ b.mon.AddWindow(2)
138+ settleShown(t, c)
139+ for _, sc := range []*screen{a, b} {
140+ w := sc.mon.Current()
141+ w.shift, w.motion = 100, c.spring(viewSpring(100, 0), ic.now)
142+ }
143+ // The one frame timer is made on the first arm and reset afterwards:
144+ // a re-arm shows as a Reset on the clock.
145+ c.armFrame()
146+ resets := len(ic.resets)
147+ ic.now = ic.now.Add(16 * time.Millisecond)
148+ if err := c.step(context.Background(), a); err != nil {
149+ t.Fatal(err)
150+ }
151+ <-c.ch.Scenes
152+ if c.frameC == nil || len(ic.resets) != resets {
153+ t.Fatalf("a flip of A re-armed or dropped the fallback timer while B animates: armed %t, resets %d", c.frameC != nil, len(ic.resets)-resets)
154+ }
155+
156+ // With nothing else animating the flip's own publish decides.
157+ b.mon.Current().stopSlide()
158+ if err := c.step(context.Background(), a); err != nil {
159+ t.Fatal(err)
160+ }
161+ <-c.ch.Scenes
162+ if c.frameC == nil || len(ic.resets) != resets+1 {
163+ t.Fatalf("A still animates but its timer was not re-armed: armed %t, resets %d", c.frameC != nil, len(ic.resets)-resets)
164+ }
165+ ic.now = ic.now.Add(5 * time.Second)
166+ if err := c.step(context.Background(), nil); err != nil {
167+ t.Fatal(err)
168+ }
169+ <-c.ch.Scenes
170+ if c.animating() || c.frameC != nil {
171+ t.Fatalf("settled but the timer runs (%v)", c.frameC != nil)
172+ }
173+}
174+
175+// A window that left the layout while its rect motion ran drops the motion:
176+// it would ask for frames with nothing to move.
177+func TestRefreshShownDropsRectsOfWindowsOutOfLayout(t *testing.T) {
178+ c, ic := indicatorCore(t)
179+ c.cfg.Animations.On = true
180+ sc := c.cur()
181+ sc.mon.AddWindow(1)
182+ sc.mon.AddWindow(2)
183+ settleShown(t, c)
184+ sp := viewSpring(40, 0)
185+ sc.rects = map[WindowID]rectMotion{
186+ 1: {x: c.spring(sp, ic.now), dx: 40},
187+ 99: {x: c.spring(sp, ic.now), dx: 40},
188+ }
189+ sc.rectsWS = sc.mon.Current()
190+ settleShown(t, c)
191+ if _, ok := sc.rects[99]; ok {
192+ t.Fatal("a window outside the layout keeps its rect motion")
193+ }
194+ if _, ok := sc.rects[1]; !ok {
195+ t.Fatal("a window in the layout lost its rect motion")
196+ }
197+}
198+
199+// A swipe that follows the fingers owns the view: a focus bind pressed
200+// meanwhile starts no camera motion.
201+func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) {
202+ c, ic := indicatorCore(t)
203+ c.cfg.Animations.On = true
204+ sc := c.cur()
205+ for id := WindowID(1); id <= 4; id++ {
206+ sc.mon.AddWindow(id)
207+ }
208+ settleShown(t, c)
209+ w := sc.mon.Current()
210+ w.shift = 25
211+ c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns}
212+ before := c.snapshot(ic.now)
213+ viewX := w.ViewX
214+ for range 3 {
215+ c.applyAction(ActionFocusColumnLeft)
216+ }
217+ if w.ViewX == viewX {
218+ t.Fatal("the focus did not move the view: the case checks nothing")
219+ }
220+ c.transition(before, ic.now)
221+ if w.motion.on || w.shift != 25 {
222+ t.Fatalf("camera motion %v, shift %v: the transition took over the fingers' view", w.motion.on, w.shift)
223+ }
224+}
225+
226+// A switch to or from a workspace with its own size stays settled: no
227+// monitor-wide slide would be cropped to it. The same switch between
228+// workspaces of the monitor's size slides.
229+func TestNoSwitchMotionForSizedWorkspace(t *testing.T) {
230+ for _, tc := range []struct {
231+ name string
232+ sized bool
233+ }{{"sized", true}, {"unsized", false}} {
234+ t.Run(tc.name, func(t *testing.T) {
235+ c, ic := indicatorCore(t)
236+ c.cfg.Animations.On = true
237+ m := c.cur().mon
238+ m.AddWindow(1)
239+ if tc.sized {
240+ m.Workspaces[1].SetSize(100, 100)
241+ }
242+ settleShown(t, c)
243+ before := c.snapshot(ic.now)
244+ m.Focus(1)
245+ c.transition(before, ic.now)
246+ if got := m.switchMotion.on; got == tc.sized {
247+ t.Fatalf("switch motion %v with a sized workspace %v", got, tc.sized)
248+ }
249+ if tc.sized && m.switchOff != 0 {
250+ t.Fatalf("switchOff %v left for a framed switch", m.switchOff)
251+ }
252+ })
253+ }
254+}
255+
256+// chainedRects is a core with two windows whose first action moves them,
257+// leaving rect motions running one frame in: the clock moved 30 ms and
258+// animate sampled them.
259+func chainedRects(t *testing.T, first Action) (*Core, *indicatorClock) {
260+ t.Helper()
261+ c, ic := indicatorCore(t)
262+ c.cfg.Animations.On = true
263+ m := c.cur().mon
264+ m.AddWindow(1)
265+ m.AddWindow(2)
266+ settleShown(t, c)
267+ before := c.snapshot(ic.now)
268+ c.applyAction(first)
269+ c.transition(before, ic.now)
270+ settleShown(t, c)
271+ ic.now = ic.now.Add(30 * time.Millisecond)
272+ c.animate(ic.now, nil)
273+ settleShown(t, c)
274+ return c, ic
275+}
276+
277+// A bind pressed between two frames chains from the speed the springs have
278+// at the bind's time, not the one of the last frame: a second change 1 ms
279+// after a frame keeps the sampled speed (within 1e-6) of the component the
280+// first one started, for a width change and for a move.
281+func TestChainedRectVelocityIsSampledAtActionTime(t *testing.T) {
282+ for _, tc := range []struct {
283+ name string
284+ first, second Action
285+ comp func(*rectMotion) *motion
286+ sampled func(rectShot) float64
287+ }{
288+ {"width", ActionMaximizeColumn, ActionMaximizeColumn,
289+ func(m *rectMotion) *motion { return &m.w }, func(r rectShot) float64 { return r.vel.w }},
290+ {"position", ActionMoveColumnLeft, ActionMoveColumnRight,
291+ func(m *rectMotion) *motion { return &m.x }, func(r rectShot) float64 { return r.vel.x }},
292+ } {
293+ t.Run(tc.name, func(t *testing.T) {
294+ c, ic := chainedRects(t, tc.first)
295+ sc := c.cur()
296+ id := WindowID(0)
297+ for k, rm := range sc.rects {
298+ if tc.comp(&rm).on {
299+ id = k
300+ }
301+ }
302+ if id == 0 {
303+ t.Fatalf("no window with a %s motion in %v", tc.name, sc.rects)
304+ }
305+ rm := sc.rects[id]
306+ m := tc.comp(&rm)
307+ last := m.velocity()
308+ ic.now = ic.now.Add(time.Millisecond)
309+
310+ // The expected speed is the spring's own at now, sampled independently.
311+ tau := time.Duration(float64(ic.now.Sub(m.start)) / m.slow)
312+ want := m.spring.velocityAt(tau) / m.slow
313+ if math.Abs(want-last) < 1e-3 {
314+ t.Fatalf("the speed did not change in 1 ms (%v to %v): the case checks nothing", last, want)
315+ }
316+
317+ before := c.snapshot(ic.now)
318+ var shot rectShot
319+ for _, r := range before[0].rects {
320+ if r.id == id {
321+ shot = r
322+ }
323+ }
324+ if got := tc.sampled(shot); math.Abs(got-want) > 1e-6 {
325+ t.Fatalf("sampled speed %v, want %v", got, want)
326+ }
327+ // Sampling left the motion alone.
328+ cur := sc.rects[id]
329+ if got := tc.comp(&cur).velocity(); got != last {
330+ t.Fatalf("snapshot moved the motion: speed %v, was %v", got, last)
331+ }
332+
333+ c.applyAction(tc.second)
334+ c.transition(before, ic.now)
335+ after := sc.rects[id]
336+ n := tc.comp(&after)
337+ if !n.on {
338+ t.Fatalf("the second action left no %s motion", tc.name)
339+ }
340+ if v := n.velocity(); math.Abs(v-want) > 1e-6 {
341+ t.Fatalf("speed %v after the second action, want the sampled %v", v, want)
342+ }
343+ })
344+ }
345+}
346+
347+// A retarget keeps the sub-pixel offsets: the old rect is the settled one
348+// plus the float offsets, not the rounded rect that was drawn.
349+func TestRetargetKeepsSubPixelOffsets(t *testing.T) {
350+ c, ic := chainedRects(t, ActionMoveColumnLeft)
351+ sc := c.cur()
352+ rm := sc.rects[1]
353+ if rm.dx == math.Round(rm.dx) {
354+ t.Fatalf("dx = %v is whole: the case checks nothing", rm.dx)
355+ }
356+ settledOld := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 1 })].Rect
357+
358+ before := c.snapshot(ic.now)
359+ var shot rectShot
360+ for _, r := range before[0].rects {
361+ if r.id == 1 {
362+ shot = r
363+ }
364+ }
365+ if shot.rect != settledOld {
366+ t.Fatalf("shot rect %+v, want the settled %+v", shot.rect, settledOld)
367+ }
368+ if shot.off.x != rm.dx || shot.off.w != rm.dw {
369+ t.Fatalf("shot offsets %+v, want the motion's x %v w %v", shot.off, rm.dx, rm.dw)
370+ }
371+
372+ c.applyAction(ActionMoveColumnRight)
373+ c.transition(before, ic.now)
374+ settledNew := c.cur().mon.Layout()[slices.IndexFunc(c.cur().mon.Layout(), func(p Placement) bool { return p.ID == 1 })].Rect
375+ if settledNew == settledOld {
376+ t.Fatal("the second action did not move window 1")
377+ }
378+ got := sc.rects[1]
379+ // Old rect = settled old + float offset: the new offset from the new
380+ // settled rect carries the fraction of the old one.
381+ want := float64(settledOld.X-settledNew.X) + rm.dx
382+ if math.Abs(got.dx-want) > 1e-9 {
383+ t.Fatalf("dx = %v after the retarget, want %v (settled %d to %d plus the offset %v)", got.dx, want, settledOld.X, settledNew.X, rm.dx)
384+ }
385+ rounded := float64(settledOld.X-settledNew.X) + math.Round(rm.dx)
386+ if math.Abs(got.dx-rounded) < 1e-6 {
387+ t.Fatalf("dx = %v equals the rounded rect's: the fraction was lost", got.dx)
388+ }
389+}
390+
391+// An action that moves other windows leaves a running rect motion alone: it
392+// is not restarted from the rounded rect nor resampled (same start, speed
393+// and offsets), while the windows the action moved animate.
394+func TestRectMotionOfUnmovedWindowIsUntouched(t *testing.T) {
395+ c, ic := indicatorCore(t)
396+ c.cfg.Animations.On = true
397+ m := c.cur().mon
398+ for id := WindowID(1); id <= 3; id++ {
399+ m.AddWindow(id)
400+ }
401+ // 2 and 3 share a column; 1 is a column of its own.
402+ c.applyAction(ActionConsumeOrExpelLeft)
403+ settleShown(t, c)
404+ sc := c.cur()
405+
406+ // A runs a rect motion, one frame in.
407+ sp := viewSpring(60.5, 0)
408+ sc.rects = map[WindowID]rectMotion{1: {x: c.spring(sp, ic.now), dx: 60.5}}
409+ sc.rectsWS = m.Current()
410+ ic.now = ic.now.Add(30 * time.Millisecond)
411+ c.animate(ic.now, nil)
412+ settleShown(t, c)
413+ a := sc.rects[1]
414+ if !a.x.on || a.dx == 0 || a.dx == 60.5 || a.x.velocity() == 0 {
415+ t.Fatalf("setup: A's motion %+v is not mid-flight", a)
416+ }
417+
418+ // B: swapping the stacked 2 and 3 moves those two only.
419+ ic.now = ic.now.Add(time.Millisecond)
420+ before := c.snapshot(ic.now)
421+ c.applyAction(ActionMoveWindowUp)
422+ c.transition(before, ic.now)
423+ if got, ok := sc.rects[1]; !ok || got != a {
424+ t.Fatalf("A's motion changed: %+v, was %+v", got, a)
425+ }
426+ for _, id := range []WindowID{2, 3} {
427+ b, ok := sc.rects[id]
428+ if !ok || !b.y.on || b.dy == 0 {
429+ t.Fatalf("window %d does not animate: %+v", id, b)
430+ }
431+ if b.x.on {
432+ t.Fatalf("window %d moved sideways: %+v", id, b)
433+ }
434+ }
435+}
A internal/core/transition_fade_internal_test.go +424 -0
@@ -0,0 +1,424 @@
1+package core
2+
3+import (
4+ "math"
5+ "slices"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// fadeCore is indicatorCore with its commands readable.
13+func fadeCore(t *testing.T) (*Core, *indicatorClock, chan ports.ClientCommand) {
14+ t.Helper()
15+ c, ic := indicatorCore(t)
16+ cmds := make(chan ports.ClientCommand, 64)
17+ c.ch.Commands = cmds
18+ return c, ic, cmds
19+}
20+
21+func sceneWindow(t *testing.T, s ports.Scene, id WindowID) ports.SceneWindow {
22+ t.Helper()
23+ i := slices.IndexFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == id })
24+ if i < 0 {
25+ t.Fatalf("window %d not in the scene: %+v", id, s.Windows)
26+ }
27+ return s.Windows[i]
28+}
29+
30+// appear fades a window in from invisible and grows it from 90 % of its
31+// settled rect, its content zoomed to the drawn size; the shown placement
32+// carries the derived Fade and Zoom, the settled one neither; Preview
33+// stays 0 (not a card).
34+func TestAppearDerivesFadeAndZoom(t *testing.T) {
35+ c, ic, cmds := fadeCore(t)
36+ c.cfg.Animations.On = true
37+ sc := c.cur()
38+ sc.mon.AddWindow(1)
39+ sc.mon.AddWindow(2)
40+ settleShown(t, c)
41+ drainConfigures(cmds)
42+ settled := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]
43+
44+ c.appearAt(sc, settledOf(t, sc, 2), ic.now)
45+ s := indicatorScene(t, c)
46+ w := sceneWindow(t, s, 2)
47+ if w.Fade != 1 || w.Hidden {
48+ t.Fatalf("right after appear: fade %v hidden %v, want invisible and shown", w.Fade, w.Hidden)
49+ }
50+ want := scaledRect(settled.Rect, appearScale)
51+ if w.Rect != want {
52+ t.Fatalf("first rect %+v, want 90 %% of %+v = %+v", w.Rect, settled.Rect, want)
53+ }
54+ if math.Abs(w.Zoom-0.9) > 0.02 || w.Preview != 0 {
55+ t.Fatalf("zoom %v preview %v, want ~0.9 and 0 (content follows the drawn size, not a card)", w.Zoom, w.Preview)
56+ }
57+ if ps := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]; ps != settled {
58+ t.Fatalf("settled placement changed: %+v vs %+v", ps, settled)
59+ }
60+ if ids := drainConfigures(cmds); len(ids) != 0 {
61+ t.Fatalf("appear reconfigured %v", ids)
62+ }
63+ // The derived zoom is not a card: the window takes input and keeps
64+ // its popups while it appears.
65+ if id, _, _ := c.hit(float64(w.Rect.X+w.Rect.W/2), float64(w.Rect.Y+w.Rect.H/2)); id != 2 {
66+ t.Fatalf("hit %d while appearing, want 2", id)
67+ }
68+ if _, _, ok := c.windowRect(2); !ok {
69+ t.Fatal("windowRect hides the appearing window")
70+ }
71+
72+ ic.now = ic.now.Add(40 * time.Millisecond)
73+ c.animate(ic.now, nil)
74+ s = indicatorScene(t, c)
75+ w = sceneWindow(t, s, 2)
76+ if !(w.Fade > 0 && w.Fade < 1) || !(w.Zoom > 0.9 && w.Zoom < 1) {
77+ t.Fatalf("mid-way fade %v zoom %v, want strictly between", w.Fade, w.Zoom)
78+ }
79+ if !(w.Rect.W > want.W && w.Rect.W < settled.Rect.W) {
80+ t.Fatalf("mid-way width %d, want between %d and %d", w.Rect.W, want.W, settled.Rect.W)
81+ }
82+
83+ ic.now = ic.now.Add(5 * time.Second)
84+ c.animate(ic.now, nil)
85+ s = indicatorScene(t, c)
86+ w = sceneWindow(t, s, 2)
87+ if w.Fade != 0 || w.Zoom != 0 || w.Rect != settled.Rect || len(sc.rects) != 0 {
88+ t.Fatalf("settled: %+v, rects %d", w, len(sc.rects))
89+ }
90+ if c.animating() {
91+ t.Fatal("still animating after the settle")
92+ }
93+}
94+
95+// With animations off appear does nothing.
96+func TestAppearOffIsInstant(t *testing.T) {
97+ c, ic := indicatorCore(t)
98+ sc := c.cur()
99+ sc.mon.AddWindow(1)
100+ settleShown(t, c)
101+ c.appearAt(sc, settledOf(t, sc, 1), ic.now)
102+ if len(sc.rects) != 0 || c.animating() {
103+ t.Fatal("appear started a motion with animations off")
104+ }
105+ if w := sceneWindow(t, indicatorScene(t, c), 1); w.Fade != 0 || w.Zoom != 0 {
106+ t.Fatalf("faded with animations off: %+v", w)
107+ }
108+}
109+
110+// A leaving window stays in the scene while it fades out and shrinks, is
111+// hidden to input, focus and configures, keeps settled and shown aligned,
112+// and goes once settled.
113+func TestLeavingEntryLifecycle(t *testing.T) {
114+ c, ic, cmds := fadeCore(t)
115+ c.cfg.Animations.On = true
116+ sc := c.cur()
117+ sc.mon.AddWindow(1)
118+ sc.mon.AddWindow(2)
119+ settleShown(t, c)
120+ drainConfigures(cmds)
121+ left := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]
122+
123+ // Close window 2: the layout re-flows; its last placement leaves.
124+ sc.mon.RemoveWindow(2)
125+ c.leaveFrom(sc, left, nil, ic.now)
126+ s := indicatorScene(t, c)
127+ if len(sc.settledLayout) != len(sc.shown) {
128+ t.Fatalf("settled %d and shown %d entries", len(sc.settledLayout), len(sc.shown))
129+ }
130+ for k := range sc.shown {
131+ if sc.shown[k].ID != sc.settledLayout[k].ID {
132+ t.Fatalf("index %d: shown %d, settled %d", k, sc.shown[k].ID, sc.settledLayout[k].ID)
133+ }
134+ }
135+ k := slices.IndexFunc(sc.shown, func(p Placement) bool { return p.ID == 2 })
136+ if k < 0 || !sc.shown[k].Leaving || !sc.shown[k].Hidden || !sc.settledLayout[k].Hidden {
137+ t.Fatalf("leaving entry: shown %+v settled %+v", sc.shown[k], sc.settledLayout[k])
138+ }
139+ w := sceneWindow(t, s, 2)
140+ if w.Hidden || w.Fade != 0 || w.Rect != left.Rect || w.Zoom != 0 || w.Preview != 0 {
141+ t.Fatalf("first leaving frame %+v, want drawn opaque at %+v", w, left.Rect)
142+ }
143+ if ids := drainConfigures(cmds); slices.Contains(ids, 2) {
144+ t.Fatalf("the leaving window was configured: %v", ids)
145+ }
146+ // Window 1 took the whole frame: the leaving one over it is not hit.
147+ cx, cy := float64(left.Rect.X+left.Rect.W/2), float64(left.Rect.Y+left.Rect.H/2)
148+ if id, _, _ := c.hit(cx, cy); id != 1 {
149+ t.Fatalf("hit %d at the leaving window's centre, want 1", id)
150+ }
151+ if id, _ := sc.mon.Focused(); id == 2 {
152+ t.Fatal("the leaving window is focused")
153+ }
154+ if _, _, ok := c.windowRect(2); ok {
155+ t.Fatal("windowRect reports the leaving window on screen")
156+ }
157+ if !c.animating() {
158+ t.Fatal("no motion runs for the leaving window")
159+ }
160+
161+ ic.now = ic.now.Add(40 * time.Millisecond)
162+ c.animate(ic.now, nil)
163+ s = indicatorScene(t, c)
164+ w = sceneWindow(t, s, 2)
165+ if !(w.Fade > 0 && w.Fade < 1) || !(w.Rect.W < left.Rect.W) || !(w.Zoom > 0.9 && w.Zoom < 1) {
166+ t.Fatalf("mid-way %+v", w)
167+ }
168+
169+ ic.now = ic.now.Add(5 * time.Second)
170+ c.animate(ic.now, nil)
171+ s = indicatorScene(t, c)
172+ if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
173+ t.Fatalf("the leaving window is still in the scene: %+v", s.Windows)
174+ }
175+ if len(sc.rects) != 0 || c.animating() || len(sc.shown) != len(sc.settledLayout) {
176+ t.Fatalf("after the settle: rects %d animating %v", len(sc.rects), c.animating())
177+ }
178+}
179+
180+// A leaving window the layout shows again (the stash reopened before the
181+// fade ended) drops its leaving entry at once: the layout's placement wins.
182+func TestLeavingEntryDroppedWhenShownAgain(t *testing.T) {
183+ c, ic := indicatorCore(t)
184+ c.cfg.Animations.On = true
185+ sc := c.cur()
186+ sc.mon.AddWindow(1)
187+ sc.mon.AddWindow(2)
188+ settleShown(t, c)
189+ left := sc.settledLayout[1]
190+ sc.mon.RemoveWindow(2)
191+ c.leaveFrom(sc, left, nil, ic.now)
192+ if s := indicatorScene(t, c); len(s.Windows) != 2 || !sc.shown[1].Leaving {
193+ t.Fatalf("no leaving entry: %+v", s.Windows)
194+ }
195+ sc.mon.AddWindow(2)
196+ s := indicatorScene(t, c)
197+ n := 0
198+ for _, w := range s.Windows {
199+ if w.ID == 2 {
200+ n++
201+ if w.Fade != 0 || w.Zoom != 0 {
202+ t.Fatalf("the re-added window keeps the leaving fade: %+v", w)
203+ }
204+ }
205+ }
206+ if n != 1 || len(sc.rects) != 0 || slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) {
207+ t.Fatalf("window 2 listed %d times, rects %d: %+v", n, len(sc.rects), sc.shown)
208+ }
209+}
210+
211+// stopRects (lock, off, overview, workspace change, unplug) drops leaving
212+// entries with the other motions.
213+func TestLeavingEntryDroppedByStopRects(t *testing.T) {
214+ c, ic := indicatorCore(t)
215+ c.cfg.Animations.On = true
216+ sc := c.cur()
217+ sc.mon.AddWindow(1)
218+ sc.mon.AddWindow(2)
219+ settleShown(t, c)
220+ left := sc.settledLayout[1]
221+ sc.mon.RemoveWindow(2)
222+ c.leaveFrom(sc, left, nil, ic.now)
223+ if s := indicatorScene(t, c); len(s.Windows) != 2 {
224+ t.Fatalf("%d windows, want the tile and the leaving entry", len(s.Windows))
225+ }
226+ c.stopAnimations()
227+ s := indicatorScene(t, c)
228+ if len(s.Windows) != 1 || s.Windows[0].ID != 1 || len(sc.shown) != 1 {
229+ t.Fatalf("after stop: %+v", s.Windows)
230+ }
231+}
232+
233+// With animations off a leaving window is simply gone.
234+func TestLeaveOffIsInstant(t *testing.T) {
235+ c, ic := indicatorCore(t)
236+ sc := c.cur()
237+ sc.mon.AddWindow(1)
238+ settleShown(t, c)
239+ left := sc.settledLayout[0]
240+ sc.mon.RemoveWindow(1)
241+ c.leaveFrom(sc, left, nil, ic.now)
242+ if s := indicatorScene(t, c); len(s.Windows) != 0 || len(sc.rects) != 0 {
243+ t.Fatalf("leaving entry with animations off: %+v", s.Windows)
244+ }
245+}
246+
247+// A dim motion animates the veil of a shown window from an offset to the
248+// settled Dim; a peeking window adds it to the configured stash dim.
249+func TestDimMotionAnimatesSceneDim(t *testing.T) {
250+ c, ic := indicatorCore(t)
251+ c.cfg.Animations.On = true
252+ sc := c.cur()
253+ sc.mon.AddWindow(1)
254+ settleShown(t, c)
255+ sc.rects = map[WindowID]rectMotion{}
256+ var rm rectMotion
257+ c.retargetLevel(&rm.dim, &rm.ddim, 0.5, 0, ic.now)
258+ sc.rects[1], sc.rectsWS = rm, sc.mon.Current()
259+ if w := sceneWindow(t, indicatorScene(t, c), 1); w.Dim != 0.5 {
260+ t.Fatalf("dim %v, want the 0.5 offset", w.Dim)
261+ }
262+ ic.now = ic.now.Add(40 * time.Millisecond)
263+ c.animate(ic.now, nil)
264+ if w := sceneWindow(t, indicatorScene(t, c), 1); !(w.Dim > 0 && w.Dim < 0.5) {
265+ t.Fatalf("mid-way dim %v", w.Dim)
266+ }
267+ ic.now = ic.now.Add(5 * time.Second)
268+ c.animate(ic.now, nil)
269+ if w := sceneWindow(t, indicatorScene(t, c), 1); w.Dim != 0 || len(sc.rects) != 0 {
270+ t.Fatalf("settled dim %v, rects %d", w.Dim, len(sc.rects))
271+ }
272+}
273+
274+// A scale motion on an overview card multiplies the card's settled zoom
275+// into Zoom; Preview stays settled (input tells cards by it).
276+func TestScaleMotionMultipliesSettledPreview(t *testing.T) {
277+ var rm rectMotion
278+ rm.scale, rm.dw = true, 50
279+ p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.2}
280+ rm.dh = 50 // aspect kept: the zoom follows the smaller ratio
281+ rm.show(&p)
282+ if p.Rect.W != 150 || math.Abs(p.Zoom-0.3) > 1e-9 || p.Preview != 0.2 {
283+ t.Fatalf("shown %+v, want width 150, zoom 0.3 and the settled preview", p)
284+ }
285+ // A plain window drawn at its size: Zoom 1 (publish emits Zoom 0).
286+ rm.dw = 0.01
287+ p = Placement{ID: 1, Rect: Rect{W: 100, H: 100}}
288+ rm.show(&p)
289+ if p.Zoom != 1 {
290+ t.Fatalf("zoom %v for an unscaled window, want 1", p.Zoom)
291+ }
292+}
293+
294+// snapshot leaves leaving entries out (they are Hidden), so a transition
295+// after a close never moves them; the neighbours' rects are recorded.
296+func TestSnapshotSkipsLeavingEntries(t *testing.T) {
297+ c, ic := indicatorCore(t)
298+ c.cfg.Animations.On = true
299+ sc := c.cur()
300+ sc.mon.AddWindow(1)
301+ sc.mon.AddWindow(2)
302+ settleShown(t, c)
303+ left := sc.settledLayout[1]
304+ sc.mon.RemoveWindow(2)
305+ c.leaveFrom(sc, left, nil, ic.now)
306+ settleShown(t, c)
307+ shots := c.snapshot(ic.now)
308+ if len(shots) != 1 || len(shots[0].rects) != 1 || shots[0].rects[0].id != 1 {
309+ t.Fatalf("snapshot rects %+v, want window 1 only", shots[0].rects)
310+ }
311+}
312+
313+// A leaving tile is listed where the tiles are painted, before the first
314+// float that opens them; a leaving float goes last. Settled and shown stay
315+// aligned. The veil keeps following the leaving float, fading with it.
316+func TestLeavingPaintOrder(t *testing.T) {
317+ c, ic := indicatorCore(t)
318+ c.cfg.Animations.On = true
319+ c.cfg.Floating.Dim = 0.4
320+ sc := c.cur()
321+ ws := sc.mon.Current()
322+ sc.mon.AddWindow(1)
323+ sc.mon.AddWindow(2)
324+ ws.AddFloating(3, 80, 60)
325+ settleShown(t, c)
326+ tile := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]
327+ float := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 3 })]
328+ if !float.Floating || tile.Floating {
329+ t.Fatalf("setup: tile %+v float %+v", tile, float)
330+ }
331+
332+ // Close the tile: its entry sits before the float.
333+ sc.mon.RemoveWindow(2)
334+ c.leaveFrom(sc, tile, nil, ic.now)
335+ s := indicatorScene(t, c)
336+ order := func(s ports.Scene) []WindowID {
337+ ids := make([]WindowID, 0, len(s.Windows))
338+ for _, w := range s.Windows {
339+ ids = append(ids, w.ID)
340+ }
341+ return ids
342+ }
343+ if got := order(s); !slices.Equal(got, []WindowID{1, 2, 3}) {
344+ t.Fatalf("order with a leaving tile %v, want [1 2 3] (tile before the float)", got)
345+ }
346+ for k := range sc.shown {
347+ if sc.shown[k].ID != sc.settledLayout[k].ID {
348+ t.Fatalf("index %d: shown %d, settled %d", k, sc.shown[k].ID, sc.settledLayout[k].ID)
349+ }
350+ }
351+ if s.Dim != 0.4 {
352+ t.Fatalf("veil %v with the float still shown, want 0.4", s.Dim)
353+ }
354+
355+ // Close the float too: its entry goes last, and the veil goes at
356+ // once (a fading veil would redraw the whole output every frame of
357+ // the fade; the float's own fade redraws only its rect).
358+ sc.mon.RemoveWindow(3)
359+ c.leaveFrom(sc, float, nil, ic.now)
360+ s = indicatorScene(t, c)
361+ if got := order(s); !slices.Equal(got, []WindowID{1, 2, 3}) {
362+ t.Fatalf("order with a leaving float %v, want [1 2 3] (float last)", got)
363+ }
364+ if s.Dim != 0 {
365+ t.Fatalf("veil %v right after the float left, want none", s.Dim)
366+ }
367+ ic.now = ic.now.Add(60 * time.Millisecond)
368+ c.animate(ic.now, nil)
369+ s = indicatorScene(t, c)
370+ if f := sceneWindow(t, s, 3); s.Dim != 0 || !(f.Fade > 0 && f.Fade < 1) {
371+ t.Fatalf("veil %v mid-fade (float fade %v), want none while the float fades", s.Dim, f.Fade)
372+ }
373+ ic.now = ic.now.Add(5 * time.Second)
374+ c.animate(ic.now, nil)
375+ s = indicatorScene(t, c)
376+ if s.Dim != 0 || len(s.Windows) != 1 {
377+ t.Fatalf("after the settle: dim %v windows %+v", s.Dim, s.Windows)
378+ }
379+}
380+
381+// A re-flow that retargets an appearing window's rect motion stops its
382+// content zoom (the aspect may change) and keeps its fade.
383+func TestRetargetClearsScaleKeepsFade(t *testing.T) {
384+ c, ic := indicatorCore(t)
385+ c.cfg.Animations.On = true
386+ sc := c.cur()
387+ sc.mon.AddWindow(1)
388+ settleShown(t, c)
389+ c.appearAt(sc, settledOf(t, sc, 1), ic.now)
390+ settleShown(t, c)
391+ if rm := sc.rects[1]; !rm.scale || !rm.fade.on {
392+ t.Fatalf("setup: %+v", rm)
393+ }
394+ ic.now = ic.now.Add(20 * time.Millisecond)
395+ c.animate(ic.now, nil)
396+ settleShown(t, c)
397+ before := c.snapshot(ic.now)
398+ sc.mon.AddWindow(2) // re-flows window 1
399+ c.transition(before, ic.now)
400+ rm, ok := sc.rects[1]
401+ if !ok || rm.scale || !rm.fade.on || !rm.on() {
402+ t.Fatalf("after the re-flow: %+v", rm)
403+ }
404+ w := sceneWindow(t, indicatorScene(t, c), 1)
405+ if w.Zoom != 0 || !(w.Fade > 0 && w.Fade < 1) {
406+ t.Fatalf("zoom %v fade %v, want no zoom and a running fade", w.Zoom, w.Fade)
407+ }
408+}
409+
410+// Dim offsets are clamped into 0..1 on the shown placement.
411+func TestDimOffsetClamped(t *testing.T) {
412+ var rm rectMotion
413+ rm.ddim = -0.3
414+ p := Placement{ID: 1, Rect: Rect{W: 10, H: 10}}
415+ rm.show(&p)
416+ if p.Dim != 0 {
417+ t.Fatalf("dim %v, want 0", p.Dim)
418+ }
419+ rm.ddim = 1.5
420+ rm.show(&p)
421+ if p.Dim != 1 {
422+ t.Fatalf("dim %v, want 1", p.Dim)
423+ }
424+}
A internal/core/transition_helpers_internal_test.go +56 -0
@@ -0,0 +1,56 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// Helpers shared by the transition tests (transition_*_internal_test.go).
11+
12+// settleShown publishes so the screens' shown layouts are current.
13+func settleShown(t *testing.T, c *Core) {
14+ t.Helper()
15+ indicatorScene(t, c)
16+}
17+
18+// settledOf is the settled placement of window id on sc.
19+func settledOf(t *testing.T, sc *screen, id WindowID) Placement {
20+ t.Helper()
21+ i := slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == id })
22+ if i < 0 {
23+ t.Fatalf("window %d not in the settled layout %+v", id, sc.settledLayout)
24+ }
25+ return sc.settledLayout[i]
26+}
27+
28+// configuresOf returns the configures sent since the last drain, by window.
29+func configuresOf(cmds chan ports.ClientCommand) map[WindowID][]ports.ConfigureWindow {
30+ out := map[WindowID][]ports.ConfigureWindow{}
31+ for {
32+ select {
33+ case cmd := <-cmds:
34+ if v, ok := cmd.(ports.ConfigureWindow); ok {
35+ out[v.ID] = append(out[v.ID], v)
36+ }
37+ default:
38+ return out
39+ }
40+ }
41+}
42+
43+// drainConfigures returns the IDs configured since the last drain, in order.
44+func drainConfigures(cmds chan ports.ClientCommand) []WindowID {
45+ var ids []WindowID
46+ for {
47+ select {
48+ case cmd := <-cmds:
49+ if v, ok := cmd.(ports.ConfigureWindow); ok {
50+ ids = append(ids, v.ID)
51+ }
52+ default:
53+ return ids
54+ }
55+ }
56+}
A internal/core/transition_map_internal_test.go +249 -0
@@ -0,0 +1,249 @@
1+package core
2+
3+import (
4+ "math"
5+ "slices"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// A tiled map: the new window fades in from 90 % of its settled rect, its
13+// neighbour slides from where it was, the client is configured once, to
14+// the final size, and everything settles exactly.
15+func TestMapTiledAppearsAndReflows(t *testing.T) {
16+ c, ic, cmds := fadeCore(t)
17+ c.cfg.Animations.On = true
18+ sc := c.cur()
19+ c.mapWindow(ports.WindowMapped{ID: 1})
20+ indicatorScene(t, c)
21+ c.animate(ic.now.Add(5*time.Second), nil)
22+ ic.now = ic.now.Add(5 * time.Second)
23+ indicatorScene(t, c)
24+ if len(sc.rects) != 0 {
25+ t.Fatalf("motions left after the first window settled: %d", len(sc.rects))
26+ }
27+ drainConfigures(cmds)
28+ old1 := sceneWindow(t, indicatorScene(t, c), 1).Rect
29+
30+ c.mapWindow(ports.WindowMapped{ID: 2})
31+ s := indicatorScene(t, c)
32+ settled2 := settledOf(t, sc, 2)
33+ w2 := sceneWindow(t, s, 2)
34+ if w2.Fade != 1 || w2.Hidden || w2.Rect != scaledRect(settled2.Rect, appearScale) || math.Abs(w2.Zoom-appearScale) > 0.02 || w2.Preview != 0 {
35+ t.Fatalf("new window right after the map: %+v, settled %+v", w2, settled2.Rect)
36+ }
37+ // The neighbour is drawn where it was, and moves to its new half.
38+ if got := sceneWindow(t, s, 1).Rect; got != old1 {
39+ t.Fatalf("neighbour right after the map at %+v, want the old %+v", got, old1)
40+ }
41+ settled1 := settledOf(t, sc, 1)
42+ if settled1.Rect == old1 {
43+ t.Fatal("the new column did not move the neighbour: the case checks nothing")
44+ }
45+ // One configure per window, to the settled size.
46+ got := drainConfigures(cmds)
47+ slices.Sort(got)
48+ if !slices.Equal(got, []WindowID{1, 2}) {
49+ t.Fatalf("configures %v, want one each for 1 and 2", got)
50+ }
51+
52+ ic.now = ic.now.Add(40 * time.Millisecond)
53+ c.animate(ic.now, nil)
54+ s = indicatorScene(t, c)
55+ w2 = sceneWindow(t, s, 2)
56+ if !(w2.Fade > 0 && w2.Fade < 1) || !(w2.Zoom > appearScale && w2.Zoom < 1) || !(w2.Rect.W > scaledRect(settled2.Rect, appearScale).W && w2.Rect.W < settled2.Rect.W) {
57+ t.Fatalf("first frame of the new window: %+v", w2)
58+ }
59+ if r := sceneWindow(t, s, 1).Rect; !(r.W < old1.W && r.W > settled1.Rect.W) {
60+ t.Fatalf("neighbour on the first frame %+v, want between %+v and %+v", r, old1, settled1.Rect)
61+ }
62+ if ids := drainConfigures(cmds); len(ids) != 0 {
63+ t.Fatalf("frames reconfigured %v", ids)
64+ }
65+
66+ ic.now = ic.now.Add(5 * time.Second)
67+ c.animate(ic.now, nil)
68+ s = indicatorScene(t, c)
69+ if w := sceneWindow(t, s, 2); w.Fade != 0 || w.Zoom != 0 || w.Rect != settled2.Rect {
70+ t.Fatalf("new window settled: %+v, want %+v", w, settled2.Rect)
71+ }
72+ if w := sceneWindow(t, s, 1); w.Rect != settled1.Rect {
73+ t.Fatalf("neighbour settled at %+v, want %+v", w.Rect, settled1.Rect)
74+ }
75+ if len(sc.rects) != 0 || c.animating() {
76+ t.Fatal("still animating after the settle")
77+ }
78+}
79+
80+// A floating toplevel appears too.
81+func TestMapFloatingAppears(t *testing.T) {
82+ c, ic, _ := fadeCore(t)
83+ c.cfg.Animations.On = true
84+ sc := c.cur()
85+ c.mapWindow(ports.WindowMapped{ID: 1})
86+ indicatorScene(t, c)
87+ c.mapWindow(ports.WindowMapped{ID: 2, Floating: true, Width: 80, Height: 60})
88+ s := indicatorScene(t, c)
89+ settled := settledOf(t, sc, 2)
90+ w := sceneWindow(t, s, 2)
91+ if !w.Floating && !settled.Floating {
92+ t.Fatalf("window 2 is not a float: %+v", settled)
93+ }
94+ if w.Fade != 1 || w.Rect != scaledRect(settled.Rect, appearScale) {
95+ t.Fatalf("float right after the map: %+v, settled %+v", w, settled.Rect)
96+ }
97+ ic.now = ic.now.Add(5 * time.Second)
98+ c.animate(ic.now, nil)
99+ if w := sceneWindow(t, indicatorScene(t, c), 2); w.Fade != 0 || w.Rect != settled.Rect {
100+ t.Fatalf("float settled: %+v", w)
101+ }
102+}
103+
104+// With animations off a map is instant: no motion, no fade.
105+func TestMapOffIsInstant(t *testing.T) {
106+ c, _, _ := fadeCore(t)
107+ sc := c.cur()
108+ c.mapWindow(ports.WindowMapped{ID: 1})
109+ indicatorScene(t, c)
110+ c.mapWindow(ports.WindowMapped{ID: 2})
111+ s := indicatorScene(t, c)
112+ if len(sc.rects) != 0 || c.animating() {
113+ t.Fatal("a map started a motion with animations off")
114+ }
115+ for _, w := range s.Windows {
116+ if w.Fade != 0 || w.Zoom != 0 || w.Rect != settledOf(t, sc, w.ID).Rect {
117+ t.Fatalf("window %d not at its settled rect: %+v", w.ID, w)
118+ }
119+ }
120+}
121+
122+// A window that is not shown tiled or floating on screen does not appear:
123+// fullscreen, hidden behind a fullscreen game, with a session lock or with
124+// the overview open. Neighbours stay put as well in the last two.
125+func TestMapExceptions(t *testing.T) {
126+ t.Run("fullscreen", func(t *testing.T) {
127+ c, ic, _ := fadeCore(t)
128+ c.cfg.Animations.On = true
129+ sc := c.cur()
130+ c.mapWindow(ports.WindowMapped{ID: 1})
131+ c.stopAnimations() // the first window's own entrance
132+ sc.mon.SetFullscreen(1, true)
133+ c.appearMapped(1, ic.now)
134+ indicatorScene(t, c)
135+ if len(sc.rects) != 0 || c.animating() {
136+ t.Fatalf("a fullscreen window appeared: %+v", sc.rects)
137+ }
138+ })
139+ t.Run("behind a fullscreen game", func(t *testing.T) {
140+ c, _, _ := fadeCore(t)
141+ c.cfg.Animations.On = true
142+ sc := c.cur()
143+ sc.mon.Current().Overflow = OverflowFixed
144+ c.mapWindow(ports.WindowMapped{ID: 1})
145+ c.stopAnimations() // the first window's own entrance
146+ sc.mon.SetFullscreen(1, true)
147+ indicatorScene(t, c)
148+ c.mapWindow(ports.WindowMapped{ID: 2})
149+ if s := indicatorScene(t, c); len(sc.rects) != 0 || sceneWindow(t, s, 2).Fade != 0 {
150+ t.Fatalf("a window mapped behind a fullscreen one appeared: %+v", sc.rects)
151+ }
152+ })
153+ t.Run("session lock", func(t *testing.T) {
154+ c, _, _ := fadeCore(t)
155+ c.cfg.Animations.On = true
156+ sc := c.cur()
157+ c.mapWindow(ports.WindowMapped{ID: 1})
158+ c.stopAnimations() // the first window's own entrance
159+ c.security = ports.SecurityState{Generation: 1, Protected: true}
160+ c.mapWindow(ports.WindowMapped{ID: 2})
161+ if len(sc.rects) != 0 || c.animating() {
162+ t.Fatalf("a window mapped under a session lock animated: %+v", sc.rects)
163+ }
164+ })
165+ t.Run("overview open", func(t *testing.T) {
166+ c, _, _ := fadeCore(t)
167+ c.cfg.Animations.On = true
168+ sc := c.cur()
169+ c.mapWindow(ports.WindowMapped{ID: 1})
170+ c.stopAnimations() // the first window's own entrance
171+ sc.mon.ToggleOverview()
172+ c.mapWindow(ports.WindowMapped{ID: 2})
173+ if len(sc.rects) != 0 {
174+ t.Fatalf("a window mapped in the overview animated: %+v", sc.rects)
175+ }
176+ })
177+ t.Run("map again", func(t *testing.T) {
178+ c, ic, _ := fadeCore(t)
179+ c.cfg.Animations.On = true
180+ sc := c.cur()
181+ c.mapWindow(ports.WindowMapped{ID: 1})
182+ c.stopAnimations() // the first window's own entrance
183+ indicatorScene(t, c)
184+ ic.now = ic.now.Add(5 * time.Second)
185+ c.mapWindow(ports.WindowMapped{ID: 1})
186+ c.stopAnimations() // the first window's own entrance
187+ if len(sc.rects) != 0 {
188+ t.Fatalf("a repeated map restarted the entrance: %+v", sc.rects)
189+ }
190+ })
191+}
192+
193+// A float maps before its first commit: Width and Height are 0, so core lays
194+// it out at a default size, and the real one comes in WindowResized. The
195+// entrance must restart from 90 % of the real size, not stay distorted.
196+func TestMapFloatEntranceFollowsItsFirstCommit(t *testing.T) {
197+ c, ic, _ := fadeCore(t)
198+ c.cfg.Animations.On = true
199+ sc := c.cur()
200+ c.mapWindow(ports.WindowMapped{ID: 1})
201+ c.stopAnimations()
202+ c.mapWindow(ports.WindowMapped{ID: 2, Floating: true})
203+ indicatorScene(t, c)
204+ guess := settledOf(t, sc, 2).Rect
205+
206+ c.resizeFloating(ports.WindowResized{ID: 2, Width: 60, Height: 20})
207+ s := indicatorScene(t, c)
208+ real := settledOf(t, sc, 2).Rect
209+ if real == guess || real.W == real.H*guess.W/guess.H {
210+ t.Fatalf("the resize did not change the settled shape: %+v then %+v", guess, real)
211+ }
212+ w := sceneWindow(t, s, 2)
213+ want := scaledRect(real, appearScale)
214+ near := func(a, b int) bool { return a-b <= 1 && b-a <= 1 }
215+ if w.Fade != 1 || !near(w.Rect.X, want.X) || !near(w.Rect.Y, want.Y) || !near(w.Rect.W, want.W) || !near(w.Rect.H, want.H) {
216+ t.Fatalf("first frame %+v, want 90 %% of the real %+v = %+v", w, real, want)
217+ }
218+ if math.Abs(w.Zoom-appearScale) > 0.02 {
219+ t.Fatalf("zoom %v, want ~0.9 (aspect kept)", w.Zoom)
220+ }
221+
222+ // Mid-way the frame keeps the real aspect, the zoom follows its width.
223+ ic.now = ic.now.Add(40 * time.Millisecond)
224+ c.animate(ic.now, nil)
225+ w = sceneWindow(t, indicatorScene(t, c), 2)
226+ ratio, wantRatio := float64(w.Rect.W)/float64(w.Rect.H), float64(real.W)/float64(real.H)
227+ if math.Abs(ratio-wantRatio)/wantRatio > 0.06 || !(w.Rect.W > want.W && w.Rect.W < real.W) || math.Abs(w.Zoom-float64(w.Rect.W)/float64(real.W)) > 0.01 {
228+ t.Fatalf("mid-way %+v: aspect %.3f want %.3f", w, ratio, wantRatio)
229+ }
230+
231+ ic.now = ic.now.Add(5 * time.Second)
232+ c.animate(ic.now, nil)
233+ if w := sceneWindow(t, indicatorScene(t, c), 2); w.Fade != 0 || w.Zoom != 0 || w.Rect != real {
234+ t.Fatalf("settled %+v, want %+v", w, real)
235+ }
236+}
237+
238+// A resize with no entrance running only moves the settled rect.
239+func TestResizeFloatWithoutEntranceKeepsNoMotion(t *testing.T) {
240+ c, _, _ := fadeCore(t)
241+ c.cfg.Animations.On = true
242+ sc := c.cur()
243+ c.mapWindow(ports.WindowMapped{ID: 1, Floating: true})
244+ c.stopAnimations()
245+ c.resizeFloating(ports.WindowResized{ID: 1, Width: 60, Height: 20})
246+ if len(sc.rects) != 0 {
247+ t.Fatalf("a resize started a motion: %+v", sc.rects)
248+ }
249+}
A internal/core/transition_overview_internal_test.go +445 -0
@@ -0,0 +1,445 @@
1+package core
2+
3+import (
4+ "context"
5+ "math"
6+ "slices"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// overviewRig is indicatorCore with animations on, windows 1 and 2 in two
14+// columns of the first workspace and window 3 alone on the second, settled,
15+// the first workspace on screen.
16+func overviewRig(t *testing.T) (*Core, *indicatorClock, *screen) {
17+ t.Helper()
18+ c, ic := indicatorCore(t)
19+ c.cfg.Animations.On = true
20+ sc := c.cur()
21+ m := sc.mon
22+ m.AddWindow(1)
23+ m.AddWindow(2)
24+ m.Focus(1)
25+ m.AddWindow(3)
26+ m.Focus(0)
27+ settleShown(t, c)
28+ return c, ic, sc
29+}
30+
31+// toggleOverview is what a bind does: snapshot, toggle, transition.
32+func toggleOverview(c *Core, sc *screen, now time.Time) {
33+ shots := c.snapshot(now)
34+ sc.mon.ToggleOverview()
35+ c.transition(shots, now)
36+}
37+
38+// frame moves the clock by d, steps the springs and returns the scene.
39+func frame(t *testing.T, c *Core, ic *indicatorClock, d time.Duration) ports.Scene {
40+ t.Helper()
41+ ic.now = ic.now.Add(d)
42+ c.animate(ic.now, nil)
43+ return indicatorScene(t, c)
44+}
45+
46+// contentScale is the scale the renderer draws the window's content at:
47+// Zoom, else Preview, else 1 (vulkan walk).
48+func contentScale(w ports.SceneWindow) float64 {
49+ if w.Zoom > 0 {
50+ return w.Zoom
51+ }
52+ if w.Preview > 0 {
53+ return w.Preview
54+ }
55+ return 1
56+}
57+
58+// checkContent fails unless the content scale the renderer will use draws
59+// the content at the frame's size (within rounding) and never magnifies it.
60+func checkContent(t *testing.T, w ports.SceneWindow, full ports.Rect, when string) {
61+ t.Helper()
62+ k := contentScale(w)
63+ if k <= 0 || k > 1 {
64+ t.Fatalf("%s: content scale %v out of (0, 1]: %+v", when, k, w)
65+ }
66+ if got := k * float64(full.W); math.Abs(got-float64(w.Rect.W)) > 1.5 {
67+ t.Fatalf("%s: content drawn %.1f wide in a %d wide frame (scale %v, full %d): %+v", when, got, w.Rect.W, k, full.W, w)
68+ }
69+ if got := k * float64(full.H); math.Abs(got-float64(w.Rect.H)) > 1.5 {
70+ t.Fatalf("%s: content drawn %.1f high in a %d high frame (scale %v, full %d): %+v", when, got, w.Rect.H, k, full.H, w)
71+ }
72+}
73+
74+// Opening: a card starts at its window's rect with its content at full
75+// size, shrinks to its overview card with the content following every frame,
76+// and settles on the exact overview layout; the other rows fade in. Closing
77+// mirrors it and animations off are instant.
78+func TestOverviewOpenCloseAnimatesCards(t *testing.T) {
79+ c, ic, sc := overviewRig(t)
80+ full := map[WindowID]ports.Rect{}
81+ for _, id := range []WindowID{1, 2} {
82+ full[id] = sceneWindow(t, indicatorScene(t, c), id).Rect
83+ }
84+
85+ toggleOverview(c, sc, ic.now)
86+ if !sc.mon.ov.open || !c.animating() {
87+ t.Fatalf("overview open %v, animating %v", sc.mon.ov.open, c.animating())
88+ }
89+ s := indicatorScene(t, c)
90+ card := map[WindowID]Placement{}
91+ for _, p := range sc.settledLayout {
92+ card[p.ID] = p
93+ }
94+ for _, id := range []WindowID{1, 2} {
95+ w := sceneWindow(t, s, id)
96+ settled := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == id })]
97+ if w.Rect != full[id] || w.Preview != settled.Preview || w.Zoom != 1 {
98+ t.Fatalf("window %d first frame %+v, want the full rect %+v at content scale 1 (card preview %v)", id, w, full[id], settled.Preview)
99+ }
100+ checkContent(t, w, full[id], "first frame")
101+ }
102+ // The other row: invisible, drawn as a card, no motion of its own.
103+ w3 := sceneWindow(t, s, 3)
104+ if w3.Hidden || w3.Fade != 1 || w3.Preview == 0 || w3.Zoom != 0 {
105+ t.Fatalf("other row first frame %+v, want invisible card without zoom", w3)
106+ }
107+
108+ for i := range 6 {
109+ s = frame(t, c, ic, 25*time.Millisecond)
110+ for _, id := range []WindowID{1, 2} {
111+ w := sceneWindow(t, s, id)
112+ checkContent(t, w, full[id], "opening")
113+ if i == 1 {
114+ p := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == id })]
115+ if !(w.Rect.W < full[id].W && w.Rect.W > p.Rect.W) || !(w.Zoom > p.Preview && w.Zoom < 1) {
116+ t.Fatalf("window %d mid-way %+v, want a frame between %+v and %+v and a zoom between %v and 1", id, w, full[id], p.Rect, p.Preview)
117+ }
118+ }
119+ }
120+ if i == 1 {
121+ if w := sceneWindow(t, s, 3); !(w.Fade > 0 && w.Fade < 1) {
122+ t.Fatalf("other row mid-way fade %v, want strictly between 0 and 1", w.Fade)
123+ }
124+ }
125+ }
126+ s = frame(t, c, ic, 5*time.Second)
127+ for _, p := range sc.mon.Layout() {
128+ if p.Hidden {
129+ continue
130+ }
131+ w := sceneWindow(t, s, p.ID)
132+ if w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 || w.Preview != p.Preview {
133+ t.Fatalf("settled window %d %+v, want the exact overview layout %+v", p.ID, w, p)
134+ }
135+ }
136+ if c.animating() || len(sc.rects) != 0 {
137+ t.Fatalf("still animating after the settle: %d rects", len(sc.rects))
138+ }
139+
140+ // Close: the cards grow back to the windows, content following.
141+ toggleOverview(c, sc, ic.now)
142+ if sc.mon.ov.open {
143+ t.Fatal("the overview did not close")
144+ }
145+ s = indicatorScene(t, c)
146+ for _, id := range []WindowID{1, 2} {
147+ w := sceneWindow(t, s, id)
148+ if w.Preview != 0 || w.Rect != card[id].Rect {
149+ t.Fatalf("window %d first closing frame %+v, want the card rect %+v as a window", id, w, card[id].Rect)
150+ }
151+ checkContent(t, w, full[id], "first closing frame")
152+ }
153+ for i := range 6 {
154+ s = frame(t, c, ic, 25*time.Millisecond)
155+ for _, id := range []WindowID{1, 2} {
156+ w := sceneWindow(t, s, id)
157+ checkContent(t, w, full[id], "closing")
158+ if i == 1 && !(w.Rect.W > card[id].Rect.W && w.Rect.W < full[id].W && w.Zoom > 0 && w.Zoom < 1) {
159+ t.Fatalf("window %d mid-way closing %+v", id, w)
160+ }
161+ }
162+ }
163+ s = frame(t, c, ic, 5*time.Second)
164+ for _, id := range []WindowID{1, 2} {
165+ if w := sceneWindow(t, s, id); w.Rect != full[id] || w.Zoom != 0 || w.Preview != 0 {
166+ t.Fatalf("window %d settled after closing %+v, want %+v", id, w, full[id])
167+ }
168+ }
169+ if w := sceneWindow(t, s, 3); !w.Hidden {
170+ t.Fatalf("window 3 %+v after closing, want hidden (no fade-out)", w)
171+ }
172+ if c.animating() {
173+ t.Fatal("still animating after closing")
174+ }
175+}
176+
177+// With animations off the overview opens and closes at once.
178+func TestOverviewOffIsInstant(t *testing.T) {
179+ c, ic, sc := overviewRig(t)
180+ c.cfg.Animations.On = false
181+ toggleOverview(c, sc, ic.now)
182+ s := indicatorScene(t, c)
183+ for _, p := range sc.mon.Layout() {
184+ if p.Hidden {
185+ continue
186+ }
187+ if w := sceneWindow(t, s, p.ID); w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 {
188+ t.Fatalf("window %d %+v with animations off, want %+v", p.ID, w, p)
189+ }
190+ }
191+ if c.animating() || len(sc.rects) != 0 {
192+ t.Fatal("a motion runs with animations off")
193+ }
194+ toggleOverview(c, sc, ic.now)
195+ if indicatorScene(t, c); c.animating() || len(sc.rects) != 0 {
196+ t.Fatal("a motion runs after closing with animations off")
197+ }
198+}
199+
200+// Moving the selection inside the open overview slides the cards (its rows
201+// scroll): same rect motions, contents scaled; the row that comes in fades.
202+func TestOverviewNavigationAnimates(t *testing.T) {
203+ c, ic, sc := overviewRig(t)
204+ toggleOverview(c, sc, ic.now)
205+ s := frame(t, c, ic, 5*time.Second)
206+ before := map[WindowID]ports.SceneWindow{}
207+ for _, w := range s.Windows {
208+ before[w.ID] = w
209+ }
210+ if !before[3].Hidden && before[3].Fade != 0 {
211+ t.Fatalf("setup: %+v", before[3])
212+ }
213+ // Down: the second row becomes the current one; rows scroll up.
214+ shots := c.snapshot(ic.now)
215+ sc.mon.OverviewMove(0, 1)
216+ c.transition(shots, ic.now)
217+ s = indicatorScene(t, c)
218+ moved := 0
219+ for _, w := range s.Windows {
220+ if w.Hidden {
221+ continue
222+ }
223+ b, ok := before[w.ID]
224+ if !ok || b.Hidden {
225+ continue
226+ }
227+ if w.Rect != b.Rect {
228+ t.Fatalf("window %d jumped on the first frame: %+v then %+v", w.ID, b.Rect, w.Rect)
229+ }
230+ moved++
231+ }
232+ if moved == 0 {
233+ t.Fatalf("no card was shown before and after: %+v", s.Windows)
234+ }
235+ s = frame(t, c, ic, 40*time.Millisecond)
236+ slid := false
237+ for _, w := range s.Windows {
238+ if b, ok := before[w.ID]; ok && !b.Hidden && !w.Hidden && w.Rect != b.Rect {
239+ slid = true
240+ // A card moving between cards keeps its content scale.
241+ if k := contentScale(w); k <= 0 || k > 1 {
242+ t.Fatalf("window %d scale %v", w.ID, k)
243+ }
244+ }
245+ }
246+ if !slid {
247+ t.Fatal("no card moved mid-way")
248+ }
249+ s = frame(t, c, ic, 5*time.Second)
250+ for _, p := range sc.mon.Layout() {
251+ if p.Hidden {
252+ continue
253+ }
254+ if w := sceneWindow(t, s, p.ID); w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 {
255+ t.Fatalf("window %d settled %+v, want %+v", p.ID, w, p)
256+ }
257+ }
258+}
259+
260+// A click while the cards move picks the card where it is drawn (the shown
261+// layout), and a click on a still overview is unchanged.
262+func TestOverviewClickDuringOpenUsesShownCards(t *testing.T) {
263+ c, ic, sc := overviewRig(t)
264+ toggleOverview(c, sc, ic.now)
265+ s := frame(t, c, ic, 30*time.Millisecond)
266+ // The pointer sits on the right half of the output: the shown card of
267+ // window 2 covers it; its settled card does not reach that far left.
268+ w2 := sceneWindow(t, s, 2)
269+ x, y := float64(w2.Rect.X+w2.Rect.W-1), float64(w2.Rect.Y+w2.Rect.H/2)
270+ settled := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == 2 })]
271+ if x < float64(settled.Rect.X+settled.Rect.W) {
272+ t.Fatalf("setup: x %v is inside the settled card %+v", x, settled.Rect)
273+ }
274+ if id := overviewIn(sc.mon.Layout(), x, y); id == 2 {
275+ t.Fatalf("setup: the settled layout picks 2 at %v,%v", x, y)
276+ }
277+ c.cursorX, c.cursorY = x+float64(sc.x), y+float64(sc.y)
278+ picked, err := c.overviewClick(context.Background())
279+ if err != nil || !picked {
280+ t.Fatalf("picked %v err %v during the opening", picked, err)
281+ }
282+ if sc.mon.ov.open {
283+ t.Fatal("the overview stayed open")
284+ }
285+ if id, _ := sc.mon.Focused(); id != 2 {
286+ t.Fatalf("focused %d, want 2 (the card drawn there)", id)
287+ }
288+}
289+
290+// A scale motion on a card never magnifies content: a shown frame wider than
291+// the card's settled one gives at most scale 1, and a card in flight at its
292+// full size still carries Zoom 1 (Preview alone would shrink it).
293+func TestCardZoomClampedAndEmitted(t *testing.T) {
294+ var rm rectMotion
295+ rm.scale, rm.dw, rm.dh = true, 400, 240
296+ p := Placement{ID: 1, Rect: Rect{W: 100, H: 60}, Preview: overviewCardZoom}
297+ rm.show(&p)
298+ if p.Zoom != 1 || p.Preview != overviewCardZoom {
299+ t.Fatalf("shown %+v, want zoom clamped to 1 and the card's preview", p)
300+ }
301+ c, ic, sc := overviewRig(t)
302+ toggleOverview(c, sc, ic.now)
303+ if w := sceneWindow(t, indicatorScene(t, c), 1); w.Zoom != 1 || w.Preview == 0 {
304+ t.Fatalf("card at full size emitted %+v, want Zoom 1 with its Preview", w)
305+ }
306+}
307+
308+// Rect motions belong to the overview state: a state change with no
309+// transition (animations off meanwhile, a session lock) drops them, and
310+// leaving the overview never keeps a card's motion on a window.
311+func TestOverviewRectsDroppedOnStateChange(t *testing.T) {
312+ c, ic, sc := overviewRig(t)
313+ toggleOverview(c, sc, ic.now)
314+ indicatorScene(t, c)
315+ if len(sc.rects) == 0 {
316+ t.Fatal("no motion for the opening")
317+ }
318+ sc.mon.ToggleOverview()
319+ indicatorScene(t, c)
320+ if len(sc.rects) != 0 {
321+ t.Fatalf("%d card motions survived the close without a transition", len(sc.rects))
322+ }
323+}
324+
325+// The overview has no camera: toggling it, moving in it and closing it never
326+// start a column or workspace slide, right after the action (settled or not).
327+func TestOverviewStartsNoCameraSlide(t *testing.T) {
328+ c, ic, sc := overviewRig(t)
329+ m := sc.mon
330+ for _, id := range []WindowID{4, 5} {
331+ m.AddWindow(id) // more columns than fit: the view scrolls
332+ }
333+ settleShown(t, c)
334+ c.animate(ic.now.Add(5*time.Second), nil)
335+ ic.now = ic.now.Add(5 * time.Second)
336+ noCamera := func(when string) {
337+ t.Helper()
338+ if m.switchMotion.on || m.Current().motion.on {
339+ t.Fatalf("%s: a camera spring runs (switch %v, view %v)", when, m.switchMotion.on, m.Current().motion.on)
340+ }
341+ }
342+ noCamera("before")
343+ toggleOverview(c, sc, ic.now)
344+ noCamera("after open")
345+ frame(t, c, ic, 30*time.Millisecond)
346+ noCamera("mid open")
347+ for _, mv := range [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
348+ shots := c.snapshot(ic.now)
349+ m.OverviewMove(mv[0], mv[1])
350+ c.transition(shots, ic.now)
351+ noCamera("after move")
352+ frame(t, c, ic, 30*time.Millisecond)
353+ }
354+ toggleOverview(c, sc, ic.now)
355+ noCamera("after close")
356+ frame(t, c, ic, 5*time.Second)
357+ noCamera("settled")
358+}
359+
360+// A card that still fades in (Fade >= 0.5) takes no click; a visible one
361+// does, and a faded card lets the one under it be picked.
362+func TestOverviewInSkipsFadedCards(t *testing.T) {
363+ r := Rect{X: 0, Y: 0, W: 100, H: 100}
364+ layout := []Placement{
365+ {ID: 1, Rect: r, Preview: 0.2},
366+ {ID: 2, Rect: r, Preview: 0.2, Fade: 0.6},
367+ }
368+ if id := overviewIn(layout, 50, 50); id != 1 {
369+ t.Fatalf("picked %d, want 1 (2 is nearly invisible)", id)
370+ }
371+ layout[1].Fade = 0.4
372+ if id := overviewIn(layout, 50, 50); id != 2 {
373+ t.Fatalf("picked %d, want 2 (visible enough)", id)
374+ }
375+ layout = layout[1:]
376+ layout[0].Fade = 0.9
377+ if id := overviewIn(layout, 50, 50); id != 0 {
378+ t.Fatalf("picked %d, want none", id)
379+ }
380+ // And through the shown layout of a running opening.
381+ c, ic, sc := overviewRig(t)
382+ toggleOverview(c, sc, ic.now)
383+ s := indicatorScene(t, c)
384+ w3 := sceneWindow(t, s, 3)
385+ if w3.Fade != 1 {
386+ t.Fatalf("setup: window 3 fade %v", w3.Fade)
387+ }
388+ if id := overviewIn(sc.shownLayout(), float64(w3.Rect.X+w3.Rect.W/2), float64(w3.Rect.Y+w3.Rect.H/2)); id == 3 {
389+ t.Fatal("the invisible card of the next row was picked")
390+ }
391+}
392+
393+// A fade that runs on a card carries over a state change: picking it
394+// mid-fade (here by closing the overview on it) starts from the fade it
395+// had, it does not jump to opaque.
396+func TestOverviewCarriesFadeAcrossState(t *testing.T) {
397+ c, ic, sc := overviewRig(t)
398+ toggleOverview(c, sc, ic.now)
399+ s := frame(t, c, ic, 40*time.Millisecond)
400+ mid := sceneWindow(t, s, 3).Fade
401+ if !(mid > 0.05 && mid < 0.95) {
402+ t.Fatalf("setup: window 3 fade %v mid-way", mid)
403+ }
404+ shots := c.snapshot(ic.now)
405+ sc.mon.OverviewPick(3)
406+ c.transition(shots, ic.now)
407+ if sc.mon.ov.open {
408+ t.Fatal("the overview stayed open")
409+ }
410+ got := sceneWindow(t, indicatorScene(t, c), 3)
411+ if math.Abs(got.Fade-mid) > 0.02 {
412+ t.Fatalf("fade %v after the pick, want the sampled %v", got.Fade, mid)
413+ }
414+ s = frame(t, c, ic, 5*time.Second)
415+ if w := sceneWindow(t, s, 3); w.Fade != 0 {
416+ t.Fatalf("settled fade %v", w.Fade)
417+ }
418+}
419+
420+// The content zoom follows the smaller of the width and height ratios: when
421+// the aspect does not hold, content never exceeds the frame.
422+func TestScaleZoomFollowsSmallerRatio(t *testing.T) {
423+ for _, tc := range []struct {
424+ name string
425+ dw, dh float64
426+ want float64
427+ }{
428+ {"narrower", -50, 0, 0.5},
429+ {"shorter", 0, -50, 0.5},
430+ {"both", -20, -40, 0.6},
431+ {"aspect kept", -30, -30, 0.7},
432+ } {
433+ t.Run(tc.name, func(t *testing.T) {
434+ rm := rectMotion{scale: true, dw: tc.dw, dh: tc.dh}
435+ p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}}
436+ rm.show(&p)
437+ if math.Abs(p.Zoom-tc.want) > 1e-9 {
438+ t.Fatalf("zoom %v, want %v", p.Zoom, tc.want)
439+ }
440+ if float64(p.Rect.W)*1 < p.Zoom*100-1e-9 || float64(p.Rect.H) < p.Zoom*100-1e-9 {
441+ t.Fatalf("content %vx%v exceeds the frame %+v", p.Zoom*100, p.Zoom*100, p.Rect)
442+ }
443+ })
444+ }
445+}
A internal/core/transition_stash_internal_test.go +471 -0
@@ -0,0 +1,471 @@
1+package core
2+
3+import (
4+ "math"
5+ "slices"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// stashRig is indicatorCore with animations on and a stash of windows 4, 3
13+// and 2 (2 selected, 3 peeking on its left) over the tile 1, settled. The
14+// peek veil is 0.5.
15+func stashRig(t *testing.T, on bool) (*Core, *indicatorClock, *screen, chan ports.ClientCommand) {
16+ t.Helper()
17+ c, ic, cmds := fadeCore(t)
18+ c.cfg.Animations.On = on
19+ c.cfg.Stash.Dim = 0.5
20+ sc := c.cur()
21+ for id := WindowID(1); id <= 4; id++ {
22+ sc.mon.AddWindow(id)
23+ }
24+ for _, id := range []WindowID{4, 3, 2} {
25+ sc.mon.Current().FocusID(id)
26+ c.applyAction(ActionToggleWindowStash)
27+ }
28+ settleShown(t, c)
29+ drainConfigures(cmds)
30+ return c, ic, sc, cmds
31+}
32+
33+// act is a bind: snapshot, action, transition.
34+func act(c *Core, ic *indicatorClock, a Action) {
35+ shots := c.snapshot(ic.now)
36+ c.applyAction(a)
37+ c.transition(shots, ic.now)
38+}
39+
40+// Showing the stash fades in and grows every window it brings on screen;
41+// the windows it keeps off screen stay out; the settled layout is the
42+// final one at once, with no extra configure.
43+func TestStashShowAppears(t *testing.T) {
44+ c, ic, sc, cmds := stashRig(t, true)
45+ act(c, ic, ActionToggleStashVisible)
46+ settleShown(t, c)
47+ if !sc.mon.Current().stashHidden {
48+ t.Fatal("setup: the stash is shown")
49+ }
50+ ic.now = ic.now.Add(5 * time.Second)
51+ c.animate(ic.now, nil)
52+ settleShown(t, c)
53+ drainConfigures(cmds)
54+
55+ act(c, ic, ActionToggleStashVisible)
56+ s := indicatorScene(t, c)
57+ for _, id := range []WindowID{2, 3} {
58+ p := settledOf(t, sc, id)
59+ w := sceneWindow(t, s, id)
60+ if w.Hidden || w.Fade != 1 || w.Rect != scaledRect(p.Rect, appearScale) || w.Zoom < 0.85 || w.Zoom > 0.95 {
61+ t.Fatalf("window %d first frame %+v, want invisible at 90 %% of %+v", id, w, p.Rect)
62+ }
63+ }
64+ if w := sceneWindow(t, s, 4); !w.Hidden || w.Fade != 0 {
65+ t.Fatalf("window 4 stays off screen: %+v", w)
66+ }
67+ // The action configures what it changes once, at the settled size.
68+ cfgs := configuresOf(cmds)
69+ for _, id := range []WindowID{2, 3} {
70+ if len(cfgs[id]) != 1 {
71+ t.Fatalf("window %d configured %d times, want once: %+v", id, len(cfgs[id]), cfgs[id])
72+ }
73+ want := c.clientRect(settledOf(t, sc, id))
74+ if v := cfgs[id][0]; !v.Visible || v.Width != want.W || v.Height != want.H {
75+ t.Fatalf("window %d configure %+v, want %dx%d (the settled client rect)", id, v, want.W, want.H)
76+ }
77+ }
78+
79+ s = frame(t, c, ic, 40*time.Millisecond)
80+ for _, id := range []WindowID{2, 3} {
81+ w := sceneWindow(t, s, id)
82+ if !(w.Fade > 0 && w.Fade < 1) || !(w.Zoom > 0.9 && w.Zoom < 1) {
83+ t.Fatalf("window %d mid-way fade %v zoom %v, want strictly between", id, w.Fade, w.Zoom)
84+ }
85+ }
86+ s = frame(t, c, ic, 5*time.Second)
87+ for _, id := range []WindowID{2, 3} {
88+ p := settledOf(t, sc, id)
89+ if w := sceneWindow(t, s, id); w.Fade != 0 || w.Zoom != 0 || w.Rect != p.Rect {
90+ t.Fatalf("window %d settled %+v, want %+v", id, w, p.Rect)
91+ }
92+ }
93+ if c.animating() || len(sc.rects) != 0 {
94+ t.Fatalf("still animating: %d rects", len(sc.rects))
95+ }
96+ if ids := drainConfigures(cmds); len(ids) != 0 {
97+ t.Fatalf("the animation reconfigured %v", ids)
98+ }
99+}
100+
101+// Hiding the stash keeps its windows drawn while they fade out and shrink,
102+// out of reach of input and focus, until they settle.
103+func TestStashHideLeaves(t *testing.T) {
104+ c, ic, sc, cmds := stashRig(t, true)
105+ left := map[WindowID]Placement{}
106+ for _, id := range []WindowID{2, 3} {
107+ left[id] = settledOf(t, sc, id)
108+ }
109+ act(c, ic, ActionToggleStashVisible)
110+ s := indicatorScene(t, c)
111+ if len(sc.settledLayout) != len(sc.shown) {
112+ t.Fatalf("settled %d shown %d", len(sc.settledLayout), len(sc.shown))
113+ }
114+ for _, id := range []WindowID{2, 3} {
115+ w := sceneWindow(t, s, id)
116+ if w.Hidden || w.Fade != 0 || w.Rect != left[id].Rect {
117+ t.Fatalf("window %d first frame %+v, want drawn opaque at %+v", id, w, left[id].Rect)
118+ }
119+ if _, _, ok := c.windowRect(id); ok {
120+ t.Fatalf("window %d is still on screen for input", id)
121+ }
122+ }
123+ // The selected window is under the tile: its centre hits the tile.
124+ r := left[2].Rect
125+ if id, _, _ := c.hit(float64(r.X+r.W/2), float64(r.Y+r.H/2)); id == 2 || id == 3 {
126+ t.Fatalf("hit %d on a leaving window", id)
127+ }
128+ if id, _ := sc.mon.Focused(); id == 2 || id == 3 {
129+ t.Fatalf("focused %d", id)
130+ }
131+ if ids := drainConfigures(cmds); slices.Contains(ids, 2) || slices.Contains(ids, 3) {
132+ t.Fatalf("a leaving window was configured: %v", ids)
133+ }
134+
135+ s = frame(t, c, ic, 40*time.Millisecond)
136+ for _, id := range []WindowID{2, 3} {
137+ if w := sceneWindow(t, s, id); !(w.Fade > 0 && w.Fade < 1) || !(w.Rect.W < left[id].Rect.W) {
138+ t.Fatalf("window %d mid-way %+v", id, w)
139+ }
140+ }
141+ s = frame(t, c, ic, 5*time.Second)
142+ for _, id := range []WindowID{2, 3} {
143+ if w := sceneWindow(t, s, id); !w.Hidden {
144+ t.Fatalf("window %d still drawn after the settle: %+v", id, w)
145+ }
146+ }
147+ if c.animating() || len(sc.rects) != 0 {
148+ t.Fatalf("still animating: %d rects", len(sc.rects))
149+ }
150+ // The hide's own configure comes once the fade is over, from the size
151+ // the window had (not from nothing).
152+ var hidden []ports.ConfigureWindow
153+ for {
154+ select {
155+ case cmd := <-cmds:
156+ if v, ok := cmd.(ports.ConfigureWindow); ok && v.ID == 2 {
157+ hidden = append(hidden, v)
158+ }
159+ continue
160+ default:
161+ }
162+ break
163+ }
164+ if len(hidden) != 1 || hidden[0].Visible || hidden[0].Activated {
165+ t.Fatalf("configures of window 2 after the hide: %+v", hidden)
166+ }
167+}
168+
169+// With animations off both are instant.
170+func TestStashToggleOffIsInstant(t *testing.T) {
171+ c, ic, sc, _ := stashRig(t, false)
172+ act(c, ic, ActionToggleStashVisible)
173+ s := indicatorScene(t, c)
174+ if w := sceneWindow(t, s, 2); !w.Hidden || len(sc.rects) != 0 || c.animating() {
175+ t.Fatalf("hide with animations off: %+v, %d rects", w, len(sc.rects))
176+ }
177+ act(c, ic, ActionToggleStashVisible)
178+ s = indicatorScene(t, c)
179+ if w := sceneWindow(t, s, 2); w.Hidden || w.Fade != 0 || w.Zoom != 0 || len(sc.rects) != 0 || c.animating() {
180+ t.Fatalf("show with animations off: %+v, %d rects", w, len(sc.rects))
181+ }
182+}
183+
184+// Navigating in the stash animates the peek veil: the window that leaves the
185+// centre darkens from 0, the one that reaches it clears from the veil.
186+func TestStashNavigationAnimatesPeekDim(t *testing.T) {
187+ c, ic, sc, _ := stashRig(t, true)
188+ if w := sceneWindow(t, indicatorScene(t, c), 3); w.Dim != 0.5 {
189+ t.Fatalf("setup: peek dim %v", w.Dim)
190+ }
191+ act(c, ic, ActionFocusColumnLeft) // 3 selected, 2 peeks on its right
192+ s := indicatorScene(t, c)
193+ if w := sceneWindow(t, s, 3); w.Dim != 0.5 || w.Focused != true {
194+ t.Fatalf("window 3 right after: %+v, want the veil it had", w)
195+ }
196+ if w := sceneWindow(t, s, 2); w.Dim != 0 {
197+ t.Fatalf("window 2 right after: dim %v, want 0 (it was selected)", w.Dim)
198+ }
199+ s = frame(t, c, ic, 40*time.Millisecond)
200+ if w := sceneWindow(t, s, 3); !(w.Dim > 0 && w.Dim < 0.5) {
201+ t.Fatalf("window 3 mid-way dim %v, want strictly between 0 and 0.5", w.Dim)
202+ }
203+ if w := sceneWindow(t, s, 2); !(w.Dim > 0 && w.Dim < 0.5) {
204+ t.Fatalf("window 2 mid-way dim %v, want strictly between 0 and 0.5", w.Dim)
205+ }
206+ s = frame(t, c, ic, 5*time.Second)
207+ if w := sceneWindow(t, s, 3); w.Dim != 0 {
208+ t.Fatalf("window 3 settled dim %v, want 0", w.Dim)
209+ }
210+ if w := sceneWindow(t, s, 2); w.Dim != 0.5 {
211+ t.Fatalf("window 2 settled dim %v, want 0.5", w.Dim)
212+ }
213+ if c.animating() || len(sc.rects) != 0 {
214+ t.Fatalf("still animating: %d rects", len(sc.rects))
215+ }
216+}
217+
218+// A navigation that chains on a running one starts the veil where it was.
219+func TestStashNavigationChainsPeekDim(t *testing.T) {
220+ c, ic, _, _ := stashRig(t, true)
221+ act(c, ic, ActionFocusColumnLeft)
222+ indicatorScene(t, c)
223+ ic.now = ic.now.Add(30 * time.Millisecond)
224+ c.animate(ic.now, nil)
225+ mid := sceneWindow(t, indicatorScene(t, c), 3).Dim
226+ act(c, ic, ActionFocusColumnRight) // 3 goes back to a peek
227+ if got := sceneWindow(t, indicatorScene(t, c), 3).Dim; got < mid-0.02 || got > mid+0.02 {
228+ t.Fatalf("window 3 veil %v after the chained action, want the %v it had", got, mid)
229+ }
230+}
231+
232+// The overview pile: the stash cards shrink from the stash windows' rects
233+// and settle on the pile layout (C1's rules).
234+func TestOverviewPileScalesFromStash(t *testing.T) {
235+ c, ic, sc, _ := stashRig(t, true)
236+ from := map[WindowID]ports.Rect{}
237+ s := indicatorScene(t, c)
238+ for _, id := range []WindowID{2, 3} {
239+ from[id] = sceneWindow(t, s, id).Rect
240+ }
241+ toggleOverview(c, sc, ic.now)
242+ s = indicatorScene(t, c)
243+ for _, id := range []WindowID{2, 3} {
244+ w := sceneWindow(t, s, id)
245+ p := settledOf(t, sc, id)
246+ if w.Hidden || w.Preview != overviewCardZoom || w.Rect != from[id] || w.Zoom != 1 {
247+ t.Fatalf("window %d first frame %+v, want its stash rect %+v at content scale 1 (card %+v)", id, w, from[id], p.Rect)
248+ }
249+ checkContent(t, w, from[id], "first frame")
250+ }
251+ s = frame(t, c, ic, 40*time.Millisecond)
252+ for _, id := range []WindowID{2, 3} {
253+ w := sceneWindow(t, s, id)
254+ p := settledOf(t, sc, id)
255+ if !(w.Rect.W < from[id].W && w.Rect.W > p.Rect.W) || !(w.Zoom > overviewCardZoom && w.Zoom < 1) {
256+ t.Fatalf("window %d mid-way %+v, want between %+v and %+v", id, w, from[id], p.Rect)
257+ }
258+ checkContent(t, w, from[id], "mid-way")
259+ }
260+ s = frame(t, c, ic, 5*time.Second)
261+ for _, id := range []WindowID{2, 3} {
262+ w := sceneWindow(t, s, id)
263+ p := settledOf(t, sc, id)
264+ if w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 || w.Preview != overviewCardZoom {
265+ t.Fatalf("window %d settled %+v, want the pile card %+v", id, w, p)
266+ }
267+ }
268+}
269+
270+// A navigation that brings a window to a margin makes it appear, one that
271+// sends a peek off screen makes it leave; the others only change their veil
272+// and rect.
273+func TestStashNavigationAppearsAndLeaves(t *testing.T) {
274+ c, ic, sc, cmds := stashRig(t, true)
275+ w := sc.mon.Current()
276+ w.AddWindow(5)
277+ w.stashFocus = false // the new tile has the focus, not the stash
278+ c.applyAction(ActionToggleWindowStash) // stash 4 3 2 5: 5 selected, 2 peeks
279+ if len(w.Stash) != 4 || w.stashAt != 3 {
280+ t.Fatalf("setup: stash %+v at %d", w.Stash, w.stashAt)
281+ }
282+ settleShown(t, c)
283+ act(c, ic, ActionFocusColumnLeft) // 2 selected, 3 comes in, 5 stays a peek
284+ s := indicatorScene(t, c)
285+ if w := sceneWindow(t, s, 3); w.Hidden || w.Fade != 1 {
286+ t.Fatalf("window 3 comes to the margin: %+v, want invisible at first", w)
287+ }
288+ if w := sceneWindow(t, s, 5); w.Hidden || w.Fade != 0 {
289+ t.Fatalf("window 5 stays a peek: %+v", w)
290+ }
291+ settledAll := frame(t, c, ic, 5*time.Second)
292+ if w := sceneWindow(t, settledAll, 3); w.Fade != 0 || w.Hidden {
293+ t.Fatalf("window 3 settled %+v", w)
294+ }
295+ drainConfigures(cmds)
296+ act(c, ic, ActionFocusColumnLeft) // 3 selected, 4 comes in, 5 goes
297+ s = indicatorScene(t, c)
298+ if w := sceneWindow(t, s, 4); w.Hidden || w.Fade != 1 {
299+ t.Fatalf("window 4 comes to the margin: %+v", w)
300+ }
301+ if w := sceneWindow(t, s, 5); w.Hidden || w.Fade != 0 || w.Rect.W == 0 {
302+ t.Fatalf("window 5 leaves from its peek rect: %+v", w)
303+ }
304+ s = frame(t, c, ic, 5*time.Second)
305+ if w := sceneWindow(t, s, 5); !w.Hidden {
306+ t.Fatalf("window 5 still drawn after the settle: %+v", w)
307+ }
308+ if c.animating() {
309+ t.Fatal("still animating")
310+ }
311+}
312+
313+// near fails the test unless a and b differ by less than eps.
314+func near(t *testing.T, what string, a, b, eps float64) {
315+ t.Helper()
316+ if math.Abs(a-b) > eps {
317+ t.Fatalf("%s jumped: %v then %v", what, a, b)
318+ }
319+}
320+
321+// A hide during the show's fade-in continues from what was drawn: no jump
322+// in fade or rect, then it settles gone.
323+func TestStashHideDuringShowDoesNotJump(t *testing.T) {
324+ c, ic, sc, _ := stashRig(t, true)
325+ act(c, ic, ActionToggleStashVisible)
326+ indicatorScene(t, c)
327+ ic.now = ic.now.Add(5 * time.Second)
328+ c.animate(ic.now, nil)
329+ indicatorScene(t, c)
330+ act(c, ic, ActionToggleStashVisible) // show
331+ indicatorScene(t, c)
332+ s := frame(t, c, ic, 30*time.Millisecond)
333+ last := sceneWindow(t, s, 2)
334+ if !(last.Fade > 0 && last.Fade < 1) {
335+ t.Fatalf("setup: fade %v", last.Fade)
336+ }
337+ act(c, ic, ActionToggleStashVisible) // hide, same instant
338+ w := sceneWindow(t, indicatorScene(t, c), 2)
339+ if w.Hidden {
340+ t.Fatalf("window 2 vanished: %+v", w)
341+ }
342+ near(t, "fade", last.Fade, w.Fade, 0.02)
343+ near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2)
344+ near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2)
345+ near(t, "zoom", contentScale(last), contentScale(w), 0.03)
346+ s = frame(t, c, ic, 5*time.Second)
347+ if w := sceneWindow(t, s, 2); !w.Hidden || c.animating() || len(sc.rects) != 0 {
348+ t.Fatalf("after the settle: %+v, %d rects", w, len(sc.rects))
349+ }
350+}
351+
352+// A show during the hide's fade-out continues from what was drawn.
353+func TestStashShowDuringHideDoesNotJump(t *testing.T) {
354+ c, ic, sc, _ := stashRig(t, true)
355+ act(c, ic, ActionToggleStashVisible) // hide
356+ indicatorScene(t, c)
357+ s := frame(t, c, ic, 30*time.Millisecond)
358+ last := sceneWindow(t, s, 2)
359+ if !(last.Fade > 0 && last.Fade < 1) {
360+ t.Fatalf("setup: fade %v", last.Fade)
361+ }
362+ act(c, ic, ActionToggleStashVisible) // show
363+ w := sceneWindow(t, indicatorScene(t, c), 2)
364+ if w.Hidden {
365+ t.Fatalf("window 2 hidden: %+v", w)
366+ }
367+ near(t, "fade", last.Fade, w.Fade, 0.02)
368+ near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2)
369+ near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2)
370+ near(t, "zoom", contentScale(last), contentScale(w), 0.03)
371+ // The entrance now runs back to opaque (the fade's speed carries over:
372+ // it may still rise a few frames before it turns).
373+ s = frame(t, c, ic, 150*time.Millisecond)
374+ if n := sceneWindow(t, s, 2); !(n.Fade < w.Fade) {
375+ t.Fatalf("fade %v then %v, want it to clear", w.Fade, n.Fade)
376+ }
377+ s = frame(t, c, ic, 5*time.Second)
378+ p := settledOf(t, sc, 2)
379+ if w := sceneWindow(t, s, 2); w.Fade != 0 || w.Zoom != 0 || w.Rect != p.Rect || c.animating() {
380+ t.Fatalf("settled %+v, want %+v", w, p.Rect)
381+ }
382+}
383+
384+// A peek sliding to a margin that a fast second navigation sends off screen
385+// leaves from where it was drawn, mid-slide, not from its settled rect.
386+func TestStashNavigationLeavesFromSlide(t *testing.T) {
387+ c, ic, sc, _ := stashRig(t, true)
388+ // 2 selected, 3 peeks left. Left: 3 centred, 2 peeks right, 4 enters.
389+ act(c, ic, ActionFocusColumnLeft)
390+ indicatorScene(t, c)
391+ s := frame(t, c, ic, 30*time.Millisecond)
392+ last := sceneWindow(t, s, 2)
393+ settled := settledOf(t, sc, 2)
394+ if last.Rect == settled.Rect {
395+ t.Fatal("setup: window 2 already at rest")
396+ }
397+ // Left again: 4 centred, 3 peeks right, 2 goes off.
398+ act(c, ic, ActionFocusColumnLeft)
399+ w := sceneWindow(t, indicatorScene(t, c), 2)
400+ if w.Hidden {
401+ t.Fatalf("window 2 vanished: %+v", w)
402+ }
403+ near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2)
404+ near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2)
405+ near(t, "dim", last.Dim, w.Dim, 0.03)
406+ if s = frame(t, c, ic, 5*time.Second); !sceneWindow(t, s, 2).Hidden {
407+ t.Fatal("window 2 still drawn after the settle")
408+ }
409+}
410+
411+// Leaving entries land in the same order whatever the map's: a hidden stash
412+// float (replaced in place) and absent tiles and floats (ID order, tiles
413+// before the first float, floats last).
414+func TestWithLeavingOrderIsDeterministic(t *testing.T) {
415+ c, ic := indicatorCore(t)
416+ c.cfg.Animations.On = true
417+ sc := c.cur()
418+ w := sc.mon.Current()
419+ for id := WindowID(1); id <= 4; id++ {
420+ sc.mon.AddWindow(id)
421+ }
422+ w.FocusID(4)
423+ c.applyAction(ActionToggleWindowStash) // 4 stashed, shown
424+ w.AddFloating(5, 20, 10)
425+ settleShown(t, c)
426+ var left []Placement
427+ for _, p := range sc.settledLayout {
428+ if p.ID == 2 || p.ID == 3 || p.ID == 4 || p.ID == 5 {
429+ left = append(left, p)
430+ }
431+ }
432+ if len(left) != 4 {
433+ t.Fatalf("setup: %d placements", len(left))
434+ }
435+ // 2 and 3 close (absent tiles), 5 closes (absent float), 4 hides.
436+ sc.mon.RemoveWindow(2)
437+ sc.mon.RemoveWindow(3)
438+ sc.mon.RemoveWindow(5)
439+ c.applyAction(ActionToggleStashVisible)
440+ for _, p := range left {
441+ c.leaveFrom(sc, p, nil, ic.now)
442+ }
443+ var want []WindowID
444+ for i := range 40 {
445+ c.refreshShown()
446+ var ids []WindowID
447+ for _, p := range sc.settledLayout {
448+ ids = append(ids, p.ID)
449+ }
450+ if i == 0 {
451+ want = ids
452+ if n := len(ids); n < 4 {
453+ t.Fatalf("layout %v", ids)
454+ }
455+ i2, i3 := slices.Index(ids, 2), slices.Index(ids, 3)
456+ i4, i5 := slices.Index(ids, 4), slices.Index(ids, 5)
457+ // Absent tiles by ID, then the leaving stash float (in its
458+ // slot), the absent float last.
459+ if !(i2 >= 0 && i2 < i3 && i3 < i4 && i4 < i5 && i5 == len(ids)-1) {
460+ t.Fatalf("order %v: want tiles 2, 3, then the stash float 4, then 5", ids)
461+ }
462+ if k := slices.Index(ids, 4); !sc.settledLayout[k].Leaving {
463+ t.Fatal("the hidden stash window is not replaced in place")
464+ }
465+ continue
466+ }
467+ if !slices.Equal(ids, want) {
468+ t.Fatalf("run %d: order %v, first run %v", i, ids, want)
469+ }
470+ }
471+}
A internal/core/transition_test.go +724 -0
@@ -0,0 +1,724 @@
1+package core_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/core"
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// xOf is the X of window id in s, shown or not.
12+func xOf(t *testing.T, s ports.Scene, id ports.WindowID) int {
13+ t.Helper()
14+ for _, w := range s.Windows {
15+ if w.ID == id {
16+ return w.Rect.X
17+ }
18+ }
19+ t.Fatalf("window %d not in the scene", id)
20+ return 0
21+}
22+
23+// fourColumns maps four windows of half the width and goes back to the
24+// first column, settled: the view is at 0.
25+func fourColumns(t *testing.T, r *swipeRig) {
26+ t.Helper()
27+ for id := ports.WindowID(1); id <= 4; id++ {
28+ r.mapWindow(t, id)
29+ }
30+ for range 3 {
31+ r.key(t, "Left", ports.ModAlt)
32+ }
33+ if r.cfg.Animations.On {
34+ r.settle(t)
35+ }
36+}
37+
38+// A key that scrolls the view shows the old view right after it, an
39+// intermediate one on the first frame and the final one once settled.
40+func TestKeyScrollTransition(t *testing.T) {
41+ r := startSwipe(t, nil)
42+ threeColumns(t, r)
43+ r.key(t, "Left", ports.ModAlt)
44+ r.key(t, "Left", ports.ModAlt)
45+ r.settle(t)
46+ r.key(t, "Right", ports.ModAlt) // 2 is on screen: nothing scrolls
47+ s := r.key(t, "Right", ports.ModAlt)[0]
48+ if got := xOf(t, s, 1); got != 0 {
49+ t.Fatalf("column 1 at %d right after the key, want the old 0", got)
50+ }
51+ got := xOf(t, r.frame(t, 16*time.Millisecond), 1)
52+ if got >= 0 || got <= -400 {
53+ t.Fatalf("column 1 at %d on the first frame, want between 0 and -400", got)
54+ }
55+ if got := xOf(t, r.settle(t), 1); got != -400 {
56+ t.Fatalf("column 1 at %d settled, want -400", got)
57+ }
58+}
59+
60+// Presses during a transition never send the view backward.
61+func TestChainedKeysNeverJumpBack(t *testing.T) {
62+ r := startSwipe(t, nil)
63+ fourColumns(t, r)
64+ r.key(t, "Right", ports.ModAlt)
65+ r.key(t, "Right", ports.ModAlt)
66+ prev := xOf(t, r.frame(t, 16*time.Millisecond), 1)
67+ r.key(t, "Right", ports.ModAlt)
68+ for i := range 40 {
69+ got := xOf(t, r.frame(t, 16*time.Millisecond), 1)
70+ if got > prev {
71+ t.Fatalf("frame %d: column 1 at %d after %d, the view went back", i, got, prev)
72+ }
73+ prev = got
74+ if got == -800 {
75+ return
76+ }
77+ }
78+ t.Fatalf("column 1 settled at %d, want -800", prev)
79+}
80+
81+func TestWorkspaceKeyTransition(t *testing.T) {
82+ r := startSwipe(t, nil)
83+ r.workspaces(t)
84+ s := r.key(t, "Next", ports.ModAlt)[0]
85+ if got, ok := rectOf(s, 1); !ok || got.Y != 0 {
86+ t.Fatalf("workspace 1 at %+v (shown %t) right after the key, want the old position", got, ok)
87+ }
88+ s = r.frame(t, 16*time.Millisecond)
89+ got, ok := rectOf(s, 2)
90+ if !ok || got.Y <= 0 || got.Y >= 600 {
91+ t.Fatalf("workspace 2 at %+v (shown %t) on the first frame, want Y between 0 and 600", got, ok)
92+ }
93+ if got, _ := rectOf(r.settle(t), 2); got.Y != 0 {
94+ t.Fatalf("workspace 2 at %d settled, want 0", got.Y)
95+ }
96+}
97+
98+// The workspace slide of a key keeps running when the next key goes on.
99+func TestChainedWorkspaceKeysKeepSliding(t *testing.T) {
100+ r := startSwipe(t, nil)
101+ r.workspaces(t)
102+ r.keySettled(t, "Next")
103+ r.keySettled(t, "Next")
104+ r.mapWindow(t, 3)
105+ r.keySettled(t, "Prior")
106+ r.keySettled(t, "Prior")
107+ r.key(t, "Next", ports.ModAlt)
108+ r.frame(t, 16*time.Millisecond)
109+ r.key(t, "Next", ports.ModAlt)
110+ if got, ok := rectOf(r.settle(t), 3); !ok || got.Y != 0 {
111+ t.Fatalf("workspace 3 at %+v (shown %t) settled", got, ok)
112+ }
113+}
114+
115+func TestAnimationsOffKeysLandAtOnce(t *testing.T) {
116+ r := startSwipe(t, animationsOff)
117+ fourColumns(t, r)
118+ r.key(t, "Right", ports.ModAlt)
119+ if got := xOf(t, r.key(t, "Right", ports.ModAlt)[0], 1); got != -400 {
120+ t.Fatalf("column 1 at %d right after the key, want -400", got)
121+ }
122+ r.noFrameScene(t)
123+
124+ r = startSwipe(t, animationsOff)
125+ r.workspaces(t)
126+ s := r.key(t, "Next", ports.ModAlt)[0]
127+ if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
128+ t.Fatalf("workspace 2 at %+v (shown %t) right after the key, want the settled rect", got, ok)
129+ }
130+ r.noFrameScene(t)
131+}
132+
133+// The overview has no camera: opening it on a scrolled view, a bind that
134+// moves the selection and closing it only move the cards (rect motions), which
135+// settle: no spring runs afterwards and nothing slides the view.
136+func TestNoTransitionWithOverview(t *testing.T) {
137+ for _, tc := range []struct {
138+ name string
139+ // open is what the overview shows before the keys of the bind.
140+ sym string
141+ mods ports.Mods
142+ }{
143+ {"open and close", "", 0},
144+ {"focus bind while open", "Left", ports.ModAlt},
145+ {"workspace bind while open", "Next", ports.ModAlt},
146+ } {
147+ t.Run(tc.name, func(t *testing.T) {
148+ r := startSwipe(t, nil)
149+ fourColumns(t, r)
150+ r.key(t, "Right", ports.ModAlt) // 2 is on screen: no scroll
151+ r.keySettled(t, "Right") // the view scrolls away from 0
152+ r.keySettled(t, "Right")
153+ // settleAll: a bind that moves nothing starts no spring.
154+ r.key(t, "o", ports.ModAlt)
155+ r.settleAll(t, wide.Name)
156+ r.noFrameScene(t)
157+ if tc.sym != "" {
158+ r.key(t, tc.sym, tc.mods)
159+ r.settleAll(t, wide.Name)
160+ r.noFrameScene(t)
161+ }
162+ r.key(t, "o", ports.ModAlt)
163+ r.settleAll(t, wide.Name)
164+ r.noFrameScene(t)
165+ })
166+ }
167+}
168+
169+// movedRig plugs a second 800x600 output right of the first.
170+func movedRig(t *testing.T, edit func(*ports.Config)) (*swipeRig, ports.OutputInfo) {
171+ t.Helper()
172+ other := ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}
173+ return startSwipeOn(t, edit, wide, other), other
174+}
175+
176+func outScene(t *testing.T, set []ports.Scene, output string) ports.Scene {
177+ t.Helper()
178+ for _, s := range set {
179+ if s.Output == output {
180+ return s
181+ }
182+ }
183+ t.Fatalf("no scene for %s", output)
184+ return ports.Scene{}
185+}
186+
187+// A workspace that goes to another monitor slides in on it from where the
188+// source drew it, in the destination's coordinates, and settles.
189+func TestMovedWorkspaceSlidesInOnDestination(t *testing.T) {
190+ r, other := movedRig(t, nil)
191+ twoColumns(t, r)
192+ set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
193+ dst := outScene(t, set, other.Name)
194+ // DP-2 starts at x=800: the source's 0 and 400 are -800 and -400 there.
195+ if got1, got2 := rectNow(t, dst, 1), rectNow(t, dst, 2); got1.X != -800 || got2.X != -400 {
196+ t.Fatalf("right after the key 1 at %+v and 2 at %+v, want the source's rects (-800, -400)", got1, got2)
197+ }
198+ set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name)
199+ if !ok {
200+ t.Fatal("no frame while the windows slide")
201+ }
202+ dst = outScene(t, set, other.Name)
203+ for id, end := range map[ports.WindowID]int{1: 0, 2: 400} {
204+ if got := rectNow(t, dst, id).X; got <= end-800 || got >= end {
205+ t.Fatalf("window %d at %d on the first frame, want between %d and %d", id, got, end-800, end)
206+ }
207+ }
208+ set, _ = r.settleAll(t, wide.Name, other.Name)
209+ dst = outScene(t, set, other.Name)
210+ if got1, got2 := rectNow(t, dst, 1), rectNow(t, dst, 2); got1.X != 0 || got2.X != 400 {
211+ t.Fatalf("settled 1 at %+v and 2 at %+v, want 0 and 400", got1, got2)
212+ }
213+ r.noFrameScene(t, wide.Name, other.Name)
214+}
215+
216+// The source screen slides to the workspace it falls back on when that one
217+// was in the list it showed, else it changes at once.
218+func TestMovedWorkspaceSourceSwitchRule(t *testing.T) {
219+ r, other := movedRig(t, nil)
220+ r.workspaces(t) // window 1 on the first workspace, 2 on the second
221+ set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
222+ src := outScene(t, set, wide.Name)
223+ // The second workspace was below the moved one: it slides up from there.
224+ if got := rectNow(t, src, 2).Y; got != 600 {
225+ t.Fatalf("source shows window 2 at y=%d right after the key, want 600 (sliding in)", got)
226+ }
227+ set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name)
228+ if !ok {
229+ t.Fatal("no frame while the source slides")
230+ }
231+ if got := rectNow(t, outScene(t, set, wide.Name), 2).Y; got <= 0 || got >= 600 {
232+ t.Fatalf("window 2 at y=%d on the first frame, want between 0 and 600", got)
233+ }
234+ set, _ = r.settleAll(t, wide.Name, other.Name)
235+ if got := rectNow(t, outScene(t, set, wide.Name), 2); got.Y != 0 {
236+ t.Fatalf("settled window 2 at %+v, want y=0", got)
237+ }
238+}
239+
240+// A source whose new workspace was not in the list it showed (the moved one
241+// was alone: a fresh empty one replaces it) changes at once: nothing slides
242+// in, only the destination's windows move.
243+func TestMovedWorkspaceSourceWithoutNeighbourIsInstant(t *testing.T) {
244+ r, other := movedRig(t, nil)
245+ twoColumns(t, r)
246+ set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
247+ if n := len(outScene(t, set, wide.Name).Windows); n != 0 {
248+ t.Fatalf("source draws %d windows right after the key, want none", n)
249+ }
250+ set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name)
251+ if !ok {
252+ t.Fatal("no frame while the windows slide")
253+ }
254+ if n := len(outScene(t, set, wide.Name).Windows); n != 0 {
255+ t.Fatalf("source draws %d windows on the first frame, want none", n)
256+ }
257+}
258+
259+// With animations off the moved workspace lands at once on both screens.
260+func TestMovedWorkspaceOffIsInstant(t *testing.T) {
261+ r, other := movedRig(t, animationsOff)
262+ twoColumns(t, r)
263+ set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
264+ dst := outScene(t, set, other.Name)
265+ if got1, got2 := rectNow(t, dst, 1), rectNow(t, dst, 2); got1.X != 0 || got2.X != 400 {
266+ t.Fatalf("1 at %+v and 2 at %+v right after the key, want the settled rects", got1, got2)
267+ }
268+ r.noFrameScene(t, wide.Name, other.Name)
269+}
270+
271+// A workspace that comes back to the screen it left within the spring of
272+// its first move slides its windows only: the screen's running workspace
273+// slide (its list still holds the workspace) must not start a vertical one.
274+func TestMovedBackWorkspaceHasNoSwitchSlide(t *testing.T) {
275+ r, other := movedRig(t, nil)
276+ twoColumns(t, r)
277+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
278+ // Within the first spring: the source is still sliding.
279+ if _, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name); !ok {
280+ t.Fatal("no frame while the windows slide")
281+ }
282+ set := r.key(t, "Left", ports.ModAlt|ports.ModCtrl|ports.ModShift)
283+ dst := outScene(t, set, wide.Name)
284+ for id := ports.WindowID(1); id <= 2; id++ {
285+ if got := rectNow(t, dst, id); got.Y != 0 {
286+ t.Fatalf("window %d at %+v right after moving back, want y=0 (no vertical slide)", id, got)
287+ }
288+ }
289+ set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name)
290+ if !ok {
291+ t.Fatal("no frame while the windows slide back")
292+ }
293+ dst = outScene(t, set, wide.Name)
294+ for id := ports.WindowID(1); id <= 2; id++ {
295+ if got := rectNow(t, dst, id); got.Y != 0 {
296+ t.Fatalf("window %d at %+v on the first frame, want y=0 (rects slide only)", id, got)
297+ }
298+ }
299+ set, _ = r.settleAll(t, wide.Name, other.Name)
300+ dst = outScene(t, set, wide.Name)
301+ if got1, got2 := rectNow(t, dst, 1), rectNow(t, dst, 2); got1 != (ports.Rect{W: 400, H: 600}) || got2 != (ports.Rect{X: 400, W: 400, H: 600}) {
302+ t.Fatalf("settled 1 at %+v and 2 at %+v", got1, got2)
303+ }
304+ r.noFrameScene(t, wide.Name, other.Name)
305+}
306+
307+// The start rects are the source's translated by the difference of the
308+// outputs' positions on both axes, with the source's size; they settle on
309+// the destination's layout.
310+func TestMovedWorkspaceStartsFromSourceGeometry(t *testing.T) {
311+ big := ports.OutputInfo{Name: "DP-2", Width: 1024, Height: 768, RefreshMilli: 60000}
312+ for _, tc := range []struct {
313+ name string
314+ dst ports.OutputInfo
315+ anchor ports.OutputAnchor
316+ key string
317+ start, final [2]ports.Rect
318+ }{
319+ {
320+ name: "right same size", dst: ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000},
321+ anchor: ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1"}, key: "Right",
322+ start: [2]ports.Rect{{X: -800, W: 400, H: 600}, {X: -400, W: 400, H: 600}},
323+ final: [2]ports.Rect{{W: 400, H: 600}, {X: 400, W: 400, H: 600}},
324+ },
325+ {
326+ name: "below larger", dst: big,
327+ anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1"}, key: "Down",
328+ start: [2]ports.Rect{{Y: -600, W: 400, H: 600}, {X: 400, Y: -600, W: 400, H: 600}},
329+ final: [2]ports.Rect{{W: 512, H: 768}, {X: 512, W: 512, H: 768}},
330+ },
331+ } {
332+ t.Run(tc.name, func(t *testing.T) {
333+ r := startSwipeOn(t, func(c *ports.Config) {
334+ c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Anchor: tc.anchor}}
335+ c.Binds["Cmd+Ctrl+Shift+Down"] = string(core.ActionMoveWorkspaceToMonitorDown)
336+ }, wide, tc.dst)
337+ twoColumns(t, r)
338+ dst := outScene(t, r.key(t, tc.key, ports.ModAlt|ports.ModCtrl|ports.ModShift), tc.dst.Name)
339+ for i, id := range []ports.WindowID{1, 2} {
340+ if got := rectNow(t, dst, id); got != tc.start[i] {
341+ t.Fatalf("window %d starts at %+v, want %+v", id, got, tc.start[i])
342+ }
343+ }
344+ set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, tc.dst.Name)
345+ if !ok {
346+ t.Fatal("no frame while the windows slide")
347+ }
348+ if got := rectNow(t, outScene(t, set, tc.dst.Name), 1); got == tc.start[0] || got == tc.final[0] {
349+ t.Fatalf("window 1 at %+v on the first frame, want it between start and final", got)
350+ }
351+ set, _ = r.settleAll(t, wide.Name, tc.dst.Name)
352+ dst = outScene(t, set, tc.dst.Name)
353+ for i, id := range []ports.WindowID{1, 2} {
354+ if got := rectNow(t, dst, id); got != tc.final[i] {
355+ t.Fatalf("window %d settles at %+v, want %+v", id, got, tc.final[i])
356+ }
357+ }
358+ })
359+ }
360+}
361+
362+// A named workspace pulled to the focused screen from the one showing it
363+// moves like a monitor move: its windows slide in from the source.
364+func TestPulledNamedWorkspaceSlidesIn(t *testing.T) {
365+ r, other := movedRig(t, func(c *ports.Config) {
366+ c.Workspaces = []ports.WorkspaceConfig{{Name: "web"}}
367+ c.Binds["Alt+w"] = "workspace web"
368+ })
369+ // web shows on DP-2 with a window, then DP-1 pulls it.
370+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
371+ r.key(t, "w", ports.ModAlt)
372+ r.settleAll(t, wide.Name, other.Name)
373+ r.mapWindow(t, 1)
374+ r.settleAll(t, wide.Name, other.Name)
375+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
376+ set := r.key(t, "w", ports.ModAlt)
377+ if got := rectNow(t, outScene(t, set, wide.Name), 1); got.X != 800 {
378+ t.Fatalf("window 1 at %+v right after the pull, want x=800 (where DP-2 drew it)", got)
379+ }
380+ set, ok := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, wide.Name, other.Name)
381+ if !ok {
382+ t.Fatal("no frame while the window slides")
383+ }
384+ if got := rectNow(t, outScene(t, set, wide.Name), 1).X; got <= 0 || got >= 800 {
385+ t.Fatalf("window 1 at x=%d on the first frame, want between 0 and 800", got)
386+ }
387+ set, _ = r.settleAll(t, wide.Name, other.Name)
388+ if got := rectNow(t, outScene(t, set, wide.Name), 1); got != (ports.Rect{W: 800, H: 600}) {
389+ t.Fatalf("settled at %+v", got)
390+ }
391+}
392+
393+// Focusing another monitor moves no window: it is not animated.
394+func TestFocusMonitorHasNoMotion(t *testing.T) {
395+ r, other := movedRig(t, nil)
396+ twoColumns(t, r)
397+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
398+ r.noFrameScene(t, wide.Name, other.Name)
399+}
400+
401+// rectNow is the rect of window id in s; it must be in the scene.
402+func rectNow(t *testing.T, s ports.Scene, id ports.WindowID) ports.Rect {
403+ t.Helper()
404+ r, ok := rectOf(s, id)
405+ if !ok {
406+ t.Fatalf("window %d not shown", id)
407+ }
408+ return r
409+}
410+
411+// twoColumns maps two windows: 1 on the left half, 2 on the right, focused.
412+func twoColumns(t *testing.T, r *swipeRig) ports.Scene {
413+ t.Helper()
414+ r.mapWindow(t, 1)
415+ return r.mapWindow(t, 2)[0]
416+}
417+
418+// Moving a column swaps two windows: the scene right after the key still
419+// shows the old rects, a frame an intermediate one, and the end the final.
420+func TestMoveColumnRectTransition(t *testing.T) {
421+ r := startSwipe(t, nil)
422+ twoColumns(t, r)
423+ s := r.key(t, "Left", ports.ModAlt|ports.ModShift)[0]
424+ if got1, got2 := rectNow(t, s, 1), rectNow(t, s, 2); got1.X != 0 || got2.X != 400 {
425+ t.Fatalf("right after the key 1 at %+v and 2 at %+v, want the old 0 and 400", got1, got2)
426+ }
427+ s = r.frame(t, 16*time.Millisecond)
428+ if got := rectNow(t, s, 1).X; got <= 0 || got >= 400 {
429+ t.Fatalf("window 1 at %d on the first frame, want between 0 and 400", got)
430+ }
431+ if got := rectNow(t, s, 2).X; got <= 0 || got >= 400 {
432+ t.Fatalf("window 2 at %d on the first frame, want between 0 and 400", got)
433+ }
434+ s = r.settle(t)
435+ if got1, got2 := rectNow(t, s, 1), rectNow(t, s, 2); got1.X != 400 || got2.X != 0 {
436+ t.Fatalf("settled 1 at %+v and 2 at %+v, want 400 and 0", got1, got2)
437+ }
438+}
439+
440+// A move that also scrolls the view moves a window by the layout delta only:
441+// window 1 keeps its place on screen while the view and the others slide.
442+func TestMoveColumnThatScrollsKeepsCameraFrame(t *testing.T) {
443+ r := startSwipe(t, nil)
444+ fourColumns(t, r)
445+ r.key(t, "Right", ports.ModAlt|ports.ModShift)
446+ r.settle(t) // order 2 1 3 4, view 0
447+ s := r.key(t, "Right", ports.ModAlt|ports.ModShift)[0]
448+ // Order 2 3 1 4; the view scrolls by 400 to show column 1.
449+ for id, want := range map[ports.WindowID]int{2: 0, 1: 400, 3: 800} {
450+ if got := rectNow(t, s, id).X; got != want {
451+ t.Fatalf("window %d at %d right after the key, want the old %d", id, got, want)
452+ }
453+ }
454+ prev := 800
455+ for i := range 40 {
456+ s = r.frame(t, 16*time.Millisecond)
457+ if i == 39 {
458+ t.Fatal("still moving after 40 frames")
459+ }
460+ if got := rectNow(t, s, 1).X; got < 399 || got > 401 {
461+ t.Fatalf("frame %d: window 1 at %d, want it to keep 400 (the scroll is not a layout move)", i, got)
462+ }
463+ got := rectNow(t, s, 3).X
464+ if got > prev {
465+ t.Fatalf("frame %d: window 3 at %d after %d", i, got, prev)
466+ }
467+ if i == 0 && (got <= 0 || got >= 800) {
468+ t.Fatalf("window 3 at %d on the first frame, want between 0 and 800", got)
469+ }
470+ prev = got
471+ if got == 0 {
472+ break
473+ }
474+ }
475+ for id, want := range map[ports.WindowID]int{2: -400, 3: 0, 1: 400} {
476+ if got := rectNow(t, s, id).X; got != want {
477+ t.Fatalf("window %d at %d settled, want %d", id, got, want)
478+ }
479+ }
480+}
481+
482+// A width change animates the frame while the client gets one configure to
483+// the final size.
484+func TestSetColumnWidthRectTransition(t *testing.T) {
485+ r := startSwipe(t, func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" })
486+ twoColumns(t, r)
487+ r.key(t, "Left", ports.ModAlt) // focus window 1: both columns fit, no scroll
488+ for len(r.commands) > 0 {
489+ <-r.commands
490+ }
491+ mods := ports.ModAlt | ports.ModCtrl
492+ s := r.key(t, "Right", mods)[0]
493+ if got := rectNow(t, s, 1); got.W != 400 {
494+ t.Fatalf("window 1 is %d wide right after the key, want the old 400", got.W)
495+ }
496+ if got := rectNow(t, s, 2); got.X != 400 {
497+ t.Fatalf("window 2 at %d right after the key, want the old 400", got.X)
498+ }
499+ s = r.frame(t, 16*time.Millisecond)
500+ if got := rectNow(t, s, 1).W; got <= 400 || got >= 480 {
501+ t.Fatalf("window 1 is %d wide on the first frame, want between 400 and 480", got)
502+ }
503+ if got := rectNow(t, s, 2).X; got <= 400 || got >= 480 {
504+ t.Fatalf("window 2 at %d on the first frame, want between 400 and 480", got)
505+ }
506+ s = r.settle(t)
507+ if got := rectNow(t, s, 1).W; got != 480 {
508+ t.Fatalf("window 1 is %d wide settled, want 480", got)
509+ }
510+ if got := rectNow(t, s, 2).X; got != 480 {
511+ t.Fatalf("window 2 at %d settled, want 480", got)
512+ }
513+ var widths []int
514+ for len(r.commands) > 0 {
515+ if v, ok := (<-r.commands).(ports.ConfigureWindow); ok && v.ID == 1 {
516+ widths = append(widths, v.Width)
517+ }
518+ }
519+ if len(widths) != 1 || widths[0] != 480 {
520+ t.Fatalf("configures of window 1 carried widths %v, want exactly [480]", widths)
521+ }
522+}
523+
524+// Hit-testing follows what is drawn: a point only the shown rect covers
525+// reaches that window mid-transition.
526+func TestPointerHitsShownRectMidTransition(t *testing.T) {
527+ r := startSwipe(t, nil)
528+ twoColumns(t, r)
529+ // Window 2 (focused) moves to the left half, 1 to the right; drawn, 1
530+ // still holds the left half for now.
531+ s := r.key(t, "Left", ports.ModAlt|ports.ModShift)[0]
532+ if got := rectNow(t, s, 1).X; got != 0 {
533+ t.Fatalf("window 1 at %d right after the key, want the old 0", got)
534+ }
535+ r.input <- ports.PointerMotion{X: 100, Y: 300}
536+ r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
537+ s = scene(t, r.scenes)
538+ for _, w := range s.Windows {
539+ if w.Focused != (w.ID == 1) {
540+ t.Fatalf("window %d focused %t after a click on the shown window 1", w.ID, w.Focused)
541+ }
542+ }
543+ r.input <- ports.PointerButton{Button: 0x110}
544+}
545+
546+// toggle-fullscreen grows the window to the output over the frames.
547+func TestFullscreenRectTransition(t *testing.T) {
548+ r := startSwipe(t, nil)
549+ twoColumns(t, r)
550+ s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]
551+ if got := rectNow(t, s, 2); got.W != 400 {
552+ t.Fatalf("window 2 is %d wide right after the key, want the old 400", got.W)
553+ }
554+ got := rectNow(t, r.frame(t, 16*time.Millisecond), 2)
555+ if got.W <= 400 || got.W >= 800 {
556+ t.Fatalf("window 2 is %d wide on the first frame, want between 400 and 800", got.W)
557+ }
558+ if got := rectNow(t, r.settle(t), 2); got != (ports.Rect{W: 800, H: 600}) {
559+ t.Fatalf("window 2 at %+v settled, want the output", got)
560+ }
561+}
562+
563+// With animations off a layout change lands at once.
564+func TestAnimationsOffRectsLandAtOnce(t *testing.T) {
565+ r := startSwipe(t, animationsOff)
566+ twoColumns(t, r)
567+ s := r.key(t, "Left", ports.ModAlt|ports.ModShift)[0]
568+ if got1, got2 := rectNow(t, s, 1), rectNow(t, s, 2); got1.X != 400 || got2.X != 0 {
569+ t.Fatalf("1 at %+v and 2 at %+v right after the key, want the final 400 and 0", got1, got2)
570+ }
571+ r.noFrameScene(t)
572+}
573+
574+// A second action during a rect transition starts from what is drawn: the
575+// scene right after it shows the rects of the last frame.
576+func TestChainedMovesStartFromShownRects(t *testing.T) {
577+ r := startSwipe(t, nil)
578+ twoColumns(t, r)
579+ r.key(t, "Left", ports.ModAlt|ports.ModShift)
580+ mid := r.frame(t, 16*time.Millisecond)
581+ s := r.key(t, "Right", ports.ModAlt|ports.ModShift)[0]
582+ for _, id := range []ports.WindowID{1, 2} {
583+ if got, want := rectNow(t, s, id), rectNow(t, mid, id); got != want {
584+ t.Fatalf("window %d at %+v right after the second key, want the shown %+v", id, got, want)
585+ }
586+ }
587+ end := r.settle(t)
588+ if got1, got2 := rectNow(t, end, 1), rectNow(t, end, 2); got1.X != 0 || got2.X != 400 {
589+ t.Fatalf("settled 1 at %+v and 2 at %+v, want 0 and 400", got1, got2)
590+ }
591+}
592+
593+// A click on a column that is partly off screen focuses it: the scene right
594+// after the click still shows the old view, a frame an intermediate one and
595+// the settled scene the new one.
596+func TestClickFocusTransition(t *testing.T) {
597+ r := startSwipe(t, func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" })
598+ threeColumns(t, r)
599+ // Column 3 grows to 480: column 2 sticks out 80 px on the left.
600+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
601+ if got := xOf(t, r.settle(t), 2); got != -80 {
602+ t.Fatalf("column 2 at %d, want -80", got)
603+ }
604+ // The release of the modifiers: a plain click, not a window drag.
605+ r.input <- ports.KeyEvent{Keysym: "Alt_L"}
606+ r.input <- ports.PointerMotion{X: 100, Y: 300}
607+ r.drain()
608+ r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
609+ s := scene(t, r.scenes)
610+ if got := xOf(t, s, 2); got != -80 {
611+ t.Fatalf("column 2 at %d right after the click, want the old -80", got)
612+ }
613+ got := xOf(t, r.frame(t, 16*time.Millisecond), 2)
614+ if got <= -80 || got >= 0 {
615+ t.Fatalf("column 2 at %d on the first frame, want between -80 and 0", got)
616+ }
617+ if got := xOf(t, r.settle(t), 2); got != 0 {
618+ t.Fatalf("column 2 at %d settled, want 0", got)
619+ }
620+}
621+
622+// A discrete swipe step (fixed overflow: nothing follows the fingers) moves
623+// through a rect motion: the scene right after the lift shows the old rects.
624+func TestDiscreteSwipeTransition(t *testing.T) {
625+ r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" })
626+ r.mapWindow(t, 1)
627+ r.mapWindow(t, 2)
628+ r.key(t, "f", ports.ModAlt) // maximize window 2
629+ if got := rectNow(t, r.settle(t), 2); got.W != 800 {
630+ t.Fatalf("window 2 is %d wide, want maximized 800", got.W)
631+ }
632+ r.begin()
633+ for range 10 {
634+ r.at += 8 * time.Millisecond
635+ r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
636+ }
637+ s := r.end(t, false)
638+ if got := rectNow(t, s, 2); got.W != 800 {
639+ t.Fatalf("window 2 is %d wide right after the lift, want the old 800", got.W)
640+ }
641+ got := rectNow(t, r.frame(t, 16*time.Millisecond), 2)
642+ if got.W <= 400 || got.W >= 800 {
643+ t.Fatalf("window 2 is %d wide on the first frame, want between 400 and 800", got.W)
644+ }
645+ s = r.settle(t)
646+ if got1, got2 := rectNow(t, s, 1), rectNow(t, s, 2); got1.W != 400 || got2.X != 400 || got2.W != 400 {
647+ t.Fatalf("settled 1 at %+v and 2 at %+v, want 1 beside 2 on halves", got1, got2)
648+ }
649+}
650+
651+// A window unmapped while its rect motion runs stops moving: with
652+// animations on it stays in the scene only as a leaving entry (drawn where
653+// it was, fading out and shrinking, hidden to input), the others keep their
654+// motions, and once everything settles it is gone and no frame is wanted:
655+// a motion kept for a gone window would ask for frames with nothing to
656+// move. With animations off it leaves the scene at once.
657+func TestUnmapMidRectMotion(t *testing.T) {
658+ for _, tt := range []struct {
659+ name string
660+ edit func(*ports.Config)
661+ }{{"on", nil}, {"off", animationsOff}} {
662+ t.Run(tt.name, func(t *testing.T) {
663+ r := startSwipe(t, tt.edit)
664+ threeColumns(t, r)
665+ // 3, focused, swaps with 2: both move.
666+ mid := r.key(t, "Left", ports.ModAlt|ports.ModShift)[0]
667+ if tt.edit == nil {
668+ // Off: the swap is instant, a flip draws no new scene.
669+ mid = r.frame(t, 16*time.Millisecond)
670+ if x := rectNow(t, mid, 3).X; x <= 0 || x >= 400 {
671+ t.Fatalf("window 3 at %d on the first frame, want it moving", x)
672+ }
673+ }
674+ moving := rectNow(t, mid, 2).X
675+ drawn3 := rectNow(t, mid, 3)
676+ r.client <- ports.WindowUnmapped{ID: 3}
677+ s := scene(t, r.scenes)
678+ w3, shown3 := listedIn(s, 3)
679+ switch {
680+ case tt.edit != nil && shown3:
681+ t.Fatalf("the unmapped window is still shown with animations off: %+v", w3)
682+ case tt.edit == nil && (!shown3 || w3.Fade != 0 || w3.Rect != drawn3):
683+ // It leaves from where it was drawn, not from the settled
684+ // slot it was moving to.
685+ t.Fatalf("the unmapped window right after the unmap: %+v (shown %t), want it leaving from %+v", w3, shown3, drawn3)
686+ }
687+ // The neighbour's own motion carries on from where it was drawn.
688+ if got := rectNow(t, s, 2).X; got != moving {
689+ t.Fatalf("window 2 jumped from %d to %d on the unmap", moving, got)
690+ }
691+ if tt.edit == nil {
692+ next := r.frame(t, 16*time.Millisecond)
693+ if got := rectNow(t, next, 2).X; got == moving {
694+ t.Fatalf("window 2 stopped at %d: its motion died with the unmapped window", got)
695+ }
696+ if w, ok := listedIn(next, 3); !ok || !(w.Fade > 0 && w.Fade < 1) || w.Rect.W >= 400 {
697+ t.Fatalf("the leaving window on the next frame: %+v (shown %t), want it fading and shrinking", w, ok)
698+ }
699+ } else if moving != 400 {
700+ t.Fatalf("window 2 at %d with animations off, want 400", moving)
701+ }
702+ if tt.edit == nil {
703+ s = r.settle(t)
704+ }
705+ if _, ok := listedIn(s, 3); ok {
706+ t.Fatal("the unmapped window came back")
707+ }
708+ if got := rectNow(t, s, 2).X; got != 400 {
709+ t.Fatalf("window 2 settled at %d, want 400", got)
710+ }
711+ r.noFrameScene(t)
712+ })
713+ }
714+}
715+
716+// listedIn is the scene window id, shown or not.
717+func listedIn(s ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) {
718+ for _, w := range s.Windows {
719+ if w.ID == id {
720+ return w, true
721+ }
722+ }
723+ return ports.SceneWindow{}, false
724+}
A internal/core/transition_unmap_internal_test.go +461 -0
@@ -0,0 +1,461 @@
1+package core
2+
3+import (
4+ "context"
5+ "math"
6+ "slices"
7+ "testing"
8+ "time"
9+
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+// twoSettled maps two tiles with animations on and settles them.
15+func twoSettled(t *testing.T) (*Core, *indicatorClock, chan ports.ClientCommand, *screen) {
16+ t.Helper()
17+ c, ic, cmds := fadeCore(t)
18+ c.cfg.Animations.On = true
19+ sc := c.cur()
20+ c.mapWindow(ports.WindowMapped{ID: 1})
21+ c.mapWindow(ports.WindowMapped{ID: 2})
22+ indicatorScene(t, c)
23+ ic.now = ic.now.Add(5 * time.Second)
24+ c.animate(ic.now, nil)
25+ indicatorScene(t, c)
26+ if len(sc.rects) != 0 {
27+ t.Fatalf("motions left after the settle: %d", len(sc.rects))
28+ }
29+ drainConfigures(cmds)
30+ return c, ic, cmds, sc
31+}
32+
33+// A tiled unmap: the closed window stays drawn as a leaving entry from its
34+// settled rect, fading out and shrinking, hidden to input and configures;
35+// its neighbour slides from where it was to the whole frame; the client is
36+// configured once; everything settles exactly and no motion is left.
37+func TestUnmapTiledLeavesAndReflows(t *testing.T) {
38+ c, ic, cmds, sc := twoSettled(t)
39+ old1 := sceneWindow(t, indicatorScene(t, c), 1).Rect
40+ left := settledOf(t, sc, 2)
41+
42+ c.unmapWindow(ports.WindowUnmapped{ID: 2})
43+ s := indicatorScene(t, c)
44+ w2 := sceneWindow(t, s, 2)
45+ if w2.Hidden || w2.Fade != 0 || w2.Rect != left.Rect || w2.Zoom != 0 {
46+ t.Fatalf("closed window right after the unmap: %+v, want drawn opaque at %+v", w2, left.Rect)
47+ }
48+ if got := sceneWindow(t, s, 1).Rect; got != old1 {
49+ t.Fatalf("neighbour right after the unmap at %+v, want the old %+v", got, old1)
50+ }
51+ settled1 := settledOf(t, sc, 1)
52+ if settled1.Rect == old1 {
53+ t.Fatal("the closed column did not move the neighbour: the case checks nothing")
54+ }
55+ if ids := drainConfigures(cmds); !slices.Equal(ids, []WindowID{1}) {
56+ t.Fatalf("configures %v, want one for 1 only", ids)
57+ }
58+ if _, w := c.screenOf(2); w != nil {
59+ t.Fatal("the unmapped window is still in a workspace")
60+ }
61+ cx, cy := float64(left.Rect.X+left.Rect.W/2), float64(left.Rect.Y+left.Rect.H/2)
62+ if id, _, _ := c.hit(cx, cy); id == 2 {
63+ t.Fatal("the leaving window is hit")
64+ }
65+
66+ ic.now = ic.now.Add(40 * time.Millisecond)
67+ c.animate(ic.now, nil)
68+ s = indicatorScene(t, c)
69+ w2 = sceneWindow(t, s, 2)
70+ if !(w2.Fade > 0 && w2.Fade < 1) || w2.Rect.W >= left.Rect.W || !(w2.Zoom > appearScale && w2.Zoom < 1) {
71+ t.Fatalf("first frame of the leaving window: %+v", w2)
72+ }
73+ if r := sceneWindow(t, s, 1).Rect; !(r.W > old1.W && r.W < settled1.Rect.W) {
74+ t.Fatalf("neighbour on the first frame %+v, want between %+v and %+v", r, old1, settled1.Rect)
75+ }
76+ if ids := drainConfigures(cmds); len(ids) != 0 {
77+ t.Fatalf("frames reconfigured %v", ids)
78+ }
79+
80+ ic.now = ic.now.Add(5 * time.Second)
81+ c.animate(ic.now, nil)
82+ s = indicatorScene(t, c)
83+ if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
84+ t.Fatalf("the closed window is still in the scene: %+v", s.Windows)
85+ }
86+ if w := sceneWindow(t, s, 1); w.Rect != settled1.Rect {
87+ t.Fatalf("neighbour settled at %+v, want %+v", w.Rect, settled1.Rect)
88+ }
89+ if len(sc.rects) != 0 || c.animating() {
90+ t.Fatal("still animating after the settle")
91+ }
92+ // The destroyed window got no configure at any point, and its last one
93+ // is forgotten once it left the scene.
94+ if ids := drainConfigures(cmds); slices.Contains(ids, 2) {
95+ t.Fatalf("the destroyed window was configured: %v", ids)
96+ }
97+ if _, ok := c.configures.sent[2]; ok {
98+ t.Fatal("configures still remember the destroyed window")
99+ }
100+}
101+
102+// A float leaves too, from its settled rect.
103+func TestUnmapFloatingLeaves(t *testing.T) {
104+ c, ic, _ := fadeCore(t)
105+ c.cfg.Animations.On = true
106+ sc := c.cur()
107+ c.mapWindow(ports.WindowMapped{ID: 1})
108+ c.mapWindow(ports.WindowMapped{ID: 2, Floating: true, Width: 80, Height: 60})
109+ indicatorScene(t, c)
110+ ic.now = ic.now.Add(5 * time.Second)
111+ c.animate(ic.now, nil)
112+ indicatorScene(t, c)
113+ left := settledOf(t, sc, 2)
114+ c.unmapWindow(ports.WindowUnmapped{ID: 2})
115+ s := indicatorScene(t, c)
116+ if w := sceneWindow(t, s, 2); !w.Floating || w.Fade != 0 || w.Rect != left.Rect {
117+ t.Fatalf("float right after the unmap: %+v, settled %+v", w, left.Rect)
118+ }
119+ ic.now = ic.now.Add(5 * time.Second)
120+ c.animate(ic.now, nil)
121+ if s := indicatorScene(t, c); slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
122+ t.Fatalf("the closed float is still in the scene: %+v", s.Windows)
123+ }
124+}
125+
126+// A window closed while its own rect motion runs (its entrance here)
127+// leaves from what was drawn: the first leaving frame shows the rect and
128+// fade it had, not its settled rect, and the leaving motion replaces the
129+// running one.
130+func TestUnmapMidMotionLeavesFromDrawn(t *testing.T) {
131+ c, ic, _, sc := twoSettled(t)
132+ c.mapWindow(ports.WindowMapped{ID: 3})
133+ indicatorScene(t, c)
134+ ic.now = ic.now.Add(30 * time.Millisecond)
135+ c.animate(ic.now, nil)
136+ before := sceneWindow(t, indicatorScene(t, c), 3)
137+ settled3 := settledOf(t, sc, 3)
138+ if before.Rect == settled3.Rect || !(before.Fade > 0 && before.Fade < 1) {
139+ t.Fatalf("window 3 is not in flight (%+v): the case checks nothing", before)
140+ }
141+ c.unmapWindow(ports.WindowUnmapped{ID: 3})
142+ s := indicatorScene(t, c)
143+ w := sceneWindow(t, s, 3)
144+ if w.Rect != before.Rect || math.Abs(w.Fade-before.Fade) > 1e-9 {
145+ t.Fatalf("leaving window right after the unmap: %+v, want drawn as before %+v", w, before)
146+ }
147+ if rm, ok := sc.rects[3]; !ok || !rm.leaving {
148+ t.Fatalf("no leaving motion for window 3: %+v", rm)
149+ }
150+ ic.now = ic.now.Add(5 * time.Second)
151+ c.animate(ic.now, nil)
152+ if s := indicatorScene(t, c); slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 3 }) || len(sc.rects) != 0 {
153+ t.Fatalf("the closed window is still there: %+v", s.Windows)
154+ }
155+}
156+
157+// A tile closed while the stash hides: the absent leaving tile goes by
158+// withLeaving's two-pass rule (absent tiles by ID before the floats, the
159+// hidden stash windows replaced in place), the same on every refresh.
160+func TestUnmapWhileStashHidesOrdersDeterministically(t *testing.T) {
161+ c, ic, sc, cmds := stashRig(t, true)
162+ sc.mon.AddWindow(5)
163+ sc.mon.AddWindow(6)
164+ settleShown(t, c)
165+ drainConfigures(cmds)
166+ act(c, ic, ActionToggleStashVisible) // 2 and 3 hide, leaving in place
167+ c.unmapWindow(ports.WindowUnmapped{ID: 5})
168+ var want []WindowID
169+ for i := range 20 {
170+ s := indicatorScene(t, c)
171+ var ids []WindowID
172+ for _, w := range s.Windows {
173+ ids = append(ids, w.ID)
174+ }
175+ if i == 0 {
176+ want = ids
177+ k := slices.Index(sc.settledLayout, settledOf(t, sc, 5))
178+ if k < 0 || !sc.settledLayout[k].Leaving || !sc.shown[k].Leaving {
179+ t.Fatalf("the closed tile is not a leaving entry: %+v", sc.settledLayout)
180+ }
181+ for _, id := range []WindowID{2, 3} {
182+ if j := slices.Index(ids, id); j < 0 || !sc.settledLayout[j].Leaving {
183+ t.Fatalf("stash window %d not replaced in place: %v", id, ids)
184+ }
185+ }
186+ // The absent tile sits before any drawn float (the leaving
187+ // stash windows); a hidden, undrawn one may precede it.
188+ i5 := slices.Index(ids, 5)
189+ for j, w := range s.Windows {
190+ if w.Floating && !w.Hidden && j < i5 {
191+ t.Fatalf("order %v: float %d before the leaving tile 5", ids, w.ID)
192+ }
193+ }
194+ continue
195+ }
196+ if !slices.Equal(ids, want) {
197+ t.Fatalf("publish %d: order %v, first %v", i, ids, want)
198+ }
199+ }
200+ if ids := drainConfigures(cmds); slices.Contains(ids, 5) {
201+ t.Fatalf("the closed window was configured: %v", ids)
202+ }
203+ ic.now = ic.now.Add(5 * time.Second)
204+ c.animate(ic.now, nil)
205+ s := indicatorScene(t, c)
206+ if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 5 }) || len(sc.rects) != 0 {
207+ t.Fatalf("after the settle: %+v", s.Windows)
208+ }
209+ if ids := drainConfigures(cmds); slices.Contains(ids, 5) {
210+ t.Fatalf("the destroyed window was configured after the fade: %v", ids)
211+ }
212+}
213+
214+// With animations off an unmap is instant: the window is gone, no motion.
215+func TestUnmapOffIsInstant(t *testing.T) {
216+ c, _, _ := fadeCore(t)
217+ sc := c.cur()
218+ c.mapWindow(ports.WindowMapped{ID: 1})
219+ c.mapWindow(ports.WindowMapped{ID: 2})
220+ indicatorScene(t, c)
221+ c.unmapWindow(ports.WindowUnmapped{ID: 2})
222+ s := indicatorScene(t, c)
223+ if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) || len(sc.rects) != 0 || c.animating() {
224+ t.Fatalf("an unmap animated with animations off: %+v", s.Windows)
225+ }
226+ if w := sceneWindow(t, s, 1); w.Rect != settledOf(t, sc, 1).Rect {
227+ t.Fatalf("neighbour not at its settled rect: %+v", w)
228+ }
229+}
230+
231+// Windows that are not drawn as a tile or an ordinary float do not leave:
232+// fullscreen, on another workspace, under a session lock, with the overview
233+// open. Nothing is left of them.
234+func TestUnmapExceptions(t *testing.T) {
235+ gone := func(t *testing.T, c *Core, sc *screen, id WindowID) {
236+ t.Helper()
237+ s := indicatorScene(t, c)
238+ if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == id }) || len(sc.rects) != 0 {
239+ t.Fatalf("window %d left something behind: %+v rects %+v", id, s.Windows, sc.rects)
240+ }
241+ if _, w := c.screenOf(id); w != nil {
242+ t.Fatalf("window %d still placed", id)
243+ }
244+ }
245+ t.Run("fullscreen", func(t *testing.T) {
246+ c, _, _, sc := twoSettled(t)
247+ sc.mon.SetFullscreen(2, true)
248+ indicatorScene(t, c)
249+ c.unmapWindow(ports.WindowUnmapped{ID: 2})
250+ gone(t, c, sc, 2)
251+ })
252+ t.Run("other workspace", func(t *testing.T) {
253+ c, _, _, sc := twoSettled(t)
254+ sc.mon.Focus(1)
255+ indicatorScene(t, c)
256+ c.stopAnimations()
257+ c.unmapWindow(ports.WindowUnmapped{ID: 2})
258+ gone(t, c, sc, 2)
259+ })
260+ t.Run("session lock", func(t *testing.T) {
261+ c, _, _, sc := twoSettled(t)
262+ c.security = ports.SecurityState{Generation: 1, Protected: true}
263+ c.unmapWindow(ports.WindowUnmapped{ID: 2})
264+ if len(sc.rects) != 0 || c.animating() {
265+ t.Fatalf("an unmap under a session lock animated: %+v", sc.rects)
266+ }
267+ if _, w := c.screenOf(2); w != nil {
268+ t.Fatal("window 2 still placed")
269+ }
270+ })
271+ t.Run("overview open", func(t *testing.T) {
272+ c, _, _, sc := twoSettled(t)
273+ sc.mon.ToggleOverview()
274+ indicatorScene(t, c)
275+ c.unmapWindow(ports.WindowUnmapped{ID: 2})
276+ gone(t, c, sc, 2)
277+ })
278+ t.Run("never placed", func(t *testing.T) {
279+ c, _, _, sc := twoSettled(t)
280+ c.unmapWindow(ports.WindowUnmapped{ID: 9})
281+ if len(sc.rects) != 0 {
282+ t.Fatalf("an unknown window animated: %+v", sc.rects)
283+ }
284+ })
285+}
286+
287+// An interruption during the fade drops the leaving entry with the other
288+// motions (stopRects): a real session lock, an output unplug, an overview
289+// toggle and a workspace switch. Nothing of the closed window is left.
290+func TestUnmapLeavingDroppedByInterruptions(t *testing.T) {
291+ leaving := func(t *testing.T) (*Core, *indicatorClock, *screen) {
292+ t.Helper()
293+ c, ic, _, sc := twoSettled(t)
294+ c.unmapWindow(ports.WindowUnmapped{ID: 2})
295+ if s := indicatorScene(t, c); !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
296+ t.Fatal("no leaving entry")
297+ }
298+ return c, ic, sc
299+ }
300+ gone := func(t *testing.T, c *Core, sc *screen, s ports.Scene) {
301+ t.Helper()
302+ if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
303+ t.Fatalf("the leaving entry survived: %+v", s.Windows)
304+ }
305+ // Other motions may run (the overview's cards): none for 2.
306+ if _, ok := sc.rects[2]; ok || slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) {
307+ t.Fatalf("rects %+v shown %+v", sc.rects, sc.shown)
308+ }
309+ }
310+ t.Run("session lock", func(t *testing.T) {
311+ c, _, sc := leaving(t)
312+ state := ports.SecurityState{Generation: 1, Protected: true}
313+ gate := portsmocks.NewMockSessionSecurity(t)
314+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
315+ c.ch.Security = gate
316+ if err := c.publish(context.Background()); err != nil {
317+ t.Fatal(err)
318+ }
319+ locked := (<-c.ch.Scenes)[0]
320+ if len(locked.Windows) != 0 || len(sc.rects) != 0 || c.animating() {
321+ t.Fatalf("locked scene %+v rects %d animating %t", locked.Windows, len(sc.rects), c.animating())
322+ }
323+ state = ports.SecurityState{Generation: 2}
324+ gone(t, c, sc, indicatorScene(t, c))
325+ })
326+ t.Run("unplug", func(t *testing.T) {
327+ c, _, sc := leaving(t)
328+ c.removeScreen("A")
329+ if c.animating() || len(sc.rects) != 0 {
330+ t.Fatalf("motions survived the unplug: %d", len(sc.rects))
331+ }
332+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
333+ gone(t, c, c.cur(), indicatorScene(t, c))
334+ })
335+ t.Run("overview toggle", func(t *testing.T) {
336+ c, ic, sc := leaving(t)
337+ act(c, ic, ActionToggleOverview)
338+ gone(t, c, sc, indicatorScene(t, c))
339+ })
340+ t.Run("workspace switch", func(t *testing.T) {
341+ c, ic, sc := leaving(t)
342+ act(c, ic, ActionFocusWorkspaceDown)
343+ gone(t, c, sc, indicatorScene(t, c))
344+ })
345+}
346+
347+// A window ID reused by a new toplevel while the old one fades out (xdg
348+// reuses IDs): the leaving entry goes, the new window appears from where the
349+// old one was drawn (appearFrom), is listed once, and gets its tile
350+// configure (the destroyed window's last configure is forgotten).
351+func TestUnmapThenRemapDuringFade(t *testing.T) {
352+ c, ic, cmds, sc := twoSettled(t)
353+ c.unmapWindow(ports.WindowUnmapped{ID: 2})
354+ indicatorScene(t, c)
355+ ic.now = ic.now.Add(40 * time.Millisecond)
356+ c.animate(ic.now, nil)
357+ mid := sceneWindow(t, indicatorScene(t, c), 2)
358+ if !(mid.Fade > 0 && mid.Fade < 1) {
359+ t.Fatalf("not fading: %+v", mid)
360+ }
361+ drainConfigures(cmds)
362+ if _, ok := c.configures.sent[2]; ok {
363+ t.Fatal("the destroyed window's configure is remembered")
364+ }
365+
366+ c.mapWindow(ports.WindowMapped{ID: 2})
367+ s := indicatorScene(t, c)
368+ n := 0
369+ for _, w := range s.Windows {
370+ if w.ID == 2 {
371+ n++
372+ }
373+ }
374+ if n != 1 {
375+ t.Fatalf("window 2 listed %d times: %+v", n, s.Windows)
376+ }
377+ if slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) || slices.ContainsFunc(sc.settledLayout, func(p Placement) bool { return p.Leaving }) {
378+ t.Fatalf("leaving entry left: %+v", sc.shown)
379+ }
380+ rm, ok := sc.rects[2]
381+ if !ok || rm.leaving {
382+ t.Fatalf("no entrance for the remapped window: %+v", rm)
383+ }
384+ w := sceneWindow(t, s, 2)
385+ // The entrance chains over the exit: drawn as the fade left it, not
386+ // invisible at 90 %.
387+ if w.Hidden || math.Abs(w.Fade-mid.Fade) > 1e-9 || w.Rect != mid.Rect {
388+ t.Fatalf("remapped window right after the map: %+v, want as drawn before %+v", w, mid)
389+ }
390+ settled := settledOf(t, sc, 2)
391+ var got []ports.ConfigureWindow
392+ for len(cmds) > 0 {
393+ if v, ok := (<-cmds).(ports.ConfigureWindow); ok && v.ID == 2 {
394+ got = append(got, v)
395+ }
396+ }
397+ if len(got) != 1 || got[0].Width != settled.Rect.W || !got[0].Visible {
398+ t.Fatalf("configures of the remapped window: %+v, want one to %+v", got, settled.Rect)
399+ }
400+ ic.now = ic.now.Add(5 * time.Second)
401+ c.animate(ic.now, nil)
402+ if w := sceneWindow(t, indicatorScene(t, c), 2); w.Fade != 0 || w.Rect != settled.Rect || len(sc.rects) != 0 {
403+ t.Fatalf("remapped window settled: %+v", w)
404+ }
405+}
406+
407+// A stash window that closes while its hide still fades keeps that fade:
408+// the motion runs on from where it was (the renderer draws it from the
409+// content it kept), and it is not restarted as a fresh exit. Its configure
410+// record is forgotten at the unmap and not kept while it fades, so a
411+// remapped window with the same ID starts from nothing.
412+func TestUnmapDuringStashHideKeepsTheFade(t *testing.T) {
413+ c, ic, sc, _ := stashRig(t, true)
414+ act(c, ic, ActionToggleStashVisible) // 2 and 3 hide, leaving in place
415+ indicatorScene(t, c)
416+ ic.now = ic.now.Add(40 * time.Millisecond)
417+ c.animate(ic.now, nil)
418+ s := indicatorScene(t, c)
419+ before := sceneWindow(t, s, 2)
420+ rm, ok := sc.rects[2]
421+ if !ok || !rm.leaving || !(before.Fade > 0 && before.Fade < 1) {
422+ t.Fatalf("setup: leaving %v fade %v", ok && rm.leaving, before.Fade)
423+ }
424+ c.configures.sent[2] = ports.ConfigureWindow{ID: 2, Width: 10}
425+ c.unmapWindow(ports.WindowUnmapped{ID: 2})
426+ after, ok := sc.rects[2]
427+ if !ok || !after.leaving || after.fade != rm.fade || after.x != rm.x {
428+ t.Fatalf("the hide's motion was replaced: before %+v after %+v", rm, after)
429+ }
430+ s = indicatorScene(t, c)
431+ if w := sceneWindow(t, s, 2); w.Fade < before.Fade || w.Hidden {
432+ t.Fatalf("after the unmap: %+v, want the fade to go on from %v", w, before.Fade)
433+ }
434+ if _, kept := c.configures.sent[2]; kept {
435+ t.Fatal("a closed window's configure survived the publish")
436+ }
437+ ic.now = ic.now.Add(5 * time.Second)
438+ c.animate(ic.now, nil)
439+ s = indicatorScene(t, c)
440+ if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) || len(sc.rects) != 0 {
441+ t.Fatalf("after the settle: %+v", s.Windows)
442+ }
443+}
444+
445+// A stash window that hides keeps its configure while it fades: the
446+// workspace still holds it, and its next show must not start from nothing.
447+func TestStashHideKeepsConfigureWhileFading(t *testing.T) {
448+ c, ic, sc, _ := stashRig(t, true)
449+ act(c, ic, ActionToggleStashVisible)
450+ for range 3 {
451+ ic.now = ic.now.Add(20 * time.Millisecond)
452+ c.animate(ic.now, nil)
453+ indicatorScene(t, c)
454+ }
455+ if _, ok := sc.rects[2]; !ok {
456+ t.Fatal("setup: window 2 is not fading")
457+ }
458+ if _, ok := c.configures.sent[2]; !ok {
459+ t.Fatal("the hidden stash window's configure was pruned while it fades")
460+ }
461+}
M internal/core/visibility_test.go +1 -0
@@ -91,6 +91,7 @@ func TestConfigureVisible(t *testing.T) {
9191 t.Fatal("popup of a visible window not on screen")
9292 }
9393 m.AddWindow(4)
94+ publish() // visibility reads the layout of the last publish
9495 if c.visible(1) || c.visible(10) {
9596 t.Fatal("popup of a scrolled-off window still on screen")
9697 }
M internal/core/workspace_size_test.go +113 -62
@@ -8,11 +8,10 @@ import (
88
99 var ultrawide = ports.OutputInfo{Name: "DP-3", Make: "Acme", Model: "W", Serial: "3", Width: 3440, Height: 1440}
1010
11-// sizedRig runs an ultrawide with the named workspace "focus" (1920x1080)
12-// bound to Alt+w, and shows it.
13-func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRig, ports.Scene) {
14- t.Helper()
15- r := startMulti(t, func(c *ports.Config) {
11+// sizedConfig is the config of the sized-workspace rigs: gaps off, two
12+// columns, the named workspace "focus" (1920x1080) bound to Alt+w.
13+func sizedConfig(overflow string, edit func(*ports.Config)) func(*ports.Config) {
14+ return func(c *ports.Config) {
1615 c.Layout.Gaps = 0
1716 c.Layout.MaxColumns = 2
1817 c.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1920, 1080}, LayoutRules: ports.LayoutRules{Overflow: overflow}}}
@@ -20,10 +19,26 @@ func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRi
2019 if edit != nil {
2120 edit(c)
2221 }
23- }, ultrawide)
22+ }
23+}
24+
25+// sizedRig runs an ultrawide with the named workspace "focus" (1920x1080)
26+// bound to Alt+w, and shows it. The animations setting is the default's
27+// unless edit changes it.
28+func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRig, ports.Scene) {
29+ t.Helper()
30+ r := startMulti(t, sizedConfig(overflow, edit), ultrawide)
2431 return r, r.key(t, "w", ports.ModAlt)[0]
2532 }
2633
34+// sizedLanding is sizedRig with animations forced on or off; the scene it
35+// returns and every keyLanded and mapLanded of the rig are settled.
36+func sizedLanding(t *testing.T, animated bool, overflow string, edit func(*ports.Config)) (*landRig, ports.Scene) {
37+ t.Helper()
38+ r := startLanding(t, animated, sizedConfig(overflow, edit), ultrawide)
39+ return r, r.keyLanded(t, "w", ports.ModAlt)[0]
40+}
41+
2742 var frame = ports.Rect{X: 760, Y: 180, W: 1920, H: 1080}
2843
2944 func within(r, o ports.Rect) bool {
@@ -31,18 +46,26 @@ func within(r, o ports.Rect) bool {
3146 }
3247
3348 func TestWorkspaceSizeInheritsByDefault(t *testing.T) {
34- r := startMulti(t, func(c *ports.Config) { c.Layout.Gaps = 0 }, ultrawide)
35- set := r.mapWindow(t, 1)
36- if set[0].WorkspaceClip != (ports.Rect{}) || set[0].Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) {
37- t.Fatalf("%+v", set[0])
38- }
49+ both(t, func(t *testing.T, animated bool) {
50+ r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Gaps = 0 }, ultrawide)
51+ set := r.mapLanded(t, 1)
52+ if set[0].WorkspaceClip != (ports.Rect{}) || set[0].Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) {
53+ t.Fatalf("%+v", set[0])
54+ }
55+ })
3956 }
4057
4158 func TestWorkspaceSizeScrollColumnsStayInFrame(t *testing.T) {
42- r, _ := sizedRig(t, "scroll", nil)
43- r.mapWindow(t, 1)
44- r.mapWindow(t, 2)
45- s := r.mapWindow(t, 3)[0]
59+ both(t, func(t *testing.T, animated bool) {
60+ r, _ := sizedLanding(t, animated, "scroll", nil)
61+ workspaceSizeScrollColumnsStayInFrame(t, r)
62+ })
63+}
64+
65+func workspaceSizeScrollColumnsStayInFrame(t *testing.T, r *landRig) {
66+ r.mapLanded(t, 1)
67+ r.mapLanded(t, 2)
68+ s := r.mapLanded(t, 3)[0]
4669 if s.WorkspaceClip != frame || s.OutputWidth != 3440 || s.OutputHeight != 1440 {
4770 t.Fatalf("clip %+v output %dx%d", s.WorkspaceClip, s.OutputWidth, s.OutputHeight)
4871 }
@@ -62,9 +85,15 @@ func TestWorkspaceSizeScrollColumnsStayInFrame(t *testing.T) {
6285 }
6386
6487 func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) {
65- r, _ := sizedRig(t, "fixed", nil)
66- r.mapWindow(t, 1)
67- s := r.mapWindow(t, 2)[0]
88+ both(t, func(t *testing.T, animated bool) {
89+ r, _ := sizedLanding(t, animated, "fixed", nil)
90+ workspaceSizeFixedColumnsFillFrame(t, r)
91+ })
92+}
93+
94+func workspaceSizeFixedColumnsFillFrame(t *testing.T, r *landRig) {
95+ r.mapLanded(t, 1)
96+ s := r.mapLanded(t, 2)[0]
6897 for i, want := range []ports.Rect{{X: 760, Y: 180, W: 960, H: 1080}, {X: 1720, Y: 180, W: 960, H: 1080}} {
6998 if s.Windows[i].Rect != want {
7099 t.Fatalf("%d: %+v", i, s.Windows[i])
@@ -73,48 +102,52 @@ func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) {
73102 }
74103
75104 func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) {
76- r, _ := sizedRig(t, "scroll", nil)
77- r.mapWindow(t, 1)
78- r.mapWindow(t, 2)
79- s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]
80- full := 0
81- for _, w := range s.Windows {
82- if w.Fullscreen {
83- full++
84- if w.Rect != frame {
85- t.Fatalf("%+v", w)
105+ both(t, func(t *testing.T, animated bool) {
106+ r, _ := sizedLanding(t, animated, "scroll", nil)
107+ r.mapLanded(t, 1)
108+ r.mapLanded(t, 2)
109+ s := r.keyLanded(t, "f", ports.ModAlt|ports.ModShift)[0]
110+ full := 0
111+ for _, w := range s.Windows {
112+ if w.Fullscreen {
113+ full++
114+ if w.Rect != frame {
115+ t.Fatalf("%+v", w)
116+ }
86117 }
87118 }
88- }
89- if full != 1 {
90- t.Fatal(s.Windows)
91- }
119+ if full != 1 {
120+ t.Fatal(s.Windows)
121+ }
122+ })
92123 }
93124
94125 func TestWorkspaceSizeFixedFullscreenKeepsFrame(t *testing.T) {
95- r, _ := sizedRig(t, "fixed", nil)
96- r.mapWindow(t, 1)
97- r.mapWindow(t, 2)
98- s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]
99- if s.WorkspaceClip != frame {
100- t.Fatalf("fullscreen clip %+v", s.WorkspaceClip)
101- }
102- for _, w := range s.Windows {
103- if w.Fullscreen && w.Rect != frame {
104- t.Fatalf("%+v", w)
126+ both(t, func(t *testing.T, animated bool) {
127+ r, _ := sizedLanding(t, animated, "fixed", nil)
128+ r.mapLanded(t, 1)
129+ r.mapLanded(t, 2)
130+ s := r.keyLanded(t, "f", ports.ModAlt|ports.ModShift)[0]
131+ if s.WorkspaceClip != frame {
132+ t.Fatalf("fullscreen clip %+v", s.WorkspaceClip)
105133 }
106- }
107- // Reload keeps the size; removing it frees the workspace.
108- cfg := r.cfg
109- r.reload <- ports.ConfigChanged{Config: cfg}
110- if s := receive(t, r.scenes)[0]; s.WorkspaceClip != frame {
111- t.Fatalf("after reload %+v", s.WorkspaceClip)
112- }
113- cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", LayoutRules: ports.LayoutRules{Overflow: "fixed"}}}
114- r.reload <- ports.ConfigChanged{Config: cfg}
115- if s := receive(t, r.scenes)[0]; s.WorkspaceClip != (ports.Rect{}) {
116- t.Fatalf("after size removal %+v", s.WorkspaceClip)
117- }
134+ for _, w := range s.Windows {
135+ if w.Fullscreen && w.Rect != frame {
136+ t.Fatalf("%+v", w)
137+ }
138+ }
139+ // Reload keeps the size; removing it frees the workspace.
140+ cfg := r.cfg
141+ r.reload <- ports.ConfigChanged{Config: cfg}
142+ if s := r.land(receive(t, r.scenes))[0]; s.WorkspaceClip != frame {
143+ t.Fatalf("after reload %+v", s.WorkspaceClip)
144+ }
145+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", LayoutRules: ports.LayoutRules{Overflow: "fixed"}}}
146+ r.reload <- ports.ConfigChanged{Config: cfg}
147+ if s := r.land(receive(t, r.scenes))[0]; s.WorkspaceClip != (ports.Rect{}) {
148+ t.Fatalf("after size removal %+v", s.WorkspaceClip)
149+ }
150+ })
118151 }
119152
120153 func TestWorkspaceSizeFloatsContained(t *testing.T) {
@@ -130,8 +163,14 @@ func TestWorkspaceSizeFloatsContained(t *testing.T) {
130163 }
131164
132165 func TestWorkspaceSizeReloadAndRemoval(t *testing.T) {
133- r, _ := sizedRig(t, "scroll", nil)
134- r.mapWindow(t, 1)
166+ both(t, func(t *testing.T, animated bool) {
167+ r, _ := sizedLanding(t, animated, "scroll", nil)
168+ workspaceSizeReloadAndRemoval(t, r)
169+ })
170+}
171+
172+func workspaceSizeReloadAndRemoval(t *testing.T, r *landRig) {
173+ r.mapLanded(t, 1)
135174 cfg := r.cfg
136175 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1000, 600}}}
137176 r.reload <- ports.ConfigChanged{Config: cfg}
@@ -159,28 +198,40 @@ func TestWorkspaceSizeReloadAndRemoval(t *testing.T) {
159198 }
160199
161200 func TestWorkspaceSizeFollowsOutputResize(t *testing.T) {
162- r, _ := sizedRig(t, "scroll", nil)
163- r.mapWindow(t, 1)
201+ both(t, func(t *testing.T, animated bool) {
202+ r, _ := sizedLanding(t, animated, "scroll", nil)
203+ workspaceSizeFollowsOutputResize(t, r)
204+ })
205+}
206+
207+func workspaceSizeFollowsOutputResize(t *testing.T, r *landRig) {
208+ r.mapLanded(t, 1)
164209 smaller := ultrawide
165210 smaller.Width, smaller.Height = 1600, 900
166- s := r.plug(t, smaller)[0]
211+ s := r.land(r.plug(t, smaller))[0]
167212 // 1920x1080 no longer fits: it clamps to the monitor.
168213 if s.WorkspaceClip != (ports.Rect{}) || s.Windows[0].Rect != (ports.Rect{W: 1600, H: 900}) {
169214 t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
170215 }
171- s = r.plug(t, ultrawide)[0]
216+ s = r.land(r.plug(t, ultrawide))[0]
172217 if s.WorkspaceClip != frame || s.Windows[0].Rect != frame {
173218 t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
174219 }
175220 }
176221
177222 func TestWorkspaceSizeReservedZoneIntersectsFrame(t *testing.T) {
178- r, _ := sizedRig(t, "scroll", nil)
223+ both(t, func(t *testing.T, animated bool) {
224+ r, _ := sizedLanding(t, animated, "scroll", nil)
225+ workspaceSizeReservedZoneIntersectsFrame(t, r)
226+ })
227+}
228+
229+func workspaceSizeReservedZoneIntersectsFrame(t *testing.T, r *landRig) {
179230 // A bar inside the top of the monitor but above the frame reserves nothing of it.
180231 bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 100, ExclusiveZone: 100}
181232 r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
182233 receive(t, r.scenes)
183- s := r.mapWindow(t, 1)[0]
234+ s := r.mapLanded(t, 1)[0]
184235 if s.Windows[0].Rect != frame {
185236 t.Fatalf("%+v", s.Windows[0])
186237 }
M internal/mocks/ports/devices_mock.go +18 -12
@@ -385,8 +385,8 @@ func (_c *MockRenderer_EndCapture_Call) RunAndReturn(run func(captureFrame ports
385385 }
386386
387387 // ExportTargets provides a mock function for the type MockRenderer
388-func (_mock *MockRenderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) {
389- ret := _mock.Called(n, modifiers)
388+func (_mock *MockRenderer) ExportTargets(n int, modifiers []uint64, singlePlane bool) ([]ports.DMABuf, error) {
389+ ret := _mock.Called(n, modifiers, singlePlane)
390390
391391 if len(ret) == 0 {
392392 panic("no return value specified for ExportTargets")
@@ -394,18 +394,18 @@ func (_mock *MockRenderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMA
394394
395395 var r0 []ports.DMABuf
396396 var r1 error
397- if returnFunc, ok := ret.Get(0).(func(int, []uint64) ([]ports.DMABuf, error)); ok {
398- return returnFunc(n, modifiers)
397+ if returnFunc, ok := ret.Get(0).(func(int, []uint64, bool) ([]ports.DMABuf, error)); ok {
398+ return returnFunc(n, modifiers, singlePlane)
399399 }
400- if returnFunc, ok := ret.Get(0).(func(int, []uint64) []ports.DMABuf); ok {
401- r0 = returnFunc(n, modifiers)
400+ if returnFunc, ok := ret.Get(0).(func(int, []uint64, bool) []ports.DMABuf); ok {
401+ r0 = returnFunc(n, modifiers, singlePlane)
402402 } else {
403403 if ret.Get(0) != nil {
404404 r0 = ret.Get(0).([]ports.DMABuf)
405405 }
406406 }
407- if returnFunc, ok := ret.Get(1).(func(int, []uint64) error); ok {
408- r1 = returnFunc(n, modifiers)
407+ if returnFunc, ok := ret.Get(1).(func(int, []uint64, bool) error); ok {
408+ r1 = returnFunc(n, modifiers, singlePlane)
409409 } else {
410410 r1 = ret.Error(1)
411411 }
@@ -420,11 +420,12 @@ type MockRenderer_ExportTargets_Call struct {
420420 // ExportTargets is a helper method to define mock.On call
421421 // - n int
422422 // - modifiers []uint64
423-func (_e *MockRenderer_Expecter) ExportTargets(n any, modifiers any) *MockRenderer_ExportTargets_Call {
424- return &MockRenderer_ExportTargets_Call{Call: _e.mock.On("ExportTargets", n, modifiers)}
423+// - singlePlane bool
424+func (_e *MockRenderer_Expecter) ExportTargets(n any, modifiers any, singlePlane any) *MockRenderer_ExportTargets_Call {
425+ return &MockRenderer_ExportTargets_Call{Call: _e.mock.On("ExportTargets", n, modifiers, singlePlane)}
425426 }
426427
427-func (_c *MockRenderer_ExportTargets_Call) Run(run func(n int, modifiers []uint64)) *MockRenderer_ExportTargets_Call {
428+func (_c *MockRenderer_ExportTargets_Call) Run(run func(n int, modifiers []uint64, singlePlane bool)) *MockRenderer_ExportTargets_Call {
428429 _c.Call.Run(func(args mock.Arguments) {
429430 var arg0 int
430431 if args[0] != nil {
@@ -434,9 +435,14 @@ func (_c *MockRenderer_ExportTargets_Call) Run(run func(n int, modifiers []uint6
434435 if args[1] != nil {
435436 arg1 = args[1].([]uint64)
436437 }
438+ var arg2 bool
439+ if args[2] != nil {
440+ arg2 = args[2].(bool)
441+ }
437442 run(
438443 arg0,
439444 arg1,
445+ arg2,
440446 )
441447 })
442448 return _c
@@ -447,7 +453,7 @@ func (_c *MockRenderer_ExportTargets_Call) Return(dMABufs []ports.DMABuf, err er
447453 return _c
448454 }
449455
450-func (_c *MockRenderer_ExportTargets_Call) RunAndReturn(run func(n int, modifiers []uint64) ([]ports.DMABuf, error)) *MockRenderer_ExportTargets_Call {
456+func (_c *MockRenderer_ExportTargets_Call) RunAndReturn(run func(n int, modifiers []uint64, singlePlane bool) ([]ports.DMABuf, error)) *MockRenderer_ExportTargets_Call {
451457 _c.Call.Return(run)
452458 return _c
453459 }
M internal/ports/config.go +8 -1
@@ -143,7 +143,14 @@ type Config struct {
143143 // Cursor.HideAfter hides the pointer cursor after this long without
144144 // motion; the next motion shows it. 0 never hides it.
145145 Cursor struct{ HideAfter time.Duration }
146- Focus struct {
146+ // Animations: On enables motion (off lands everything instantly);
147+ // Slowdown scales every spring's duration (not the focus pulse), 0.1 to
148+ // 10 (the config's animations.speed maps to it).
149+ Animations struct {
150+ On bool
151+ Slowdown float64
152+ }
153+ Focus struct {
147154 // FollowMove shows the target workspace after a column or window
148155 // moves to it.
149156 FollowMove bool
M internal/ports/damage_test.go +20 -0
@@ -35,3 +35,23 @@ func TestDamageSince(t *testing.T) {
3535 t.Fatal("stale history used")
3636 }
3737 }
38+
39+func TestAppendDamageSince(t *testing.T) {
40+ a, b := Rect{X: 1, W: 2, H: 2}, Rect{Y: 5, W: 1, H: 1}
41+ c := SurfaceContent{Seq: 7, DamageHistory: []SeqDamage{{Seq: 6, Rects: []Rect{a}}, {Seq: 7, Rects: []Rect{b}}}}
42+ keep := []Rect{{W: 9}}
43+ if got, ok := c.AppendDamageSince(keep, 5); !ok || !slices.Equal(got, []Rect{{W: 9}, a, b}) {
44+ t.Fatalf("append %v %v", got, ok)
45+ }
46+ // A refused history leaves dst as it was.
47+ c.DamageHistory[1].Full = true
48+ if got, ok := c.AppendDamageSince(keep, 5); ok || !slices.Equal(got, keep) {
49+ t.Fatalf("full change: %v %v", got, ok)
50+ }
51+ // Reusing the slice allocates nothing.
52+ c.DamageHistory[1].Full = false
53+ buf := make([]Rect, 0, 4)
54+ if n := testing.AllocsPerRun(20, func() { buf, _ = c.AppendDamageSince(buf[:0], 5) }); n != 0 {
55+ t.Fatalf("allocs = %v", n)
56+ }
57+}
M internal/ports/devices.go +4 -2
@@ -47,9 +47,11 @@ type Renderer interface {
4747 // ExportTargets allocates n images of the renderer's size that the
4848 // display can scan out, with one of the given XRGB8888 (SDR) or
4949 // XRGB2101010 (HDR) modifiers (none: any the device exports),
50- // and returns them as dmabufs.
50+ // and returns them as dmabufs; a modifier may have several memory
51+ // planes (DCC metadata), each plane owning its file. singlePlane
52+ // restricts the choice to one-plane modifiers.
5153 // n = 0 drops the targets.
52- ExportTargets(n int, modifiers []uint64) ([]DMABuf, error)
54+ ExportTargets(n int, modifiers []uint64, singlePlane bool) ([]DMABuf, error)
5355 // UseTarget selects the exported image the next Render draws into.
5456 UseTarget(i int)
5557 // CursorBuffers allocates two linear ARGB8888 images of size×size the
M internal/ports/scene.go +65 -7
@@ -4,6 +4,7 @@ import (
44 "image"
55 "math"
66 "os"
7+ "slices"
78 )
89
910 // Scene carries core → renderer immutable snapshots with fresh Windows slices,
@@ -68,6 +69,37 @@ type Scene struct {
6869 CaptureIndicators []CaptureIndicator
6970 }
7071
72+// SameAs reports whether o draws exactly what s draws: every field other
73+// than Seq is equal. A scene carrying a CaptureScene is never the same
74+// (conservative: the capture image is not compared). Add every new Scene
75+// field here; TestSceneSameAsCoversEveryField fails until the field count
76+// is updated.
77+func (s Scene) SameAs(o Scene) bool {
78+ if s.Security != o.Security || s.Output != o.Output ||
79+ s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight ||
80+ s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off ||
81+ s.Background != o.Background || s.Border != o.Border ||
82+ s.WorkspaceClip != o.WorkspaceClip || s.Dim != o.Dim || s.DimBehind != o.DimBehind {
83+ return false
84+ }
85+ if s.CaptureScene != nil || o.CaptureScene != nil {
86+ return false
87+ }
88+ if (s.Capture == nil) != (o.Capture == nil) {
89+ return false
90+ }
91+ if s.Capture != nil {
92+ a, b := *s.Capture, *o.Capture
93+ if a.Shown != b.Shown || a.Session != b.Session || a.Revision != b.Revision ||
94+ a.Workspace != b.Workspace || a.Window != b.Window || !slices.Equal(a.Excluded, b.Excluded) {
95+ return false
96+ }
97+ }
98+ return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) &&
99+ slices.Equal(s.DropHints, o.DropHints) && slices.Equal(s.Layers, o.Layers) &&
100+ slices.Equal(s.CaptureIndicators, o.CaptureIndicators)
101+}
102+
71103 // Shows reports whether the scene draws the surface of id: only its
72104 // content changes need a new frame. A window scrolled off the output is
73105 // not drawn. WorkspaceClip also excludes off-viewport windows; OverLayers
@@ -175,6 +207,11 @@ type SurfaceContent struct {
175207 // Opaque ignores the alpha byte: an x format, or an opaque region that
176208 // covers the whole surface. It is false while Fade is set.
177209 Opaque bool
210+ // OpaqueRect is the largest rect of the opaque region inside a surface
211+ // that is not wholly Opaque, in logical pixels from the surface origin:
212+ // the pixels there ignore alpha too, so the surfaces below it are hidden
213+ // by it. Empty when there is none or while Fade is set.
214+ OpaqueRect Rect
178215 // Fade is how much wp_alpha_modifier_v1 fades the surface: 0 none,
179216 // 1 invisible. Its opacity is multiplied by 1-Fade.
180217 Fade float32
@@ -270,24 +307,35 @@ type SeqDamage struct {
270307 // DamageSince is the union of changes after content seq up to this one,
271308 // and false when the history does not reach back to seq (redraw all).
272309 func (c SurfaceContent) DamageSince(seq uint64) ([]Rect, bool) {
310+ out, ok := c.AppendDamageSince(nil, seq)
311+ if !ok {
312+ return nil, false
313+ }
314+ return out, true
315+}
316+
317+// AppendDamageSince is DamageSince appending to dst, so a caller reusing
318+// its slice allocates nothing. When the history does not reach back to seq
319+// it returns dst unchanged and false.
320+func (c SurfaceContent) AppendDamageSince(dst []Rect, seq uint64) ([]Rect, bool) {
273321 if seq >= c.Seq {
274- return nil, true
322+ return dst, true
275323 }
276324 h := c.DamageHistory
277325 if len(h) == 0 || h[0].Seq > seq+1 || h[len(h)-1].Seq != c.Seq {
278- return nil, false
326+ return dst, false
279327 }
280- var out []Rect
328+ n := len(dst)
281329 for _, d := range h {
282330 if d.Seq <= seq {
283331 continue
284332 }
285333 if d.Full {
286- return nil, false
334+ return dst[:n], false
287335 }
288- out = append(out, d.Rects...)
336+ dst = append(dst, d.Rects...)
289337 }
290- return out, true
338+ return dst, true
291339 }
292340
293341 // Subsurface is a child surface at X, Y logical pixels from the root
@@ -371,6 +419,11 @@ type SceneWindow struct {
371419 // Dim darkens the window, border included, with black at this
372420 // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing.
373421 Dim float64
422+ // Fade makes the whole window translucent, border and content: 0 is
423+ // opaque, 1 invisible (like SurfaceContent.Fade). A window appearing
424+ // or leaving animates it; a faded window is composed, never scanned
425+ // out.
426+ Fade float64
374427 // FocusEffect, above 0, is the focus indicator's effect at this frame
375428 // (focus.animation, focus.effect): the window's surfaces are lifted
376429 // toward white by that fraction (screen blend). Renderers without the
@@ -382,8 +435,13 @@ type SceneWindow struct {
382435 // needs its kind here and in the push constants (a misc flag).
383436 FocusEffect float64
384437 // Preview, above 0, draws the window's surfaces that much smaller in
385- // Rect (an overview thumbnail): the client keeps its size.
438+ // Rect (an overview thumbnail): the client keeps its size. A preview
439+ // is a card: it never opens the floats.
386440 Preview float64
441+ // Zoom, above 0, is the content scale of a window whose frame is
442+ // animating (appearing, leaving, an overview card in flight): it
443+ // replaces Preview for drawing only; the window keeps its kind.
444+ Zoom float64
387445 // Popups are drawn from their content only: no border, no background.
388446 Popup bool
389447 // OverLayers popups hang from a layer surface: drawn over the top and
A internal/ports/scene_same_test.go +96 -0
@@ -0,0 +1,96 @@
1+package ports
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func sameSceneBase() Scene {
11+ return Scene{
12+ Security: SecurityState{Generation: 1},
13+ Output: "A",
14+ Seq: 1,
15+ OutputWidth: 100,
16+ OutputHeight: 50,
17+ Scale: 1,
18+ Background: "#000000",
19+ Border: Border{Width: 2, Active: "#ffffff", Inactive: "#222222"},
20+ Windows: []SceneWindow{{ID: 1, Rect: Rect{X: 1, W: 10, H: 10}}},
21+ Separators: []Separator{{Rect: Rect{W: 1, H: 10}}},
22+ DropHints: []Rect{{W: 5, H: 5}},
23+ Layers: []SceneLayer{{ID: 9, Rect: Rect{W: 100, H: 5}}},
24+ Capture: &SceneCapture{Shown: 1, Excluded: []WindowID{3}},
25+
26+ CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},
27+ }
28+}
29+
30+func TestSceneSameAs(t *testing.T) {
31+ base := sameSceneBase()
32+
33+ same := sameSceneBase()
34+ same.Seq = 99
35+ assert.True(t, base.SameAs(same), "Seq alone must not matter")
36+ assert.True(t, Scene{}.SameAs(Scene{Seq: 3}))
37+
38+ for name, edit := range map[string]func(*Scene){
39+ "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },
40+ "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 },
41+ "window zoom": func(s *Scene) { s.Windows[0].Zoom = 0.9 },
42+ "window count": func(s *Scene) { s.Windows = nil },
43+ "security": func(s *Scene) { s.Security.Generation++ },
44+ "background": func(s *Scene) { s.Background = "#111111" },
45+ "border": func(s *Scene) { s.Border.Width++ },
46+ "output": func(s *Scene) { s.Output = "B" },
47+ "size": func(s *Scene) { s.OutputWidth++ },
48+ "scale": func(s *Scene) { s.Scale = 2 },
49+ "off": func(s *Scene) { s.Off = true },
50+ "clip": func(s *Scene) { s.WorkspaceClip.W = 1 },
51+ "dim": func(s *Scene) { s.Dim = 0.5 },
52+ "dim behind": func(s *Scene) { s.DimBehind = true },
53+ "separator": func(s *Scene) { s.Separators[0].Active = true },
54+ "drop hint": func(s *Scene) { s.DropHints[0].W++ },
55+ "layer": func(s *Scene) { s.Layers[0].Rect.H++ },
56+ "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },
57+ "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },
58+ "transform": func(s *Scene) { s.Transform = 1 },
59+ "capture shown": func(s *Scene) { s.Capture.Shown++ },
60+ "capture session": func(s *Scene) { s.Capture.Session++ },
61+ "capture workspace": func(s *Scene) { s.Capture.Workspace++ },
62+ "capture window": func(s *Scene) { s.Capture.Window++ },
63+ "capture revision": func(s *Scene) { s.Capture.Revision++ },
64+ "capture nil": func(s *Scene) { s.Capture = nil },
65+ "capture scene": func(s *Scene) { s.CaptureScene = &Scene{} },
66+ } {
67+ t.Run(name, func(t *testing.T) {
68+ o := sameSceneBase()
69+ edit(&o)
70+ assert.False(t, base.SameAs(o))
71+ assert.False(t, o.SameAs(base))
72+ })
73+ }
74+
75+ both := sameSceneBase()
76+ both.CaptureScene = &Scene{}
77+ other := sameSceneBase()
78+ other.CaptureScene = &Scene{}
79+ assert.False(t, both.SameAs(other), "a capture scene is never the same")
80+}
81+
82+// TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
83+// update Scene.SameAs, then this count.
84+func TestSceneSameAsCoversEveryField(t *testing.T) {
85+ const fields = 20
86+ assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
87+ "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
88+}
89+
90+// TestSceneCaptureSameAsCoversEveryField fails when SceneCapture gains or
91+// loses a field: update Scene.SameAs, then this count.
92+func TestSceneCaptureSameAsCoversEveryField(t *testing.T) {
93+ const fields = 6
94+ assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(SceneCapture{}))),
95+ "SceneCapture fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
96+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close