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:
60 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...60 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
61 # Fast, mandatory guards for tiled content publications and SHM copies.61 # Fast, mandatory guards for tiled content publications and SHM copies.
62 perf-check:62 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=163+ 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
64 check: vet test arch fakes-check log-check perf-check64 check: vet test arch fakes-check log-check perf-check
65 65
66 # On-demand SDR/HDR colour check with examples/testpattern on a headless66 # On-demand SDR/HDR colour check with examples/testpattern on a headless
M README.md +2 -2
@@ -18,7 +18,7 @@
18 18
19 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.19 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.
20 20
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.
22 22
23 ## OK, but why Go?23 ## OK, but why Go?
24 24
@@ -65,7 +65,7 @@ A full build takes seconds, and tests, race detection, profiling and formatting
65 - **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.65 - **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.
66 - **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.66 - **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.
67 - **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.67 - **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.
69 - **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.69 - **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.
70 - **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.70 - **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.
71 - **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).71 - **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`).
44 | `mouse.accel-profile` | `adaptive` | `adaptive`: faster moves go further; `flat`: constant speed |44 | `mouse.accel-profile` | `adaptive` | `adaptive`: faster moves go further; `flat`: constant speed |
45 | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons |45 | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons |
46 | `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |46 | `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) |
47 | `focus.follow-move` | `off` | Follow a column moved to another workspace |49 | `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` |
49 | `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 |51 | `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 |
50 | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` |52 | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` |
51 | `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 |53 | `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
134 136
135 ## Touchpad137 ## Touchpad
136 138
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:
138 140
139 - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen.141 - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen.
140 - Up or down slides to the next numbered workspace.142 - 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
143 145
144 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.146 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.
145 147
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+
146 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.150 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.
147 151
148 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.152 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
218 222
219 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.223 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.
220 224
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).
222 226
223 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.227 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.
224 228
@@ -255,7 +259,7 @@ The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows`
255 259
256 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.260 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.
257 261
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.
259 263
260 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.264 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.
261 265
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
8 - **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.8 - **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.
9 - **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.9 - **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.
10 - **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.10 - **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`.
11 - **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.14 - **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.
12 - **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.15 - **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.
13 16
@@ -17,7 +20,7 @@ With the capability, the dynamic loader ignores `LD_PRELOAD` and `LD_LIBRARY_PAT
17 20
18 ## Guards21 ## Guards
19 22
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`.
21 24
22 ## Measuring25 ## Measuring
23 26
M examples/config +9 -0
@@ -72,6 +72,15 @@ cursor.hide-after = 5s
72 72
73 # on: moving a column or window to another workspace shows that workspace.73 # on: moving a column or window to another workspace shows that workspace.
74 focus.follow-move = off74 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
75 # Focus indicator on the newly focused window: an animation (pulse or off)84 # Focus indicator on the newly focused window: an animation (pulse or off)
76 # of an effect (screen: a slight brightening), peaking at strength85 # of an effect (screen: a slight brightening), peaking at strength
77 # (0.01 to 0.2; 0.04: black reaches about 10/255).86 # (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 {
169 c.Touchpad.ScrollFactor = 1169 c.Touchpad.ScrollFactor = 1
170 c.Mouse.AccelProfile = ports.AccelAdaptive170 c.Mouse.AccelProfile = ports.AccelAdaptive
171 c.Cursor.HideAfter = 5 * time.Second171 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
172 c.Focus.Animation = ports.FocusAnimationPulse175 c.Focus.Animation = ports.FocusAnimationPulse
173 c.Focus.Effect = ports.FocusEffectScreen176 c.Focus.Effect = ports.FocusEffectScreen
174 c.Focus.Strength = 0.04177 c.Focus.Strength = 0.04
@@ -749,6 +752,23 @@ func set(c *ports.Config, key, v string) error {
749 return fmt.Errorf("must be off or a duration between 100ms and %s, e.g. 5s", maxCursorHideAfter)752 return fmt.Errorf("must be off or a duration between 100ms and %s, e.g. 5s", maxCursorHideAfter)
750 }753 }
751 c.Cursor.HideAfter = d754 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
752 case "focus.follow-move":772 case "focus.follow-move":
753 b, err := onOff(v)773 b, err := onOff(v)
754 if err != nil {774 if err != nil {
@@ -1007,6 +1027,14 @@ func positive(dst *int, v string, max int) error {
1007 return nil1027 return nil
1008 }1028 }
1009 1029
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+
1010 func onOff(v string) (bool, error) {1038 func onOff(v string) (bool, error) {
1011 switch v {1039 switch v {
1012 case "on":1040 case "on":
M internal/adapters/config/config_test.go +31 -0
@@ -2,6 +2,7 @@ package config
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "math"
5 "os"6 "os"
6 "path/filepath"7 "path/filepath"
7 "reflect"8 "reflect"
@@ -78,6 +79,36 @@ func TestFollowMove(t *testing.T) {
78 }79 }
79 }80 }
80 81
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+
81 func TestFocusIndicator(t *testing.T) {112 func TestFocusIndicator(t *testing.T) {
82 d := Defaults().Focus113 d := Defaults().Focus
83 if d.Animation != ports.FocusAnimationPulse || d.Effect != ports.FocusEffectScreen || d.Strength != 0.04 {114 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)
420 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}420 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
421 }421 }
422 r.EXPECT().SetHDR(float64(0)).Return().Maybe()422 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()
424 r.EXPECT().UseTarget(mock.Anything).Return()424 r.EXPECT().UseTarget(mock.Anything).Return()
425 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)425 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
426 r.EXPECT().Close().Return().Once()426 r.EXPECT().Close().Return().Once()
M internal/adapters/drm/capture_test.go +3 -3
@@ -339,7 +339,7 @@ func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) {
339 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}339 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
340 }340 }
341 display.EXPECT().SetHDR(float64(0)).Return().Maybe()341 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()
343 display.EXPECT().UseTarget(mock.Anything).Return()343 display.EXPECT().UseTarget(mock.Anything).Return()
344 display.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)344 display.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
345 display.EXPECT().Close().Return().Once()345 display.EXPECT().Close().Return().Once()
@@ -447,7 +447,7 @@ func TestRunCaptureWaitsForItsIndicatorScene(t *testing.T) {
447 }447 }
448 r := portsmocks.NewMockRenderer(t)448 r := portsmocks.NewMockRenderer(t)
449 r.EXPECT().SetHDR(float64(0)).Return().Maybe()449 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()
451 r.EXPECT().UseTarget(mock.Anything).Return()451 r.EXPECT().UseTarget(mock.Anything).Return()
452 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {452 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
453 switch {453 switch {
@@ -543,7 +543,7 @@ func TestRunCaptureWithoutIndicatorFailsAfterTheBoundedWait(t *testing.T) {
543 }543 }
544 r := portsmocks.NewMockRenderer(t)544 r := portsmocks.NewMockRenderer(t)
545 r.EXPECT().SetHDR(float64(0)).Return().Maybe()545 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()
547 r.EXPECT().UseTarget(mock.Anything).Return()547 r.EXPECT().UseTarget(mock.Anything).Return()
548 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)548 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
549 r.EXPECT().Close().Return().Once()549 r.EXPECT().Close().Return().Once()
M internal/adapters/drm/commit_test.go +231 -10
@@ -2,6 +2,7 @@ package drm
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "os"6 "os"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -111,21 +112,40 @@ func TestCursorRefusedDoesNotBlameScanoutBuffer(t *testing.T) {
111 }112 }
112 }113 }
113 114
114-// showImages validates the images with a TEST_ONLY modeset and falls back115+// tiledMod stands for a non-linear driver modifier in the image tests.
115-// to linear images when KMS refuses the driver's.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.
116 func TestShowImagesTestsBeforeModeset(t *testing.T) {121 func TestShowImagesTestsBeforeModeset(t *testing.T) {
117- o, k, commits := testOutput(t, unix.EINVAL)122+ o, k, commits := testOutput(t, unix.EINVAL, unix.EINVAL)
118 o.cursor = nil123 o.cursor = nil
119 r := portsmocks.NewMockRenderer(t)124 r := portsmocks.NewMockRenderer(t)
120 buf := func() ports.DMABuf {125 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 {
121 f, w, _ := os.Pipe()136 f, w, _ := os.Pipe()
122 w.Close()137 w.Close()
123 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}138 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
124 }139 }
140+ tiled1 := func() ports.DMABuf {
141+ b := linear()
142+ b.Modifier = tiledMod
143+ return b
144+ }
125 r.EXPECT().SetHDR(float64(0)).Return().Maybe()145 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
126- r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once()146+ r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
127- r.EXPECT().SetHDR(float64(0)).Return().Maybe()147+ r.EXPECT().ExportTargets(2, []uint64(nil), true).Return([]ports.DMABuf{tiled1(), tiled1()}, nil).Once()
128- r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Once()148+ r.EXPECT().ExportTargets(2, []uint64{0}, false).Return([]ports.DMABuf{linear(), linear()}, nil).Once()
129 r.EXPECT().UseTarget(mock.Anything).Return()149 r.EXPECT().UseTarget(mock.Anything).Return()
130 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)150 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
131 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)151 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
@@ -135,7 +155,7 @@ func TestShowImagesTestsBeforeModeset(t *testing.T) {
135 if err := o.showImages(r, imagesDriver, nil); err != nil {155 if err := o.showImages(r, imagesDriver, nil); err != nil {
136 t.Fatal(err)156 t.Fatal(err)
137 }157 }
138- want := []uint32{atomicTestOnly | atomicAllowModes, atomicTestOnly | atomicAllowModes, atomicAllowModes}158+ want := []uint32{atomicTestOnly | atomicAllowModes, atomicTestOnly | atomicAllowModes, atomicTestOnly | atomicAllowModes, atomicAllowModes}
139 if len(*commits) != len(want) {159 if len(*commits) != len(want) {
140 t.Fatalf("%d commits", len(*commits))160 t.Fatalf("%d commits", len(*commits))
141 }161 }
@@ -149,6 +169,207 @@ func TestShowImagesTestsBeforeModeset(t *testing.T) {
149 }169 }
150 }170 }
151 171
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+
152 // Enabling an output whose images were not made yet (started switched373 // Enabling an output whose images were not made yet (started switched
153 // away) tests them and modesets once.374 // away) tests them and modesets once.
154 func TestEnableAfterStartAwayModesetsOnce(t *testing.T) {375 func TestEnableAfterStartAwayModesetsOnce(t *testing.T) {
@@ -161,7 +382,7 @@ func TestEnableAfterStartAwayModesetsOnce(t *testing.T) {
161 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}382 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
162 }383 }
163 r.EXPECT().SetHDR(float64(0)).Return().Maybe()384 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()
165 r.EXPECT().UseTarget(mock.Anything).Return()386 r.EXPECT().UseTarget(mock.Anything).Return()
166 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)387 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
167 r.EXPECT().Close().Return().Once()388 r.EXPECT().Close().Return().Once()
@@ -262,7 +483,7 @@ func TestRunReportsSeenAfterCursorCommit(t *testing.T) {
262 r.EXPECT().CursorBuffers(64).Return([2]ports.DMABuf{buf(), buf()}, nil).Once()483 r.EXPECT().CursorBuffers(64).Return([2]ports.DMABuf{buf(), buf()}, nil).Once()
263 r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).Return(nil)484 r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).Return(nil)
264 r.EXPECT().SetHDR(float64(0)).Return().Maybe()485 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()
266 r.EXPECT().UseTarget(mock.Anything).Return()487 r.EXPECT().UseTarget(mock.Anything).Return()
267 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)488 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
268 r.EXPECT().Close().Return().Once()489 r.EXPECT().Close().Return().Once()
@@ -355,7 +576,7 @@ func TestRunClosesFrameFences(t *testing.T) {
355 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}576 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
356 }577 }
357 r.EXPECT().SetHDR(float64(0)).Return().Maybe()578 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()
359 r.EXPECT().UseTarget(mock.Anything).Return()580 r.EXPECT().UseTarget(mock.Anything).Return()
360 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {581 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
361 f, w, _ := os.Pipe()582 f, w, _ := os.Pipe()
M internal/adapters/drm/drop_hint_test.go +3 -3
@@ -9,15 +9,15 @@ import (
9 func TestDropHintsBlockPlanes(t *testing.T) {9 func TestDropHintsBlockPlanes(t *testing.T) {
10 s, c := overlayScene()10 s, c := overlayScene()
11 s.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}11 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" {
13 t.Fatalf("overlay reason %q", reason)13 t.Fatalf("overlay reason %q", reason)
14 }14 }
15 fs, fc := fullscreenScene()15 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" {
17 t.Fatal("no hints, still refused")17 t.Fatal("no hints, still refused")
18 }18 }
19 fs.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}19 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" {
21 t.Fatalf("scanout reason %q", reason)21 t.Fatalf("scanout reason %q", reason)
22 }22 }
23 }23 }
M internal/adapters/drm/hdr_test.go +14 -14
@@ -73,7 +73,7 @@ func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
73 }73 }
74 74
75 func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) {75 func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) {
76- o, k, commits := testOutput(t, unix.EINVAL, unix.EINVAL)76+ o, k, commits := testOutput(t, unix.EINVAL)
77 o.cursor = nil77 o.cursor = nil
78 o.hdr.cap = hdrCapability{Capable: true}78 o.hdr.cap = hdrCapability{Capable: true}
79 o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: 203}79 o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: 203}
@@ -88,9 +88,9 @@ func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) {
88 var calls []string88 var calls []string
89 r.EXPECT().SetHDR(float64(203)).Run(func(float64) { calls = append(calls, "hdr") }).Return().Once()89 r.EXPECT().SetHDR(float64(203)).Run(func(float64) { calls = append(calls, "hdr") }).Return().Once()
90 r.EXPECT().SetHDR(float64(0)).Run(func(float64) { calls = append(calls, "sdr") }).Return().Once()90 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()91+ r.EXPECT().ExportTargets(2, []uint64{0}, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
92- r.EXPECT().ExportTargets(0, []uint64(nil)).Run(func(int, []uint64) { calls = append(calls, "drop") }).Return(nil, 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)).Return([]ports.DMABuf{buf(), buf()}, nil).Once()93+ r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
94 r.EXPECT().UseTarget(mock.Anything).Return()94 r.EXPECT().UseTarget(mock.Anything).Return()
95 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)95 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
96 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil)96 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil)
@@ -108,16 +108,16 @@ func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) {
108 if o.hdr.on || !o.hdr.failed {108 if o.hdr.on || !o.hdr.failed {
109 t.Fatalf("HDR on %t failed %t", o.hdr.on, o.hdr.failed)109 t.Fatalf("HDR on %t failed %t", o.hdr.on, o.hdr.failed)
110 }110 }
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 {
112 t.Fatalf("commits: %d", len(*commits))114 t.Fatalf("commits: %d", len(*commits))
113 }115 }
114- for i := range 2 {116+ if (*commits)[0].flags != atomicTestOnly|atomicAllowModes {
115- if (*commits)[i].flags != atomicTestOnly|atomicAllowModes {117+ t.Fatalf("HDR test flags: %#x", (*commits)[0].flags)
116- t.Fatalf("HDR test %d flags: %#x", i, (*commits)[i].flags)
117- }
118 }118 }
119- if (*commits)[2].flags != atomicTestOnly|atomicAllowModes || (*commits)[3].flags != atomicAllowModes {119+ if (*commits)[1].flags != atomicTestOnly|atomicAllowModes || (*commits)[2].flags != atomicAllowModes {
120- t.Fatalf("SDR commits: %#x %#x", (*commits)[2].flags, (*commits)[3].flags)120+ t.Fatalf("SDR commits: %#x %#x", (*commits)[1].flags, (*commits)[2].flags)
121 }121 }
122 if v, _ := (*commits)[0].req.value(o.conn.id, 100); v != 321 {122 if v, _ := (*commits)[0].req.value(o.conn.id, 100); v != 321 {
123 t.Fatalf("HDR test metadata: %d", v)123 t.Fatalf("HDR test metadata: %d", v)
@@ -160,7 +160,7 @@ func TestHDRSuccessfulModeset(t *testing.T) {
160 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}160 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
161 }161 }
162 r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Once()162 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()
164 r.EXPECT().UseTarget(mock.Anything).Return()164 r.EXPECT().UseTarget(mock.Anything).Return()
165 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)165 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
166 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil).Twice()166 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil).Twice()
@@ -201,7 +201,7 @@ func TestHDRBlobReusedAcrossImageSetup(t *testing.T) {
201 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}201 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
202 }202 }
203 r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Twice()203 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()
205 r.EXPECT().UseTarget(mock.Anything).Return()205 r.EXPECT().UseTarget(mock.Anything).Return()
206 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)206 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
207 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil)207 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil)
@@ -251,7 +251,7 @@ func TestHDRBlobReplacementWaitsForCommit(t *testing.T) {
251 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}251 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
252 }252 }
253 r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Twice()253 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()
255 r.EXPECT().UseTarget(mock.Anything).Return()255 r.EXPECT().UseTarget(mock.Anything).Return()
256 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)256 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
257 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil)257 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil)
M internal/adapters/drm/images.go +73 -20
@@ -17,10 +17,14 @@ import (
17 type imageKind int17 type imageKind int
18 18
19 const (19 const (
20- imagesDriver imageKind = iota // exported, modifier chosen by the driver20+ imagesDriver imageKind = iota // exported, modifier chosen by the driver (DCC planes allowed)
21- imagesLinear // exported, linear21+ imagesSinglePlane // exported, driver modifier with one memory plane
22+ imagesLinear // exported, linear
22 )23 )
23 24
25+// maxFBPlanes is the number of planes a KMS framebuffer holds (fbCmd2).
26+const maxFBPlanes = 4
27+
24 // showImages sets up images from kind on and modesets them. KMS may refuse28 // showImages sets up images from kind on and modesets them. KMS may refuse
25 // an image only in a modeset: a TEST_ONLY modeset checks it first, and the29 // an image only in a modeset: a TEST_ONLY modeset checks it first, and the
26 // next kind is tried on refusal. cause is why the30 // 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
57 o.hdr.on = false61 o.hdr.on = false
58 o.freeImages()62 o.freeImages()
59 r.SetHDR(0)63 r.SetHDR(0)
60- _, _ = r.ExportTargets(0, nil)64+ _, _ = r.ExportTargets(0, nil, false)
61 // A failed test commit can leave the old HDR mode on screen;65 // A failed test commit can leave the old HDR mode on screen;
62 // retain its blob until the SDR modeset succeeds or Close restores it.66 // retain its blob until the SDR modeset succeeds or Close restores it.
63 } else {67 } else {
@@ -97,9 +101,20 @@ func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) er
97 o.freeImages()101 o.freeImages()
98 return err102 return err
99 }103 }
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+ }
100 o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Msg("modeset refused the output images")110 o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Msg("modeset refused the output images")
101 o.freeImages()111 o.freeImages()
102 kind, cause = got+1, err112 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+ }
103 }118 }
104 }119 }
105 120
@@ -112,13 +127,21 @@ func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (ima
112 if o.runContext != nil && o.runContext.Err() != nil {127 if o.runContext != nil && o.runContext.Err() != nil {
113 return kind, o.runContext.Err()128 return kind, o.runContext.Err()
114 }129 }
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())
116 if o.hdr.on {144 if o.hdr.on {
117- for _, f := range o.primary.formats {
118- if f.Format == fourccXR30 {
119- mods = append(mods, f.Modifier)
120- }
121- }
122 if len(mods) == 0 {145 if len(mods) == 0 {
123 return kind, fmt.Errorf("primary plane has no XRGB2101010 modifiers")146 return kind, fmt.Errorf("primary plane has no XRGB2101010 modifiers")
124 }147 }
@@ -131,7 +154,12 @@ func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (ima
131 } else if kind == imagesLinear {154 } else if kind == imagesLinear {
132 mods = []uint64{0}155 mods = []uint64{0}
133 }156 }
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 {
135 return kind, nil163 return kind, nil
136 }164 }
137 o.log.Info().Err(err).Str("connector", o.conn.name).Int("kind", int(kind)).Msg("output image export")165 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
139 return imagesLinear, fmt.Errorf("%s: GPU cannot export scanout images (ADR 014): %w", o.conn.name, err)167 return imagesLinear, fmt.Errorf("%s: GPU cannot export scanout images (ADR 014): %w", o.conn.name, err)
140 }168 }
141 169
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+
142 // exportImages makes the renderer's exported targets the output images.190 // exportImages makes the renderer's exported targets the output images.
143-func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {191+func (o *Output) exportImages(r ports.Renderer, mods []uint64, singlePlane bool) error {
144- bufs, err := r.ExportTargets(len(o.fbs), mods)192+ bufs, err := r.ExportTargets(len(o.fbs), mods, singlePlane)
145 if err != nil {193 if err != nil {
146 return err194 return err
147 }195 }
196+ o.planes, o.imageMod = 0, 0
148 for i := range bufs {197 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+ }
149 if err == nil {201 if err == nil {
150- format := uint32(fourccXRGB)202+ o.fbs[i], err = o.k.addFB(&bufs[i], o.imageFormat())
151- if o.hdr.on {203+ }
152- format = fourccXR30204+ // Every plane owns its file; the framebuffer keeps its own
153- }205+ // reference to the buffer.
154- o.fbs[i], err = o.k.addFB(&bufs[i], format)206+ for _, p := range bufs[i].Planes {
207+ p.File.Close()
155 }208 }
156- bufs[i].Planes[0].File.Close()
157 }209 }
158 // GPU memory starts undefined (old VRAM contents): clear both images210 // GPU memory starts undefined (old VRAM contents): clear both images
159 // before the modeset shows one.211 // before the modeset shows one.
@@ -175,10 +227,11 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
175 }227 }
176 if err != nil {228 if err != nil {
177 o.freeImages()229 o.freeImages()
178- _, _ = r.ExportTargets(0, nil)230+ _, _ = r.ExportTargets(0, nil, false)
179 return err231 return err
180 }232 }
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")
182 return nil235 return nil
183 }236 }
184 237
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 (
12 "github.com/bnema/neferwl/internal/adapters/capture"12 "github.com/bnema/neferwl/internal/adapters/capture"
13 "github.com/bnema/neferwl/internal/adapters/clock"13 "github.com/bnema/neferwl/internal/adapters/clock"
14 "github.com/bnema/neferwl/internal/adapters/presented"14 "github.com/bnema/neferwl/internal/adapters/presented"
15+ "github.com/bnema/neferwl/internal/adapters/surfaces"
15 16
16 "github.com/bnema/neferwl/internal/ports"17 "github.com/bnema/neferwl/internal/ports"
17 "github.com/bnema/zerowrap"18 "github.com/bnema/zerowrap"
@@ -35,6 +36,10 @@ type Output struct {
35 securityInvalid bool36 securityInvalid bool
36 inactiveOnClose bool // affirmative terminal KMS result, owned by Close37 inactiveOnClose bool // affirmative terminal KMS result, owned by Close
37 runContext context.Context // bounds compositor clear waits during Run startup38 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
38 43
39 k kms44 k kms
40 flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents45 flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents
@@ -176,6 +181,10 @@ type Output struct {
176 protectNotBefore time.Time181 protectNotBefore time.Time
177 // kind is how the images were made.182 // kind is how the images were made.
178 kind imageKind183 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
179 // formats receives the direct scanout formats after each modeset;188 // formats receives the direct scanout formats after each modeset;
180 // sampled and device are what they are built from (Want).189 // sampled and device are what they are built from (Want).
181 formats chan<- ports.OutputFormats190 formats chan<- ports.OutputFormats
@@ -1026,7 +1035,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1026 o.probeAsync(r)1035 o.probeAsync(r)
1027 }1036 }
1028 }1037 }
1029- surfaces := make(map[ports.WindowID]ports.SurfaceContent)1038+ table := surfaces.New()
1039+ drawn := table.Map()
1040+ o.kept = table.Kept
1030 var scene ports.Scene1041 var scene ports.Scene
1031 haveScene, dirty := false, false1042 haveScene, dirty := false, false
1032 var want ports.CursorChange1043 var want ports.CursorChange
@@ -1334,6 +1345,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1334 if !o.sceneCurrent(s) {1345 if !o.sceneCurrent(s) {
1335 continue1346 continue
1336 }1347 }
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+ }
1337 if o.protected {1352 if o.protected {
1338 s.CaptureScene = nil1353 s.CaptureScene = nil
1339 if o.wantOff != s.Off {1354 if o.wantOff != s.Off {
@@ -1347,6 +1362,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1347 }1362 }
1348 scene, haveScene, dirty = s, true, true1363 scene, haveScene, dirty = s, true, true
1349 o.wantOff = s.Off1364 o.wantOff = s.Off
1365+ // A closed window's last content goes once no scene draws it.
1366+ table.Prune(scene)
1350 case c := <-cursor:1367 case c := <-cursor:
1351 o.observeSecurity()1368 o.observeSecurity()
1352 if o.protected {1369 if o.protected {
@@ -1359,11 +1376,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1359 stateDirty = true1376 stateDirty = true
1360 }1377 }
1361 case c := <-contents:1378 case c := <-contents:
1362- if c.Empty() {1379+ table.Update(c, scene)
1363- delete(surfaces, c.ID)
1364- } else {
1365- surfaces[c.ID] = c
1366- }
1367 if c.Seq > seen[c.ID] {1380 if c.Seq > seen[c.ID] {
1368 seen[c.ID] = c.Seq1381 seen[c.ID] = c.Seq
1369 reportDirty = true1382 reportDirty = true
@@ -1463,7 +1476,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1463 continue1476 continue
1464 }1477 }
1465 start := time.Now()1478 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)
1467 var fatal renderError1480 var fatal renderError
1468 if errors.As(err, &fatal) || errors.Is(err, errSecurityScene) {1481 if errors.As(err, &fatal) || errors.Is(err, errSecurityScene) {
1469 // A failed render or epoch rejection did not hand these requests to the worker.1482 // 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 (
5 "context"5 "context"
6 "errors"6 "errors"
7 "image"7 "image"
8+ "maps"
8 "os"9 "os"
9 "strings"10 "strings"
10 "sync"11 "sync"
@@ -565,7 +566,7 @@ func TestRunReportsSeenAfterFlip(t *testing.T) {
565 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}566 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
566 }567 }
567 r.EXPECT().SetHDR(float64(0)).Return().Maybe()568 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()
569 r.EXPECT().UseTarget(mock.Anything).Return()570 r.EXPECT().UseTarget(mock.Anything).Return()
570 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {571 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
571 f, w, _ := os.Pipe()572 f, w, _ := os.Pipe()
@@ -651,7 +652,7 @@ func TestRunComposedFullscreenKeepsVRR(t *testing.T) {
651 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}652 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
652 }653 }
653 r.EXPECT().SetHDR(float64(0)).Return().Maybe()654 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()
655 r.EXPECT().UseTarget(mock.Anything).Return()656 r.EXPECT().UseTarget(mock.Anything).Return()
656 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)657 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
657 r.EXPECT().Close().Return().Once()658 r.EXPECT().Close().Return().Once()
@@ -790,7 +791,7 @@ func TestCaptureRenderFailureClosesPendingRequest(t *testing.T) {
790 t.Cleanup(func() { _ = f.Close() })791 t.Cleanup(func() { _ = f.Close() })
791 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}792 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
792 }793 }
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()
794 r.EXPECT().Render(ports.Scene{Background: "#000000"}, mock.Anything).Return(nil, nil).Twice()795 r.EXPECT().Render(ports.Scene{Background: "#000000"}, mock.Anything).Return(nil, nil).Twice()
795 r.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Background != "#000000" }), mock.Anything).Return(nil, boom).Once()796 r.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Background != "#000000" }), mock.Anything).Return(nil, boom).Once()
796 r.EXPECT().Close().Return().Once()797 r.EXPECT().Close().Return().Once()
@@ -852,7 +853,7 @@ func TestCaptureForcesDRMComposition(t *testing.T) {
852 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}853 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
853 }854 }
854 r.EXPECT().SetHDR(float64(0)).Return().Maybe() // SDR output855 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()
856 r.EXPECT().UseTarget(mock.Anything).Return()857 r.EXPECT().UseTarget(mock.Anything).Return()
857 rendered := make(chan ports.Scene, 3)858 rendered := make(chan ports.Scene, 3)
858 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {859 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 {
968 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}969 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
969 }970 }
970 r.EXPECT().SetHDR(float64(0)).Return().Maybe()971 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()
972 r.EXPECT().UseTarget(mock.Anything).Return()973 r.EXPECT().UseTarget(mock.Anything).Return()
973 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)974 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
974 r.EXPECT().Close().Return().Once()975 r.EXPECT().Close().Return().Once()
@@ -1052,3 +1053,111 @@ func TestRunSecurityResetClearsWantedAt(t *testing.T) {
1052 t.Fatalf("stamp kept across the security reset: %s", sr.o.wantedAt)1053 t.Fatalf("stamp kept across the security reset: %s", sr.o.wantedAt)
1053 }1054 }
1054 }1055 }
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 {
59 // overlayCandidate finds the one window the overlay can show: an opaque59 // overlayCandidate finds the one window the overlay can show: an opaque
60 // dmabuf at integer physical coordinates, drawn 1:1, with no other window60 // dmabuf at integer physical coordinates, drawn 1:1, with no other window
61 // or layer above it, and the plane colour mode it needs. reason is why none.61 // 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) {
63 if s.Transform != 0 {66 if s.Transform != 0 {
64 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"67 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"
65 }68 }
@@ -82,10 +85,10 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
82 // A viewport crop is composed: the plane would show the whole85 // A viewport crop is composed: the plane would show the whole
83 // buffer, and so is a colour the plane cannot show (raw PQ values86 // buffer, and so is a colour the plane cannot show (raw PQ values
84 // on an SDR output, SDR on HDR without a pipeline). A dimmed87 // on an SDR output, SDR on HDR without a pipeline). A dimmed
85- // window (a peeking stashed one) needs the veil drawn over it, and88+ // window (a peeking stashed one) needs the veil drawn over it, a
86- // one with the focus effect needs its lift; an overview preview is89+ // fading one its translucency, and one with the focus effect its
87- // drawn smaller than its buffer.90+ // lift; an overview preview is 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) {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)) {
89 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {92 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
90 pick, mode = w, m93 pick, mode = w, m
91 continue94 continue
@@ -97,6 +100,15 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
97 }100 }
98 }101 }
99 if pick == nil {102 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+ }
100 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "no_candidate"112 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "no_candidate"
101 }113 }
102 if s.Dim > 0 {114 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) {
43 func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) {43 func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) {
44 s, contents := fullscreenScene()44 s, contents := fullscreenScene()
45 s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}45 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" {
47 t.Fatalf("fullscreen bypassed clip: %q", reason)47 t.Fatalf("fullscreen bypassed clip: %q", reason)
48 }48 }
49 if fullscreenShown(&s) {49 if fullscreenShown(&s) {
@@ -54,28 +54,28 @@ func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) {
54 func TestOverlayWorkspaceClip(t *testing.T) {54 func TestOverlayWorkspaceClip(t *testing.T) {
55 s, contents := overlayScene()55 s, contents := overlayScene()
56 s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}56 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" {
58 t.Fatalf("overflowing workspace overlay: %q", reason)58 t.Fatalf("overflowing workspace overlay: %q", reason)
59 }59 }
60 s.WorkspaceClip = ports.Rect{X: 100, W: 100, H: 100}60 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 {
62 t.Fatalf("contained workspace overlay: %v %q", win.ID, reason)62 t.Fatalf("contained workspace overlay: %v %q", win.ID, reason)
63 }63 }
64 }64 }
65 65
66 func TestOverlayCandidate(t *testing.T) {66 func TestOverlayCandidate(t *testing.T) {
67 s, c := overlayScene()67 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 {
69 t.Fatalf("candidate %v %q", w.ID, reason)69 t.Fatalf("candidate %v %q", w.ID, reason)
70 }70 }
71 // A rotated output composes: planes are not rotated.71 // A rotated output composes: planes are not rotated.
72 rs, rc := overlayScene()72 rs, rc := overlayScene()
73 rs.Transform = 173 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" {
75 t.Fatalf("rotated output reason %q", reason)75 t.Fatalf("rotated output reason %q", reason)
76 }76 }
77 c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}}77 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" {
79 t.Fatalf("YUV overlay reason %q", reason)79 t.Fatalf("YUV overlay reason %q", reason)
80 }80 }
81 // A transformed buffer is composed: the plane would show it unrotated.81 // A transformed buffer is composed: the plane would show it unrotated.
@@ -83,46 +83,58 @@ func TestOverlayCandidate(t *testing.T) {
83 rotated := c[2]83 rotated := c[2]
84 rotated.Transform = 184 rotated.Transform = 1
85 c[2] = rotated85 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" {
87 t.Fatalf("transformed overlay reason %q", reason)87 t.Fatalf("transformed overlay reason %q", reason)
88 }88 }
89 _, c = overlayScene()89 _, c = overlayScene()
90 s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}})90 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" {
92 t.Fatalf("reason %q", reason)92 t.Fatalf("reason %q", reason)
93 }93 }
94 s, _ = overlayScene()94 s, _ = overlayScene()
95 s.Scale = 1.595 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" {
97 t.Fatalf("reason %q", reason)97 t.Fatalf("reason %q", reason)
98 }98 }
99 s, _ = overlayScene()99 s, _ = overlayScene()
100 s.Layers = []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 5, H: 5}}}100 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" {
102 t.Fatalf("reason %q", reason)102 t.Fatalf("reason %q", reason)
103 }103 }
104 // The dim veil is composed under the float: nothing leaves for the plane.104 // The dim veil is composed under the float: nothing leaves for the plane.
105 s, _ = overlayScene()105 s, _ = overlayScene()
106 s.Dim = 0.3106 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" {
108 t.Fatalf("dim reason %q", reason)108 t.Fatalf("dim reason %q", reason)
109 }109 }
110 // A dimmed window (a peeking stashed one) needs its veil composed.110 // A dimmed window (a peeking stashed one) needs its veil composed.
111 s, _ = overlayScene()111 s, _ = overlayScene()
112 s.Windows[len(s.Windows)-1].Dim = 0.5112 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" {
114 t.Fatalf("dimmed window reason %q", reason)114 t.Fatalf("dimmed window reason %q", reason)
115 }115 }
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+ }
116 // A window with the focus effect needs it composed.128 // A window with the focus effect needs it composed.
117 s, _ = overlayScene()129 s, _ = overlayScene()
118 s.Windows[len(s.Windows)-1].FocusEffect = 0.05130 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" {
120 t.Fatalf("pulsing window reason %q", reason)132 t.Fatalf("pulsing window reason %q", reason)
121 }133 }
122 // An overview preview is drawn smaller than its buffer: composed.134 // An overview preview is drawn smaller than its buffer: composed.
123 s, _ = overlayScene()135 s, _ = overlayScene()
124 s.Windows[len(s.Windows)-1].Preview = 0.5136 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" {
126 t.Fatalf("preview reason %q", reason)138 t.Fatalf("preview reason %q", reason)
127 }139 }
128 // A viewport crop is composed: the plane would show the whole buffer.140 // A viewport crop is composed: the plane would show the whole buffer.
@@ -130,13 +142,13 @@ func TestOverlayCandidate(t *testing.T) {
130 cropped := c[2]142 cropped := c[2]
131 cropped.Source = [4]float32{10, 10, 50, 50}143 cropped.Source = [4]float32{10, 10, 50, 50}
132 c[2] = cropped144 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" {
134 t.Fatalf("cropped overlay reason %q", reason)146 t.Fatalf("cropped overlay reason %q", reason)
135 }147 }
136 // A full-buffer source is no crop.148 // A full-buffer source is no crop.
137 cropped.Source = [4]float32{0, 0, 100, 100}149 cropped.Source = [4]float32{0, 0, 100, 100}
138 c[2] = cropped150 c[2] = cropped
139- if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" {151+ if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" {
140 t.Fatalf("full source reason %q", reason)152 t.Fatalf("full source reason %q", reason)
141 }153 }
142 }154 }
@@ -212,12 +224,12 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) {
212 func TestOverlayPopupAndBorder(t *testing.T) {224 func TestOverlayPopupAndBorder(t *testing.T) {
213 s, c := overlayScene()225 s, c := overlayScene()
214 s.Windows = append([]ports.SceneWindow{{ID: 7, Popup: true, Rect: ports.Rect{W: 5, H: 5}}}, s.Windows...)226 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" {
216 t.Fatalf("popup: %q", reason)228 t.Fatalf("popup: %q", reason)
217 }229 }
218 s, _ = overlayScene()230 s, _ = overlayScene()
219 s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}}231 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" {
221 t.Fatalf("border: %q", reason)233 t.Fatalf("border: %q", reason)
222 }234 }
223 }235 }
M internal/adapters/drm/power_test.go +2 -2
@@ -25,7 +25,7 @@ func TestRunOutputPower(t *testing.T) {
25 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}25 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
26 }26 }
27 r.EXPECT().SetHDR(float64(0)).Return().Maybe()27 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()
29 r.EXPECT().UseTarget(mock.Anything).Return()29 r.EXPECT().UseTarget(mock.Anything).Return()
30 renders := make(chan struct{}, 8)30 renders := make(chan struct{}, 8)
31 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {31 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) {
133 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}133 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
134 }134 }
135 r.EXPECT().SetHDR(float64(0)).Return().Maybe()135 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()
137 r.EXPECT().UseTarget(mock.Anything).Return().Maybe()137 r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
138 renders := make(chan struct{}, 8)138 renders := make(chan struct{}, 8)
139 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {139 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
32 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}32 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
33 }33 }
34 r.EXPECT().SetHDR(float64(0)).Return().Maybe()34 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()
36 r.EXPECT().UseTarget(mock.Anything).Return().Maybe()36 r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
37 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {37 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
38 return renders()38 return renders()
@@ -266,7 +266,7 @@ func TestProtectedShowImagesHDRKeepsHDRForKMSErrors(t *testing.T) {
266 r.EXPECT().SetHDR(float64(203)).Return().Once()266 r.EXPECT().SetHDR(float64(203)).Return().Once()
267 r.EXPECT().UseTarget(mock.Anything).Return().Maybe()267 r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
268 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Maybe()268 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()
270 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil).Maybe()270 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil).Maybe()
271 k.EXPECT().createBlob(mock.Anything).Return(321, nil).Maybe()271 k.EXPECT().createBlob(mock.Anything).Return(321, nil).Maybe()
272 k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()272 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
30 }30 }
31 clear(o.showsScratch)31 clear(o.showsScratch)
32 for id, seq := range seen {32 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)) {
34 o.showsScratch[id] = seq36 o.showsScratch[id] = seq
35 }37 }
36 }38 }
M internal/adapters/drm/scanout.go +15 -3
@@ -85,7 +85,11 @@ func fullscreenShown(s *ports.Scene) bool {
85 // scanoutCandidate returns the window content the output can scan out85 // scanoutCandidate returns the window content the output can scan out
86 // directly, or a reason why it cannot. w, h are the output's physical size.86 // directly, or a reason why it cannot. w, h are the output's physical size.
87 // The plane's formats are checked by scanoutFB.87 // 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) {
89 if s.Transform != 0 {93 if s.Transform != 0 {
90 // The scanout buffer would have to be pre-rotated: rotated outputs compose.94 // The scanout buffer would have to be pre-rotated: rotated outputs compose.
91 return ports.SurfaceContent{}, "output_transform"95 return ports.SurfaceContent{}, "output_transform"
@@ -107,6 +111,9 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
107 if full == nil {111 if full == nil {
108 return ports.SurfaceContent{}, "no_fullscreen"112 return ports.SurfaceContent{}, "no_fullscreen"
109 }113 }
114+ if kept != nil && kept(full.ID) {
115+ return ports.SurfaceContent{}, "leaving"
116+ }
110 if len(s.DropHints) > 0 {117 if len(s.DropHints) > 0 {
111 // Drag hints are drawn over the window.118 // Drag hints are drawn over the window.
112 return ports.SurfaceContent{}, "drop_hint"119 return ports.SurfaceContent{}, "drop_hint"
@@ -115,6 +122,11 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
115 // Its veil is composed over it: the plane would show it bright.122 // Its veil is composed over it: the plane would show it bright.
116 return ports.SurfaceContent{}, "dim"123 return ports.SurfaceContent{}, "dim"
117 }124 }
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+ }
118 // Core leaves the layers a fullscreen window hides out of the scene:130 // Core leaves the layers a fullscreen window hides out of the scene:
119 // one above the windows here is drawn.131 // one above the windows here is drawn.
120 for _, l := range s.Layers {132 for _, l := range s.Layers {
@@ -200,7 +212,7 @@ func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) {
200 func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent, mode colorMode) {212 func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent, mode colorMode) {
201 reason := "disabled"213 reason := "disabled"
202 if o.scanout {214 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)
204 if reason == "" {216 if reason == "" {
205 mode, reason = planeColor(c.Color, c.DMABuf.Format, c.Opaque, o.hdr.on, o.primary.pipeline)217 mode, reason = planeColor(c.Color, c.DMABuf.Format, c.Opaque, o.hdr.on, o.primary.pipeline)
206 }218 }
@@ -255,7 +267,7 @@ func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S
255 var w ports.SceneWindow267 var w ports.SceneWindow
256 var c ports.SurfaceContent268 var c ports.SurfaceContent
257 var mode colorMode269 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)
259 if reason == "" && mode != colorBypass {271 if reason == "" && mode != colorBypass {
260 if ok, known := o.overlay.verdict(c.DMABuf.Format, o.cursorShown()); known && !ok {272 if ok, known := o.overlay.verdict(c.DMABuf.Format, o.cursorShown()); known && !ok {
261 reason = "color_refused"273 reason = "color_refused"
M internal/adapters/drm/scanout_test.go +7 -4
@@ -27,6 +27,9 @@ func TestScanoutCandidate(t *testing.T) {
27 {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"},27 {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"},
28 {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"},28 {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"},
29 {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"},29 {"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"},
30 {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"},33 {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"},
31 {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"},34 {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"},
32 {"float above", func(s *ports.Scene) {35 {"float above", func(s *ports.Scene) {
@@ -73,7 +76,7 @@ func TestScanoutCandidate(t *testing.T) {
73 if tc.cont != nil {76 if tc.cont != nil {
74 tc.cont(&c)77 tc.cont(&c)
75 }78 }
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 {
77 t.Errorf("%s: reason %q, want %q", tc.name, reason, tc.reason)80 t.Errorf("%s: reason %q, want %q", tc.name, reason, tc.reason)
78 }81 }
79 }82 }
@@ -102,7 +105,7 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) {
102 root := ports.SurfaceContent{ID: 1, Seq: 7, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, SHM: &ports.SHMBuffer{},105 root := ports.SurfaceContent{ID: 1, Seq: 7, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, SHM: &ports.SHMBuffer{},
103 Geometry: ports.Rect{W: 100, H: 50},106 Geometry: ports.Rect{W: 100, H: 50},
104 Children: []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{Seq: 3, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, Opaque: true, DMABuf: game, Async: true, Acquire: acquire}}}}107 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)
106 if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) {109 if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) {
107 t.Fatalf("candidate %+v, reason %q", c, reason)110 t.Fatalf("candidate %+v, reason %q", c, reason)
108 }111 }
@@ -111,12 +114,12 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) {
111 // one; alone it is not a dmabuf.114 // one; alone it is not a dmabuf.
112 solidRoot := root115 solidRoot := root
113 solidRoot.SHM, solidRoot.Solid = nil, &ports.SolidColor{A: 1}116 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)
115 if reason != "" || c.DMABuf != game || c.Solid != nil || c.ID != 1 || c.Seq != 7 {118 if reason != "" || c.DMABuf != game || c.Solid != nil || c.ID != 1 || c.Seq != 7 {
116 t.Fatalf("solid root with dmabuf child: candidate %+v, reason %q", c, reason)119 t.Fatalf("solid root with dmabuf child: candidate %+v, reason %q", c, reason)
117 }120 }
118 solidRoot.Children = nil121 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" {
120 t.Fatalf("solid root alone: reason %q, want not_dmabuf", reason)123 t.Fatalf("solid root alone: reason %q, want not_dmabuf", reason)
121 }124 }
122 }125 }
M internal/adapters/drm/security_recovery_test.go +1 -1
@@ -31,7 +31,7 @@ func TestSecurityStuckRecoveryInvalidatesBeforeFreshProof(t *testing.T) {
31 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}31 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
32 }32 }
33 r.EXPECT().SetHDR(float64(0)).Return().Maybe()33 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()
35 r.EXPECT().UseTarget(mock.Anything).Return()35 r.EXPECT().UseTarget(mock.Anything).Return()
36 fence, signal := protectionFence(t, false)36 fence, signal := protectionFence(t, false)
37 var renderedOnce atomic.Bool37 var renderedOnce atomic.Bool
M internal/adapters/drm/security_run_test.go +1 -1
@@ -32,7 +32,7 @@ func TestSecurityRunWakePendingResumeAndOff(t *testing.T) {
32 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}32 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
33 }33 }
34 r.EXPECT().SetHDR(float64(0)).Return().Maybe()34 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()
36 r.EXPECT().UseTarget(mock.Anything).Return()36 r.EXPECT().UseTarget(mock.Anything).Return()
37 black := make(chan struct{}, 8)37 black := make(chan struct{}, 8)
38 desktop := make(chan struct{}, 2)38 desktop := make(chan struct{}, 2)
M internal/adapters/drm/security_startup_test.go +3 -3
@@ -28,7 +28,7 @@ func TestSecurityProtectedStartupBlackProofAndCaptureRejection(t *testing.T) {
28 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}28 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
29 }29 }
30 r.EXPECT().SetHDR(float64(0)).Return().Maybe()30 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()
32 r.EXPECT().UseTarget(mock.Anything).Return()32 r.EXPECT().UseTarget(mock.Anything).Return()
33 r.EXPECT().Render(ports.Scene{Background: "#000000"}, map[ports.WindowID]ports.SurfaceContent(nil)).Return(nil, nil).Times(3)33 r.EXPECT().Render(ports.Scene{Background: "#000000"}, map[ports.WindowID]ports.SurfaceContent(nil)).Return(nil, nil).Times(3)
34 r.EXPECT().Close().Return().Once()34 r.EXPECT().Close().Return().Once()
@@ -94,8 +94,8 @@ func TestSecurityStartupClearWaitUsesRunContext(t *testing.T) {
94 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}94 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
95 }95 }
96 r.EXPECT().SetHDR(float64(0)).Return().Maybe()96 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
97- r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()97+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
98- r.EXPECT().ExportTargets(0, []uint64(nil)).Return(nil, nil).Once()98+ r.EXPECT().ExportTargets(0, []uint64(nil), false).Return(nil, nil).Once()
99 r.EXPECT().UseTarget(0).Return().Once()99 r.EXPECT().UseTarget(0).Return().Once()
100 fence, _ := protectionFence(t, false)100 fence, _ := protectionFence(t, false)
101 ctx, cancel := context.WithCancel(context.Background())101 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 {
52 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}52 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
53 }53 }
54 r.EXPECT().SetHDR(float64(0)).Return().Maybe()54 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()
56 r.EXPECT().UseTarget(mock.Anything).Return()56 r.EXPECT().UseTarget(mock.Anything).Return()
57 var started atomic.Bool57 var started atomic.Bool
58 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {58 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) {
92 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}92 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
93 }93 }
94 r.EXPECT().SetHDR(float64(0)).Return().Maybe()94 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()
96 r.EXPECT().UseTarget(mock.Anything).Return()96 r.EXPECT().UseTarget(mock.Anything).Return()
97 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)97 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
98 r.EXPECT().Close().Return().Once()98 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 (
15 "github.com/bnema/neferwl/internal/adapters/capture"15 "github.com/bnema/neferwl/internal/adapters/capture"
16 "github.com/bnema/neferwl/internal/adapters/clock"16 "github.com/bnema/neferwl/internal/adapters/clock"
17 "github.com/bnema/neferwl/internal/adapters/presented"17 "github.com/bnema/neferwl/internal/adapters/presented"
18+ "github.com/bnema/neferwl/internal/adapters/surfaces"
18 "github.com/bnema/neferwl/internal/adapters/syncfile"19 "github.com/bnema/neferwl/internal/adapters/syncfile"
19 "github.com/bnema/neferwl/internal/ports"20 "github.com/bnema/neferwl/internal/ports"
20 "github.com/bnema/zerowrap"21 "github.com/bnema/zerowrap"
@@ -66,10 +67,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
66 var confirmed *ports.OutputHDR67 var confirmed *ports.OutputHDR
67 if opts.HDR {68 if opts.HDR {
68 r.SetHDR(203)69 r.SetHDR(203)
69- if bufs, err := r.ExportTargets(1, nil); err != nil {70+ if bufs, err := r.ExportTargets(1, nil, false); err != nil {
70 opts.Log.Warn().Err(err).Str("output", opts.Name).Msg("virtual HDR unavailable; falling back to SDR")71 opts.Log.Warn().Err(err).Str("output", opts.Name).Msg("virtual HDR unavailable; falling back to SDR")
71 r.SetHDR(0)72 r.SetHDR(0)
72- _, _ = r.ExportTargets(0, nil)73+ _, _ = r.ExportTargets(0, nil, false)
73 } else {74 } else {
74 for _, b := range bufs {75 for _, b := range bufs {
75 for _, p := range b.Planes {76 for _, p := range b.Planes {
@@ -87,7 +88,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
87 }88 }
88 }89 }
89 }90 }
90- surfaces := make(map[ports.WindowID]ports.SurfaceContent)91+ table := surfaces.New()
92+ drawn := table.Map()
91 var scene ports.Scene93 var scene ports.Scene
92 haveScene, dirty := false, false94 haveScene, dirty := false, false
93 frame := 095 frame := 0
@@ -199,16 +201,18 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
199 if opts.Security != nil && (s.Security != security || s.Security != opts.Security.Snapshot()) {201 if opts.Security != nil && (s.Security != security || s.Security != opts.Security.Snapshot()) {
200 return202 return
201 }203 }
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+ }
202 scene, haveScene, dirty = s, true, true208 scene, haveScene, dirty = s, true, true
209+ // A closed window's last content goes once no scene draws it.
210+ table.Prune(scene)
203 }211 }
204 update := func(c ports.SurfaceContent) {212 update := func(c ports.SurfaceContent) {
205 seen[c.ID] = max(seen[c.ID], c.Seq)213 seen[c.ID] = max(seen[c.ID], c.Seq)
206 dirty = dirty || capture.Shows(scene, c.ID)214 dirty = dirty || capture.Shows(scene, c.ID)
207- if c.Empty() {215+ table.Update(c, scene)
208- delete(surfaces, c.ID)
209- } else {
210- surfaces[c.ID] = c
211- }
212 }216 }
213 clk := opts.Clock217 clk := opts.Clock
214 if clk == nil {218 if clk == nil {
@@ -469,7 +473,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
469 failRequests("security epoch changed")473 failRequests("security epoch changed")
470 continue474 continue
471 }475 }
472- pipeline.SubmitHidden(scene, surfaces, hidden)476+ pipeline.SubmitHidden(scene, drawn, hidden)
473 clear(hidden)477 clear(hidden)
474 // A slow child may outlive Wayland's stale-report timeout even478 // A slow child may outlive Wayland's stale-report timeout even
475 // though this owner waits. Publish its non-expiring holds first.479 // 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
490 }494 }
491 }495 }
492 // Captures never show the capture indicator: their frame goes first.496 // 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)
494 if pdone != nil {498 if pdone != nil {
495 // The plain render reads client buffers even when its requests499 // The plain render reads client buffers even when its requests
496 // are rejected by a gate transition: own and wait its fence500 // 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
517 failRequests("security epoch changed")521 failRequests("security epoch changed")
518 continue522 continue
519 }523 }
520- xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), surfaces)524+ xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn)
521 if xdone != nil {525 if xdone != nil {
522 // The excluded-frame composition reads client buffers even when the displayed526 // The excluded-frame composition reads client buffers even when the displayed
523 // frame is skipped by a gate/off transition. Own and wait its527 // 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
545 failRequests("security epoch changed")549 failRequests("security epoch changed")
546 continue550 continue
547 }551 }
548- done, err := r.Render(scene, surfaces)552+ done, err := r.Render(scene, drawn)
549 if err != nil {553 if err != nil {
550 return fmt.Errorf("render frame: %w", err)554 return fmt.Errorf("render frame: %w", err)
551 }555 }
@@ -575,7 +579,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
575 pipeline.EndGate(nil)579 pipeline.EndGate(nil)
576 requests = capture.Waiting(requests, scene)580 requests = capture.Waiting(requests, scene)
577 frame++581 frame++
578- opts.report(&reports, flipInfo(scene, surfaces), seen)582+ opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen)
579 if opts.ScreenshotDir != "" && !opts.protected() {583 if opts.ScreenshotDir != "" && !opts.protected() {
580 shot := r.Pixels()584 shot := r.Pixels()
581 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {585 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
613 }617 }
614 }618 }
615 }619 }
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+ }
617 }623 }
618 }624 }
619 625
@@ -665,12 +671,14 @@ func (opts Options) flush(q *presented.Queue) {
665 // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC671 // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
666 // time (software clock, refresh unknown), so presentation feedback and672 // time (software clock, refresh unknown), so presentation feedback and
667 // frame callbacks follow headless frames.673 // 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 {
669 var ts unix.Timespec675 var ts unix.Timespec
670 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)676 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
671 shows := map[ports.WindowID]uint64{}677 shows := map[ports.WindowID]uint64{}
672 for id, c := range surfaces {678 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) {
674 shows[id] = c.Seq682 shows[id] = c.Seq
675 }683 }
676 }684 }
M internal/adapters/headless/output_test.go +47 -6
@@ -84,18 +84,27 @@ func TestRun(t *testing.T) {
84 if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {84 if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {
85 t.Errorf("coalescing: %+v %+v", s, c)85 t.Errorf("coalescing: %+v %+v", s, c)
86 }86 }
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.
87 contents <- ports.SurfaceContent{ID: 1}89 contents <- ports.SurfaceContent{ID: 1}
88 waitFrames(t, f, 2)90 waitFrames(t, f, 2)
89 if _, c := f.snapshot(); len(c) < 2 {91 if _, c := f.snapshot(); len(c) < 2 {
90 t.Fatal("no second frame")92 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 {
92 t.Error("content not deleted")101 t.Error("content not deleted")
93 }102 }
94 cancel()103 cancel()
95 if err := <-done; err != nil {104 if err := <-done; err != nil {
96 t.Fatal(err)105 t.Fatal(err)
97 }106 }
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"} {
99 file, err := os.Open(filepath.Join(dir, name))108 file, err := os.Open(filepath.Join(dir, name))
100 if err != nil {109 if err != nil {
101 t.Fatal(err)110 t.Fatal(err)
@@ -108,6 +117,38 @@ func TestRun(t *testing.T) {
108 }117 }
109 }118 }
110 119
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+
111 // Content of a window the output does not show draws no frame.152 // Content of a window the output does not show draws no frame.
112 func TestRunSkipsContentNotShown(t *testing.T) {153 func TestRunSkipsContentNotShown(t *testing.T) {
113 scenes := make(chan ports.Scene, 1)154 scenes := make(chan ports.Scene, 1)
@@ -308,7 +349,7 @@ func TestHeadlessRawHDRScreenshot(t *testing.T) {
308 }349 }
309 r := portsmocks.NewMockRenderer(t)350 r := portsmocks.NewMockRenderer(t)
310 r.EXPECT().SetHDR(float64(203)).Return().Once()351 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()
312 rendered := make(chan struct{}, 4)353 rendered := make(chan struct{}, 4)
313 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {354 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
314 rendered <- struct{}{}355 rendered <- struct{}{}
@@ -381,16 +422,16 @@ func TestHeadlessHDRFormatsConfirmed(t *testing.T) {
381 if err != nil {422 if err != nil {
382 t.Fatal(err)423 t.Fatal(err)
383 }424 }
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()
385 t.Cleanup(func() {426 t.Cleanup(func() {
386 if _, err := f.Stat(); err == nil {427 if _, err := f.Stat(); err == nil {
387 t.Error("exported fd not closed")428 t.Error("exported fd not closed")
388 }429 }
389 })430 })
390 } else {431 } 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()
392 r.EXPECT().SetHDR(float64(0)).Return().Once()433 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()
394 }435 }
395 r.EXPECT().Close().Return().Once()436 r.EXPECT().Close().Return().Once()
396 formats := make(chan ports.OutputFormats, 1)437 formats := make(chan ports.OutputFormats, 1)
M internal/adapters/headless/security_test.go +1 -1
@@ -267,7 +267,7 @@ func TestSecurityRawHDRSuppressed(t *testing.T) {
267 }267 }
268 r := portsmocks.NewMockRenderer(t)268 r := portsmocks.NewMockRenderer(t)
269 r.EXPECT().SetHDR(float64(203)).Return().Once()269 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()
271 rendered := make(chan ports.Scene, 4)271 rendered := make(chan ports.Scene, 4)
272 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {272 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
273 rendered <- s273 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) {
529 hdrShot := readPixels(t, r).RGBAAt(3, 3)529 hdrShot := readPixels(t, r).RGBAAt(3, 3)
530 leased := captureWait(t, r) // HDR capture held across the toggle530 leased := captureWait(t, r) // HDR capture held across the toggle
531 r.SetHDR(0)531 r.SetHDR(0)
532- if _, err := r.ExportTargets(0, nil); err != nil {532+ if _, err := r.ExportTargets(0, nil, false); err != nil {
533 t.Fatal(err)533 t.Fatal(err)
534 }534 }
535 scene.Seq++535 scene.Seq++
@@ -556,7 +556,7 @@ func TestCaptureSurvivesHDRToggle(t *testing.T) {
556 }556 }
557 // Back to HDR: the capture pass and slots are reused.557 // Back to HDR: the capture pass and slots are reused.
558 r.SetHDR(203)558 r.SetHDR(203)
559- if _, err := r.ExportTargets(1, r.hdrMods); err != nil {559+ if _, err := r.ExportTargets(1, r.hdrMods, false); err != nil {
560 t.Skipf("no HDR target: %v", err)560 t.Skipf("no HDR target: %v", err)
561 }561 }
562 scene.Seq++562 scene.Seq++
M internal/adapters/vulkan/compose.go +16 -0
@@ -326,6 +326,22 @@ func (r *Renderer) solidDraw(rect image.Rectangle, content *ports.SurfaceContent
326 return dr326 return dr
327 }327 }
328 328
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+
329 // dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA345 // dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA
330 // leaves the destination at (1-alpha) of its previous value.346 // leaves the destination at (1-alpha) of its previous value.
331 func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw {347 func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw {
M internal/adapters/vulkan/damage.go +159 -16
@@ -3,6 +3,7 @@ package vulkan
3 import (3 import (
4 "image"4 "image"
5 "math"5 "math"
6+ "slices"
6 7
7 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
8 )9 )
@@ -10,8 +11,10 @@ import (
10 // Damage: an output target keeps what it holds (scene Seq, each window's11 // Damage: an output target keeps what it holds (scene Seq, each window's
11 // content Seq). When the scene is the same, only the region changed since12 // content Seq). When the scene is the same, only the region changed since
12 // then is redrawn: every quad is clipped to it on the CPU and the pass13 // 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 or14+// keeps the rest of the image. A scene with a new Seq redraws only the
14-// a history too short redraws everything.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.
15 18
16 // damageRegion collects the region of a target to redraw this frame and19 // damageRegion collects the region of a target to redraw this frame and
17 // what the target will hold after it: the windows drawn, their content20 // what the target will hold after it: the windows drawn, their content
@@ -22,6 +25,8 @@ type damageRegion struct {
22 seen map[ports.WindowID]bool25 seen map[ports.WindowID]bool
23 bound image.Rectangle26 bound image.Rectangle
24 drawn map[ports.WindowID]heldWindow27 drawn map[ports.WindowID]heldWindow
28+ // rects is scratch for a content's damage rects (reused across frames).
29+ rects []ports.Rect
25 }30 }
26 31
27 // heldWindow is a window a target holds: its content Seq and rect.32 // heldWindow is a window a target holds: its content Seq and rect.
@@ -32,11 +37,34 @@ type heldWindow struct {
32 37
33 func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion {38 func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion {
34 d := &damageRegion{seen: map[ports.WindowID]bool{}, bound: bounds, drawn: map[ports.WindowID]heldWindow{}}39 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.40+ d.hold(tg, s)
36- if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {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 {
37 d.held = tg52 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
38 }67 }
39- return d
40 }68 }
41 69
42 // frameDamage reuses the bookkeeping maps. drawn is swapped with the70 // 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
50 r.damageDrawn = make(map[ports.WindowID]heldWindow)78 r.damageDrawn = make(map[ports.WindowID]heldWindow)
51 }79 }
52 clear(r.damageDrawn)80 clear(r.damageDrawn)
53- r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds}81+ r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds, rects: r.damage.rects[:0]}
54- if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {82+ r.damage.hold(tg, s)
55- r.damage.held = tg
56- }
57 return &r.damage83 return &r.damage
58 }84 }
59 85
@@ -86,13 +112,12 @@ func (d *damageRegion) window(id ports.WindowID, c ports.SurfaceContent, rect im
86 d.add(rect)112 d.add(rect)
87 return113 return
88 }114 }
89- rects, known := c.DamageSince(held)115+ var known bool
116+ d.rects, known = c.AppendDamageSince(d.rects[:0], held)
90 if !known {117 if !known {
91 d.add(rect)118 d.add(rect)
92- return
93 }119 }
94- // Mapped by content, called next: nothing more here.120+ // Otherwise mapped by content, called next: nothing more here.
95- _ = rects
96 }121 }
97 122
98 // content maps a root surface's buffer damage onto full (where its w×h123 // 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,
107 if !ok || held == c.Seq || len(c.Children) > 0 {132 if !ok || held == c.Seq || len(c.Children) > 0 {
108 return133 return
109 }134 }
110- rects, known := c.DamageSince(held)135+ var known bool
136+ d.rects, known = c.AppendDamageSince(d.rects[:0], held)
111 if !known || c.Width <= 0 || c.Height <= 0 {137 if !known || c.Width <= 0 || c.Height <= 0 {
112 return138 return
113 }139 }
140+ rects := d.rects
114 if c.Transform != 0 {141 if c.Transform != 0 {
115 d.add(dst)142 d.add(dst)
116 return143 return
@@ -162,7 +189,123 @@ func (d *damageRegion) clip(ds []draw) []draw {
162 }189 }
163 190
164 // hold records what the target holds after this frame: only the windows191 // 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.
166 func (tg *target) hold(s ports.Scene, d *damageRegion) {195 func (tg *target) hold(s ports.Scene, d *damageRegion) {
167- tg.valid, tg.sceneSeq, tg.windows = true, s.Seq, d.drawn196+ 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
168 }311 }
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) {
50 return r50 return r
51 }51 }
52 damaged, full := newR(), newR()52 damaged, full := newR(), newR()
53- bufsD, err := damaged.ExportTargets(2, nil)53+ bufsD, err := damaged.ExportTargets(2, nil, false)
54 if err != nil {54 if err != nil {
55 t.Skipf("no exportable targets: %v", err)55 t.Skipf("no exportable targets: %v", err)
56 }56 }
M internal/adapters/vulkan/dmabuf_test.go +1 -1
@@ -14,7 +14,7 @@ import (
14 14
15 // udmabuf turns memfd pages into a real dmabuf: a linear buffer any GPU15 // udmabuf turns memfd pages into a real dmabuf: a linear buffer any GPU
16 // driver imports, like a client's.16 // 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 {
18 t.Helper()18 t.Helper()
19 dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0)19 dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0)
20 if err != nil {20 if err != nil {
M internal/adapters/vulkan/hdr_composition_test.go +3 -3
@@ -27,7 +27,7 @@ func hdrTestRenderer(t *testing.T) *Renderer {
27 if len(r.hdrMods) == 0 {27 if len(r.hdrMods) == 0 {
28 t.Skip("no HDR transfer-src modifier")28 t.Skip("no HDR transfer-src modifier")
29 }29 }
30- bufs, err := r.ExportTargets(1, r.hdrMods)30+ bufs, err := r.ExportTargets(1, r.hdrMods, false)
31 if err != nil {31 if err != nil {
32 t.Skipf("no HDR target: %v", err)32 t.Skipf("no HDR target: %v", err)
33 }33 }
@@ -116,7 +116,7 @@ func TestHDRPixels(t *testing.T) {
116 defer plain.Close()116 defer plain.Close()
117 plain.SetHDR(203)117 plain.SetHDR(203)
118 plain.SetVirtualOutput(true)118 plain.SetVirtualOutput(true)
119- bufs, err := plain.ExportTargets(1, nil)119+ bufs, err := plain.ExportTargets(1, nil, false)
120 if err != nil {120 if err != nil {
121 t.Skipf("no HDR target: %v", err)121 t.Skipf("no HDR target: %v", err)
122 }122 }
@@ -149,7 +149,7 @@ func TestHDRPixelsReadsRenderedTarget(t *testing.T) {
149 t.Fatal(err)149 t.Fatal(err)
150 }150 }
151 // New targets replace the one the frame drew into: nothing to read.151 // 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)
153 if err != nil {153 if err != nil {
154 t.Skipf("no two HDR targets: %v", err)154 t.Skipf("no two HDR targets: %v", err)
155 }155 }
M internal/adapters/vulkan/renderer.go +10 -0
@@ -10,6 +10,7 @@ import (
10 "unsafe"10 "unsafe"
11 11
12 "github.com/bnema/neferwl/internal/ports"12 "github.com/bnema/neferwl/internal/ports"
13+ "github.com/bnema/zerowrap"
13 14
14 vk "github.com/bnema/purego-vulkan/vulkan"15 vk "github.com/bnema/purego-vulkan/vulkan"
15 )16 )
@@ -35,6 +36,11 @@ type Renderer struct {
35 hdrNits float6436 hdrNits float64
36 hdr hdrPass37 hdr hdrPass
37 hdrError error38 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
38 // hdrReadback makes exported HDR targets transfer sources, for GPU tests44 // hdrReadback makes exported HDR targets transfer sources, for GPU tests
39 // and headless --screenshot-raw (SetHDRReadback); never DRM.45 // and headless --screenshot-raw (SetHDRReadback); never DRM.
40 hdrReadback bool46 hdrReadback bool
@@ -340,6 +346,10 @@ func (r *Renderer) TakeRedrawn() int {
340 return n346 return n
341 }347 }
342 348
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+
343 // QueuePriority is the global priority the queue got: realtime, high or353 // QueuePriority is the global priority the queue got: realtime, high or
344 // default.354 // default.
345 func (r *Renderer) QueuePriority() string { return r.queuePriority }355 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) {
393 }393 }
394 394
395 // solidContent is a w×h B8G8R8A8 buffer of one color.395 // 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 {
397 px := make([]byte, w*h*4)397 px := make([]byte, w*h*4)
398 for i := 0; i < len(px); i += 4 {398 for i := 0; i < len(px); i += 4 {
399 px[i], px[i+1], px[i+2], px[i+3] = c.B, c.G, c.R, 255399 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 {
402 }402 }
403 403
404 // shmContent puts pixels in a memfd pool, like a wl_shm client.404 // 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 {
406 t.Helper()406 t.Helper()
407 fd, err := unix.MemfdCreate("shm-test", unix.MFD_CLOEXEC)407 fd, err := unix.MemfdCreate("shm-test", unix.MFD_CLOEXEC)
408 if err != nil {408 if err != nil {
@@ -561,3 +561,42 @@ func TestRendererAlphaBlend(t *testing.T) {
561 }561 }
562 }562 }
563 }563 }
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 {
22 zoom float6422 zoom float64
23 // pulse is the focus effect of the window being placed23 // pulse is the focus effect of the window being placed
24 // (ports.SceneWindow.FocusEffect); 0 otherwise.24 // (ports.SceneWindow.FocusEffect); 0 otherwise.
25- pulse float3225+ 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
26 bounds image.Rectangle30 bounds image.Rectangle
27 draws []draw31 draws []draw
28 }32 }
@@ -33,7 +37,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
33 // The walk stays in scene-physical space (the target's size, swapped for37 // The walk stays in scene-physical space (the target's size, swapped for
34 // a rotated output); orient maps the draws to the target afterwards.38 // a rotated output); orient maps the draws to the target afterwards.
35 sw, sh := r.sceneSize(s.Transform)39 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]}
37 r.scratchCovers = r.scratchCovers[:0]41 r.scratchCovers = r.scratchCovers[:0]
38 if w.scale <= 0 {42 if w.scale <= 0 {
39 w.scale = 143 w.scale = 1
@@ -129,21 +133,25 @@ func (w *sceneWalk) physRectF(x, y, width, height float64) image.Rectangle {
129 return image.Rect(p(x), p(y), p(x+width), p(y+height))133 return image.Rect(p(x), p(y), p(x+width), p(y+height))
130 }134 }
131 135
132-// fill draws a solid rect (physical pixels).136+// fill draws a solid rect (physical pixels), at the walk's alpha.
133 func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) {137 func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) {
134 rect = rect.Intersect(w.bounds)138 rect = rect.Intersect(w.bounds)
135 if rect.Empty() {139 if rect.Empty() {
136 return140 return
137 }141 }
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)
139 }147 }
140 148
141 // dim paints a translucent black quad over rect (physical), clipped to the149 // dim paints a translucent black quad over rect (physical), clipped to the
142-// output. Damage clipping later limits it to the region being repainted,150+// output, at alpha scaled by the walk's alpha. Damage clipping later limits
143-// just like other fills.151+// it to the region being repainted, just like other fills.
144 func (w *sceneWalk) dim(rect image.Rectangle, alpha float64) {152 func (w *sceneWalk) dim(rect image.Rectangle, alpha float64) {
145 if rect = rect.Intersect(w.bounds); !rect.Empty() {153 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)))
147 }155 }
148 }156 }
149 157
@@ -161,6 +169,30 @@ func (w *sceneWalk) layers(afterWindows bool) {
161 }169 }
162 }170 }
163 171
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+
164 // windows draws ordered placements with borders. Tile lines go after196 // windows draws ordered placements with borders. Tile lines go after
165 // the column group, before the first float above it; an overview preview197 // the column group, before the first float above it; an overview preview
166 // of a float is not one. The veil goes under that float, or under every198 // of a float is not one. The veil goes under that float, or under every
@@ -171,16 +203,22 @@ func (w *sceneWalk) windows() {
171 }203 }
172 tileLines := false204 tileLines := false
173 for _, win := range w.s.Windows {205 for _, win := range w.s.Windows {
174- if win.Hidden || win.Popup || win.Rect.W <= 0 || win.Rect.H <= 0 {206+ if !drawsAsWindow(win) {
175 continue207 continue
176 }208 }
177- if !win.Below && win.Floating && win.Preview == 0 && !tileLines {209+ if opensFloats(win) && !tileLines {
178 w.separators(0)210 w.separators(0)
179 tileLines = true211 tileLines = true
180 if w.s.Dim > 0 && !w.s.DimBehind {212 if w.s.Dim > 0 && !w.s.DimBehind {
181 w.dim(w.bounds, w.s.Dim)213 w.dim(w.bounds, w.s.Dim)
182 }214 }
183 }215 }
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))
184 // Content sits inside the border; core sized the client to match.222 // Content sits inside the border; core sized the client to match.
185 b, inset := 0, ports.Sides(0)223 b, inset := 0, ports.Sides(0)
186 if !win.Fullscreen {224 if !win.Fullscreen {
@@ -193,7 +231,11 @@ func (w *sceneWalk) windows() {
193 // Until its first buffer, a window shows the background: no flash.231 // Until its first buffer, a window shows the background: no flash.
194 w.fill(body, parseColor(w.s.Background))232 w.fill(body, parseColor(w.s.Background))
195 if !content.Empty() {233 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 {
197 w.zoom = win.Preview239 w.zoom = win.Preview
198 }240 }
199 w.pulse = float32(max(0, min(win.FocusEffect, 1)))241 w.pulse = float32(max(0, min(win.FocusEffect, 1)))
@@ -207,6 +249,7 @@ func (w *sceneWalk) windows() {
207 // A stashed window peeking in: dimmed with its border.249 // A stashed window peeking in: dimmed with its border.
208 w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim)250 w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim)
209 }251 }
252+ w.alpha = 1
210 }253 }
211 if !tileLines {254 if !tileLines {
212 w.separators(0)255 w.separators(0)
@@ -267,28 +310,56 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y
267 }310 }
268 }311 }
269 312
270-// opaqueChildren gives the physical rects shown by the opaque subsurfaces313+// opaqueChildren gives the physical rects shown opaque by the subsurfaces
271 // above the root (e.g. a game's GPU surface over its window's shm buffer):314 // 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 is315+// they hide the surfaces below them. A child that is not wholly opaque but
273-// left out, so the surface below it still shows: a dmabuf that fails to316+// has an opaque region (Firefox's page, whose region stops a pixel short of
274-// import (opaqueChildren pre-imports it) or an unreadable shm buffer.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.
275 func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64, clip image.Rectangle) []image.Rectangle {321 func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64, clip image.Rectangle) []image.Rectangle {
276 covers := w.r.scratchCovers[:0]322 covers := w.r.scratchCovers[:0]
277 for i := range content.Children {323 for i := range content.Children {
278 ch := &content.Children[i]324 ch := &content.Children[i]
279- if ch.Below || !ch.Opaque {325+ if ch.Below || !ch.Opaque && ch.OpaqueRect.W <= 0 {
280 continue326 continue
281 }327 }
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)
283 if !ok || !w.drawable(&ch.SurfaceContent) {329 if !ok || !w.drawable(&ch.SurfaceContent) {
284 continue330 continue
285 }331 }
332+ if !ch.Opaque {
333+ if dst = opaqueCover(&ch.SurfaceContent, full, dst); dst.Empty() {
334+ continue
335+ }
336+ }
286 covers = append(covers, dst)337 covers = append(covers, dst)
287 }338 }
288 w.r.scratchCovers = covers339 w.r.scratchCovers = covers
289 return covers340 return covers
290 }341 }
291 342
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+
292 // drawable reports whether the child's buffer will draw. GPU buffer363 // drawable reports whether the child's buffer will draw. GPU buffer
293 // imports are cached, so pre-importing here costs nothing.364 // imports are cached, so pre-importing here costs nothing.
294 func (w *sceneWalk) drawable(c *ports.SurfaceContent) bool {365 func (w *sceneWalk) drawable(c *ports.SurfaceContent) bool {
@@ -340,6 +411,7 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y float64, cl
340 // surface draws one surface buffer with its origin at (x, y) logical,411 // surface draws one surface buffer with its origin at (x, y) logical,
341 // clipped to clip (logical), unless an opaque surface above it (covers,412 // clipped to clip (logical), unless an opaque surface above it (covers,
342 // physical) hides all of it: its buffer is then neither copied nor drawn.413 // 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.
343 func (w *sceneWalk) surface(content *ports.SurfaceContent, x, y float64, clip image.Rectangle, key shmKey, seq uint64, covers []image.Rectangle, root bool) {415 func (w *sceneWalk) surface(content *ports.SurfaceContent, x, y float64, clip image.Rectangle, key shmKey, seq uint64, covers []image.Rectangle, root bool) {
344 full, dst, ok := w.surfaceRects(content, x, y, clip)416 full, dst, ok := w.surfaceRects(content, x, y, clip)
345 if !ok {417 if !ok {
@@ -353,7 +425,74 @@ func (w *sceneWalk) surface(content *ports.SurfaceContent, x, y float64, clip im
353 if root {425 if root {
354 w.dmg.content(key.win, content, full, dst)426 w.dmg.content(key.win, content, full, dst)
355 }427 }
428+ n := len(w.draws)
356 w.content(dst, full, content, key, seq, root)429 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)
357 }496 }
358 497
359 // content draws the part of a buffer mapped onto full (physical pixels)498 // 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
365 return504 return
366 }505 }
367 if content.Solid != nil {506 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 {
369 w.draws = append(w.draws, dr)512 w.draws = append(w.draws, dr)
370 }513 }
371 return514 return
@@ -378,6 +521,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
378 }521 }
379 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)522 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)
380 r.setContentColor(&dr, content)523 r.setContentColor(&dr, content)
524+ dr.pc.color[3] *= w.alpha
381 dr.pc.mapy[2] = w.pulse525 dr.pc.mapy[2] = w.pulse
382 if im.yuv {526 if im.yuv {
383 dr.pc.misc[1] |= flagYUV527 dr.pc.misc[1] |= flagYUV
@@ -399,22 +543,32 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
399 return543 return
400 }544 }
401 b := content.SHM545 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- }
406 st := shmState{w: content.Width, h: content.Height, seq: seq, windowSeq: content.Seq}546 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.547+ // The copy of this content, if any, needs no pool: a kept (closing)
408- var damage func(uint64) ([]ports.Rect, bool)548+ // window whose pool file is closed still draws.
409- if root {549+ c := r.shmCached(key, st)
410- damage = content.DamageSince550+ if c != nil {
411- }551+ // As a drawn pool, for Trim; nothing is mapped for it.
412- c, err := r.shmCopyFor(key, st, pixels, b.Offset, b.Stride, damage)552+ if m := r.pools[b.Pool]; m != nil {
413- if err != nil {553+ m.last = r.frame
414- return554+ }
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+ }
415 }568 }
416 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)569 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)
417 r.setContentColor(&dr, content)570 r.setContentColor(&dr, content)
571+ dr.pc.color[3] *= w.alpha
418 dr.pc.mapy[2] = w.pulse572 dr.pc.mapy[2] = w.pulse
419 dr.set = c.set573 dr.set = c.set
420 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}574 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 (
9 )9 )
10 10
11 // TestRenderSteadyStateAllocations measures the cost of a small stable scene,11 // 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.
13 func TestRenderSteadyStateAllocations(t *testing.T) {17 func TestRenderSteadyStateAllocations(t *testing.T) {
14 for _, tc := range []struct {18 for _, tc := range []struct {
15 name string19 name string
@@ -41,6 +45,93 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
41 } else {45 } else {
42 t.Logf("small steady-state Render: %.1f allocs/frame", allocs)46 t.Logf("small steady-state Render: %.1f allocs/frame", allocs)
43 }47 }
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+ }
44 })135 })
45 }136 }
46 }137 }
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 {
178 last uint64 // frame that drew it178 last uint64 // frame that drew it
179 }179 }
180 180
181-// shmCopyFor returns the GPU buffer holding a surface's content st, copying181+// shmCached returns the GPU buffer already holding a surface's content st,
182-// the client pixels (stride bytes per row from offset) into one when no182+// or nil. It needs no client pixels: a kept window whose pool is closed
183-// buffer holds them yet. A buffer holding an older content of the same183+// still draws from it.
184-// size gets only what changed since (damage, in buffer pixels; false:184+func (r *Renderer) shmCached(key shmKey, st shmState) *shmCopy {
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) {
187 s := r.shm[key]185 s := r.shm[key]
188 if s == nil {186 if s == nil {
189- s = &shmSurface{}187+ return nil
190- r.shm[key] = s
191 }188 }
192 s.last = r.frame189 s.last = r.frame
193 for _, c := range s.bufs {190 for _, c := range s.bufs {
194 if c != nil && c.valid && c.holds.w == st.w && c.holds.h == st.h && c.holds.seq == st.seq {191 if c != nil && c.valid && c.holds.w == st.w && c.holds.h == st.h && c.holds.seq == st.seq {
195 c.gpu.last = r.frame192 c.gpu.last = r.frame
196- return c, nil193+ return c
197 }194 }
198 }195 }
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
199 // The older buffer takes the new content.211 // The older buffer takes the new content.
200 i := 0212 i := 0
201 if s.bufs[0] != nil && (s.bufs[1] == nil || s.bufs[1].gpu.last < s.bufs[0].gpu.last) {213 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
3 import (3 import (
4 "errors"4 "errors"
5 "fmt"5 "fmt"
6+ "maps"
6 "os"7 "os"
7 "slices"8 "slices"
8 "unsafe"9 "unsafe"
@@ -29,11 +30,20 @@ type target struct {
29 // exported images are shared with the display: ownership goes to the30 // exported images are shared with the display: ownership goes to the
30 // foreign queue family after each frame, and KMS reads them.31 // foreign queue family after each frame, and KMS reads them.
31 exported bool32 exported bool
32- // What the image holds (damage.go): the scene Seq and each window's33+ // What the image holds (damage.go): the scene, its Seq and each
33- // content Seq; valid once a frame was drawn whole.34+ // window's content Seq; valid once a frame was drawn whole. Scenes are
34- valid bool35+ // immutable for core, but output adapters reuse their slices in place
35- sceneSeq uint6436+ // (DRM overlay frames, capture), so the compared slices of scene point
36- windows map[ports.WindowID]heldWindow37+ // 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
37 }47 }
38 48
39 // fourccXRGB is DRM_FORMAT_XRGB8888: B8G8R8A8 in memory, alpha ignored.49 // fourccXRGB is DRM_FORMAT_XRGB8888: B8G8R8A8 in memory, alpha ignored.
@@ -44,7 +54,9 @@ const fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24
44 54
45 // ExportTargets allocates n exported images of the output size, replacing55 // ExportTargets allocates n exported images of the output size, replacing
46 // the previous ones; n = 0 only drops them (back to the internal image).56 // 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) {
48 r.dropTargets()60 r.dropTargets()
49 if n > 0 && r.hdrNits > 0 && r.hdrReadback && r.physical != 0 {61 if n > 0 && r.hdrNits > 0 && r.hdrReadback && r.physical != 0 {
50 // Readback targets (GPU tests, headless --screenshot-raw) are also62 // Readback targets (GPU tests, headless --screenshot-raw) are also
@@ -60,7 +72,7 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, err
60 if !r.dd.HasGetMemoryFdKHR() || !r.dd.HasGetImageDrmFormatModifierPropertiesEXT() || len(r.dmabuf.Formats) == 0 {72 if !r.dd.HasGetMemoryFdKHR() || !r.dd.HasGetImageDrmFormatModifierPropertiesEXT() || len(r.dmabuf.Formats) == 0 {
61 return nil, errors.New("device cannot export dmabufs")73 return nil, errors.New("device cannot export dmabufs")
62 }74 }
63- mods := r.exportModifiers(modifiers)75+ mods := r.exportModifiers(modifiers, singlePlane)
64 if len(mods) == 0 {76 if len(mods) == 0 {
65 if r.hdrNits == 0 {77 if r.hdrNits == 0 {
66 return nil, errors.New("no XRGB8888 modifier both the device and the display accept")78 return nil, errors.New("no XRGB8888 modifier both the device and the display accept")
@@ -108,14 +120,18 @@ func (r *Renderer) target() *target {
108 120
109 // exportModifiers intersects the selected signal format modifiers with121 // exportModifiers intersects the selected signal format modifiers with
110 // those supported by the display. HDR never assumes an unspecified list,122 // those supported by the display. HDR never assumes an unspecified list,
111-// except for a virtual output (no display) or a test readback.123+// except for a virtual output (no display) or a test readback. singlePlane
112-func (r *Renderer) exportModifiers(display []uint64) []uint64 {124+// drops the modifiers with more than one memory plane.
125+func (r *Renderer) exportModifiers(display []uint64, singlePlane bool) []uint64 {
113 var out []uint64126 var out []uint64
114 available := r.renderMods127 available := r.renderMods
115 if r.hdrNits > 0 {128 if r.hdrNits > 0 {
116 available = r.hdrMods129 available = r.hdrMods
117 }130 }
118 for _, m := range available {131 for _, m := range available {
132+ if singlePlane && r.modPlanes[m] > 1 {
133+ continue
134+ }
119 if (len(display) == 0 && (r.hdrNits == 0 || r.hdrReadback || r.virtual)) || slices.Contains(display, m) {135 if (len(display) == 0 && (r.hdrNits == 0 || r.hdrReadback || r.virtual)) || slices.Contains(display, m) {
120 out = append(out, m)136 out = append(out, m)
121 }137 }
@@ -123,14 +139,16 @@ func (r *Renderer) exportModifiers(display []uint64) []uint64 {
123 return out139 return out
124 }140 }
125 141
126-// probeRenderModifiers lists single-plane, exportable SDR and HDR targets.142+// probeRenderModifiers lists exportable SDR and HDR targets.
127 func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) {143 func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) {
128 r.renderMods = r.probeModifiers(physical, vk.FormatB8g8r8a8Unorm)144 r.renderMods = r.probeModifiers(physical, vk.FormatB8g8r8a8Unorm)
129 r.hdrMods = r.probeModifiers(physical, vk.FormatA2r10g10b10UnormPack32)145 r.hdrMods = r.probeModifiers(physical, vk.FormatA2r10g10b10UnormPack32)
130 }146 }
131 147
132-// probeModifiers lists the single-plane, exportable modifiers of format that148+// probeModifiers lists the exportable modifiers of format (one to four memory
133-// support the target usage. HDR targets need transfer-src only when149+// 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
134 // hdrReadback is set (GPU tests, headless --screenshot-raw; never DRM).152 // hdrReadback is set (GPU tests, headless --screenshot-raw; never DRM).
135 func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 {153 func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 {
136 list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT}154 list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT}
@@ -148,17 +166,55 @@ func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format)
148 } else if r.hdrReadback {166 } else if r.hdrReadback {
149 need |= formatFeatureTransferSrc167 need |= formatFeatureTransferSrc
150 }168 }
151- var result []uint64
152 // SDR targets are composed and read back; HDR targets are color169 // SDR targets are composed and read back; HDR targets are color
153 // attachments, and transfer sources only with hdrReadback.170 // attachments, and transfer sources only with hdrReadback.
154- for _, m := range mods[:list.DrmFormatModifierCount] {171+ result, planes := filterModifiers(mods[:list.DrmFormatModifierCount], need, func(m uint64) bool { return r.exportable(physical, format, m) })
155- if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, format, m.DrmFormatModifier) {172+ if r.modPlanes == nil {
156- result = append(result, m.DrmFormatModifier)173+ r.modPlanes = make(map[uint64]uint32)
157- }
158 }174 }
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)
159 return result178 return result
160 }179 }
161 180
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+
162 func (r *Renderer) exportable(physical vk.PhysicalDevice, format vk.Format, modifier uint64) bool {218 func (r *Renderer) exportable(physical vk.PhysicalDevice, format vk.Format, modifier uint64) bool {
163 mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive}219 mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive}
164 ext := vk.PhysicalDeviceExternalImageFormatInfo{SType: vk.StructureTypePhysicalDeviceExternalImageFormatInfo, Next: unsafe.Pointer(&mod), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT}220 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) {
226 if err := checked("vkGetImageDrmFormatModifierPropertiesEXT", d.GetImageDrmFormatModifierPropertiesEXT(r.device, t.image, &props)); err != nil {282 if err := checked("vkGetImageDrmFormatModifierPropertiesEXT", d.GetImageDrmFormatModifierPropertiesEXT(r.device, t.image, &props)); err != nil {
227 return nil, ports.DMABuf{}, err283 return nil, ports.DMABuf{}, err
228 }284 }
229- var layout vk.SubresourceLayout285+ n := min(max(r.modPlanes[props.DrmFormatModifier], 1), maxModifierPlanes)
230- sub := vk.ImageSubresource{AspectMask: vk.ImageAspectMemoryPlane0BitEXT}286+ var layouts [maxModifierPlanes]vk.SubresourceLayout
231- d.GetImageSubresourceLayout(r.device, t.image, &sub, &layout)287+ for i := range n {
288+ sub := vk.ImageSubresource{AspectMask: vk.ImageAspectMemoryPlane0BitEXT << i}
289+ d.GetImageSubresourceLayout(r.device, t.image, &sub, &layouts[i])
290+ }
232 t.view, err = r.imageView(t.image, format)291 t.view, err = r.imageView(t.image, format)
233 if err != nil {292 if err != nil {
234 return nil, ports.DMABuf{}, err293 return nil, ports.DMABuf{}, err
@@ -238,11 +297,29 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) {
238 if err := checked("vkGetMemoryFdKHR", d.GetMemoryFdKHR(r.device, &get, &fd)); err != nil {297 if err := checked("vkGetMemoryFdKHR", d.GetMemoryFdKHR(r.device, &get, &fd)); err != nil {
239 return nil, ports.DMABuf{}, err298 return nil, ports.DMABuf{}, err
240 }299 }
241- f := os.NewFile(uintptr(fd), "neferwl-target")300+ // Every plane owns its file: the planes share the one memory object,
242- if f == nil {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 {
243 return nil, ports.DMABuf{}, fmt.Errorf("invalid exported fd %d", fd)310 return nil, ports.DMABuf{}, fmt.Errorf("invalid exported fd %d", fd)
244 }311 }
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}
246 ok = true323 ok = true
247 return t, buf, nil324 return t, buf, nil
248 }325 }
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
3 import (3 import (
4 "image/color"4 "image/color"
5 "math"5 "math"
6+ "slices"
6 "testing"7 "testing"
7 8
8 "github.com/bnema/neferwl/internal/ports"9 "github.com/bnema/neferwl/internal/ports"
@@ -11,20 +12,24 @@ import (
11 12
12 // Frames drawn into exported targets reach their dmabufs: another renderer13 // Frames drawn into exported targets reach their dmabufs: another renderer
13 // importing a target (as KMS scans it out) sees the frame, and readback14 // 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.
15 func TestExportTargets(t *testing.T) {18 func TestExportTargets(t *testing.T) {
16 r, err := New(64, 32)19 r, err := New(64, 32)
17 if err != nil {20 if err != nil {
18 t.Skipf("Vulkan unavailable: %v", err)21 t.Skipf("Vulkan unavailable: %v", err)
19 }22 }
20 defer r.Close()23 defer r.Close()
21- bufs, err := r.ExportTargets(2, nil)24+ bufs, err := r.ExportTargets(2, nil, true)
22 if err != nil {25 if err != nil {
23 t.Skipf("no exportable targets: %v", err)26 t.Skipf("no exportable targets: %v", err)
24 }27 }
25 defer func() {28 defer func() {
26 for _, b := range bufs {29 for _, b := range bufs {
27- b.Planes[0].File.Close()30+ for _, p := range b.Planes {
31+ p.File.Close()
32+ }
28 }33 }
29 }()34 }()
30 if len(bufs) != 2 || bufs[0].Width != 64 || bufs[0].Format != fourccXRGB || bufs[0].Planes[0].Stride < 64*4 {35 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) {
83 if len(r.hdrMods) == 0 {88 if len(r.hdrMods) == 0 {
84 t.Skip("no HDR modifier supporting transfer-src")89 t.Skip("no HDR modifier supporting transfer-src")
85 }90 }
86- bufs, err := r.ExportTargets(1, r.hdrMods)91+ bufs, err := r.ExportTargets(1, r.hdrMods, false)
87 if err != nil {92 if err != nil {
88 t.Skipf("no HDR-exportable target: %v", err)93 t.Skipf("no HDR-exportable target: %v", err)
89 }94 }
@@ -140,12 +145,12 @@ func TestVirtualHDRExportsWithoutDisplayList(t *testing.T) {
140 t.Skip("no exportable HDR GPU")145 t.Skip("no exportable HDR GPU")
141 }146 }
142 r.SetHDR(203)147 r.SetHDR(203)
143- if bufs, err := r.ExportTargets(1, nil); err == nil {148+ if bufs, err := r.ExportTargets(1, nil, false); err == nil {
144 bufs[0].Planes[0].File.Close()149 bufs[0].Planes[0].File.Close()
145 t.Fatal("HDR export without a display list succeeded on a non-virtual output")150 t.Fatal("HDR export without a display list succeeded on a non-virtual output")
146 }151 }
147 r.SetVirtualOutput(true)152 r.SetVirtualOutput(true)
148- bufs, err := r.ExportTargets(1, nil)153+ bufs, err := r.ExportTargets(1, nil, false)
149 if err != nil {154 if err != nil {
150 t.Fatalf("virtual HDR export: %v", err)155 t.Fatalf("virtual HDR export: %v", err)
151 }156 }
@@ -167,3 +172,198 @@ func checkHDRPixel(t *testing.T, r *Renderer, rgb [3]float64) {
167 }172 }
168 }173 }
169 }174 }
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) {
56 t.Fatalf("popup escaped clip: %v", got)56 t.Fatalf("popup escaped clip: %v", got)
57 }57 }
58 // Popups above layers belong to output-wide controls, not the workspace.58 // 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.
59 scene.Seq++61 scene.Seq++
60- scene.Windows[0].OverLayers = true62+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true, OverLayers: true}}
61 if err := render(r, scene, contents); err != nil {63 if err := render(r, scene, contents); err != nil {
62 t.Fatal(err)64 t.Fatal(err)
63 }65 }
64 if got := readPixels(t, r).RGBAAt(10, 10); got != red {66 if got := readPixels(t, r).RGBAAt(10, 10); got != red {
65 t.Fatalf("output-wide popup clipped: %v", got)67 t.Fatalf("output-wide popup clipped: %v", got)
66 }68 }
67- scene.Windows[0].OverLayers = false69+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true}}
68 // Switching back to inherited geometry repaints the margins.70 // Switching back to inherited geometry repaints the margins.
69 scene.Seq++71 scene.Seq++
70 scene.WorkspaceClip = ports.Rect{}72 scene.WorkspaceClip = ports.Rect{}
M internal/adapters/wayland/surface.go +24 -3
@@ -393,7 +393,7 @@ func (s *surface) applyCommit(u *update) {
393 s.bufferScale = u.scale393 s.bufferScale = u.scale
394 }394 }
395 s.transform = u.transform395 s.transform = u.transform
396- oldOpaque := s.content.Opaque396+ oldOpaque, oldOpaqueRect := s.content.Opaque, s.content.OpaqueRect
397 if u.opaqueSet {397 if u.opaqueSet {
398 s.opaque = u.opaque398 s.opaque = u.opaque
399 }399 }
@@ -455,6 +455,7 @@ func (s *surface) applyCommit(u *update) {
455 c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height)455 c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height)
456 s.formatOpaque = c.Opaque456 s.formatOpaque = c.Opaque
457 c.Opaque = (c.Opaque || s.opaqueCovers(c.LogicalW, c.LogicalH)) && u.fade == 0457 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)
458 c.Fade = u.fade459 c.Fade = u.fade
459 resized := !s.has || c.Width != s.content.Width || c.Height != s.content.Height || c.LogicalW != s.content.LogicalW || c.LogicalH != s.content.LogicalH460 resized := !s.has || c.Width != s.content.Width || c.Height != s.content.Height || c.LogicalW != s.content.LogicalW || c.LogicalH != s.content.LogicalH
460 s.commitDamage(u, true, resized, c.Width, c.Height)461 s.commitDamage(u, true, resized, c.Width, c.Height)
@@ -484,13 +485,14 @@ func (s *surface) applyCommit(u *update) {
484 s.content.Source, _ = s.source(s.content.Width, s.content.Height)485 s.content.Source, _ = s.source(s.content.Width, s.content.Height)
485 s.content.LogicalW, s.content.LogicalH = s.logicalSize(s.content.Width, s.content.Height)486 s.content.LogicalW, s.content.LogicalH = s.logicalSize(s.content.Width, s.content.Height)
486 s.content.Opaque = (s.formatOpaque || s.opaqueCovers(s.content.LogicalW, s.content.LogicalH)) && u.fade == 0487 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)
487 s.content.Fade = u.fade489 s.content.Fade = u.fade
488 }490 }
489 }491 }
490 if s.current == nil {492 if s.current == nil {
491 s.content, s.has = ports.SurfaceContent{}, false493 s.content, s.has = ports.SurfaceContent{}, false
492 }494 }
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 {
494 s.version++496 s.version++
495 s.root().treeDirty = true497 s.root().treeDirty = true
496 }498 }
@@ -519,7 +521,7 @@ func (s *surface) applyCommit(u *update) {
519 }521 }
520 reshaped := s.has && (s.content.LogicalW != oldW || s.content.LogicalH != oldH || s.content.Source != oldSource || s.content.Transform != oldTransform)522 reshaped := s.has && (s.content.LogicalW != oldW || s.content.LogicalH != oldH || s.content.Source != oldSource || s.content.Transform != oldTransform)
521 // Opacity changes how every pixel blends, so it repaints the whole surface.523 // 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)
523 drawn := fresh || damaged || moved || geometry || hinted || reshaped || opacity || s.sub.parent != nil525 drawn := fresh || damaged || moved || geometry || hinted || reshaped || opacity || s.sub.parent != nil
524 if drawn {526 if drawn {
525 if moved || geometry || reshaped || opacity {527 if moved || geometry || reshaped || opacity {
@@ -623,6 +625,25 @@ func (s *surface) opaqueCovers(w, h int) bool {
623 return w > 0 && h > 0 && covers(s.opaque, ports.Rect{W: w, H: h})625 return w > 0 && h > 0 && covers(s.opaque, ports.Rect{W: w, H: h})
624 }626 }
625 627
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+
626 // maxCoverRects bounds the region covers checks: beyond it, not covered.647 // maxCoverRects bounds the region covers checks: beyond it, not covered.
627 const maxCoverRects = 16648 const maxCoverRects = 16
628 649
M internal/adapters/wayland/transform_test.go +42 -2
@@ -161,13 +161,16 @@ func TestOpaqueRegionContent(t *testing.T) {
161 if prev.Opaque {161 if prev.Opaque {
162 t.Fatalf("partial region made the surface opaque: %+v", prev)162 t.Fatalf("partial region made the surface opaque: %+v", prev)
163 }163 }
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+ }
164 // Two halves cover the whole surface, even through a larger rect.167 // Two halves cover the whole surface, even through a larger rect.
165 // Opacity changes blending everywhere: the commit damages it all.168 // Opacity changes blending everywhere: the commit damages it all.
166 whole := tc.region(t, ports.Rect{W: 15, H: 20}, ports.Rect{X: 15, W: 100, H: 20})169 whole := tc.region(t, ports.Rect{W: 15, H: 20}, ports.Rect{X: 15, W: 100, H: 20})
167 requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetOpaqueRegion, whole)170 requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetOpaqueRegion, whole)
168 tc.commit(t)171 tc.commit(t)
169 got := tc.next(t)172 got := tc.next(t)
170- if !got.Opaque {173+ if !got.Opaque || got.OpaqueRect != (ports.Rect{}) {
171 t.Fatalf("covering region: %+v", got)174 t.Fatalf("covering region: %+v", got)
172 }175 }
173 if rects, ok := got.DamageSince(prev.Seq); ok {176 if rects, ok := got.DamageSince(prev.Seq); ok {
@@ -186,9 +189,46 @@ func TestOpaqueRegionContent(t *testing.T) {
186 }189 }
187 requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetOpaqueRegion, uint32(0))190 requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetOpaqueRegion, uint32(0))
188 tc.commit(t)191 tc.commit(t)
189- if got := tc.next(t); got.Opaque {192+ if got := tc.next(t); got.Opaque || got.OpaqueRect != (ports.Rect{}) {
190 t.Fatalf("NULL region: %+v", got)193 t.Fatalf("NULL region: %+v", got)
191 }194 }
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+ }
192 }232 }
193 233
194 // Transform and opaque region requested after a queued commit belong to234 // 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)
294 if err != nil {294 if err != nil {
295 return nil, err295 return nil, err
296 }296 }
297+ r.SetLogger(renderLog)
297 renderLog.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")298 renderLog.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
298 return r, nil299 return r, nil
299 }300 }
M internal/app/app_test.go +54 -6
@@ -9,6 +9,7 @@ import (
9 "os/exec"9 "os/exec"
10 "path/filepath"10 "path/filepath"
11 "strings"11 "strings"
12+ "sync"
12 "testing"13 "testing"
13 "time"14 "time"
14 15
@@ -101,7 +102,13 @@ func TestQuitJoinsWorkers(t *testing.T) {
101 }102 }
102 }103 }
103 104
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) {
105 if _, err := exec.LookPath("weston-simple-shm"); err != nil {112 if _, err := exec.LookPath("weston-simple-shm"); err != nil {
106 t.Skip("weston-simple-shm not installed")113 t.Skip("weston-simple-shm not installed")
107 }114 }
@@ -109,7 +116,8 @@ func TestHeadlessSpawnClose(t *testing.T) {
109 t.Setenv("XDG_STATE_HOME", t.TempDir())116 t.Setenv("XDG_STATE_HOME", t.TempDir())
110 cfg := config.Defaults()117 cfg := config.Defaults()
111 cfg.Terminal.Command = []string{"weston-simple-shm"}118 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)
113 ctx, cancel := context.WithCancel(context.Background())121 ctx, cancel := context.WithCancel(context.Background())
114 defer cancel()122 defer cancel()
115 inputReady := make(chan chan<- ports.InputEvent, 1)123 inputReady := make(chan chan<- ports.InputEvent, 1)
@@ -144,6 +152,12 @@ func TestHeadlessSpawnClose(t *testing.T) {
144 case set := <-scenes:152 case set := <-scenes:
145 s := set[0]153 s := set[0]
146 if len(s.Windows) == count {154 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+ }
147 // A lone column fills the 1920 output (default zero gaps).161 // A lone column fills the 1920 output (default zero gaps).
148 if count == 1 && s.Windows[0].Rect.W != 1920 {162 if count == 1 && s.Windows[0].Rect.W != 1920 {
149 t.Errorf("width = %d, want 1920", s.Windows[0].Rect.W)163 t.Errorf("width = %d, want 1920", s.Windows[0].Rect.W)
@@ -214,7 +228,13 @@ func TestHeadlessTyping(t *testing.T) {
214 }228 }
215 }229 }
216 230
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) {
218 if _, err := exec.LookPath("foot"); err != nil {238 if _, err := exec.LookPath("foot"); err != nil {
219 t.Skip("foot unavailable")239 t.Skip("foot unavailable")
220 }240 }
@@ -225,17 +245,45 @@ func TestHeadlessPointerClickFocus(t *testing.T) {
225 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}245 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
226 // Pulse frames would fill the scene tap before the click.246 // Pulse frames would fill the scene tap before the click.
227 cfg.Focus.Animation = ports.FocusAnimationOff247 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.
228 scenes := make(chan []ports.Scene, 128)251 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})
230 if err != nil {274 if err != nil {
231 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {275 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
232 t.Skipf("Vulkan unavailable: %v", err)276 t.Skipf("Vulkan unavailable: %v", err)
233 }277 }
234 t.Fatal(err)278 t.Fatal(err)
235 }279 }
236- two, focused := false, false280+ stopCollector()
237 for len(scenes) > 0 {281 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]
239 if len(s.Windows) == 2 {287 if len(s.Windows) == 2 {
240 two = true288 two = true
241 for _, w := range s.Windows {289 for _, w := range s.Windows {
M internal/app/headless_test.go +2 -2
@@ -25,7 +25,7 @@ func TestVirtualRendererExportsHDRTargets(t *testing.T) {
25 }25 }
26 defer r.Close()26 defer r.Close()
27 // A device that cannot export at all says nothing about the wiring.27 // 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)
29 if err != nil {29 if err != nil {
30 t.Skipf("no exportable target: %v", err)30 t.Skipf("no exportable target: %v", err)
31 }31 }
@@ -35,7 +35,7 @@ func TestVirtualRendererExportsHDRTargets(t *testing.T) {
35 }35 }
36 }36 }
37 r.SetHDR(203)37 r.SetHDR(203)
38- bufs, err := r.ExportTargets(1, nil)38+ bufs, err := r.ExportTargets(1, nil, false)
39 if err != nil {39 if err != nil {
40 t.Fatalf("virtual HDR export: %v", err)40 t.Fatalf("virtual HDR export: %v", err)
41 }41 }
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
2 2
3 import (3 import (
4 "math/rand"4 "math/rand"
5+ "slices"
5 "testing"6 "testing"
7+ "time"
6 8
7 "github.com/bnema/neferwl/internal/ports"9 "github.com/bnema/neferwl/internal/ports"
8 )10 )
@@ -13,52 +15,154 @@ func TestBorderSceneInvariants(t *testing.T) {
13 for _, overflow := range []string{"fixed", "scroll"} {15 for _, overflow := range []string{"fixed", "scroll"} {
14 for _, gap := range []int{0, 6} {16 for _, gap := range []int{0, 6} {
15 t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {17 t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {
16- rng := rand.New(rand.NewSource(20260928))
17 r := startMulti(t, func(c *ports.Config) {18 r := startMulti(t, func(c *ports.Config) {
19+ c.Animations.On = false
18 c.Border.Width = 220 c.Border.Width = 2
19 c.Layout.MaxColumns = 321 c.Layout.MaxColumns = 3
20 c.Layout.Overflow = overflow22 c.Layout.Overflow = overflow
21 c.Layout.Gaps = gap23 c.Layout.Gaps = gap
22 }, left, right)24 }, left, right)
23- var ids []ports.WindowID25+ borderScenario(t, r, gap, nil)
24- next := ports.WindowID(1)26+ })
25- // A single keypress may publish a scene even when the action is27+ }
26- // inapplicable; check every resulting scene independently.28+ }
27- keys := []struct {29+}
28- sym string30+
29- mods ports.Mods31+// With animations on, the scene right after an action shows the old layout
30- }{32+// moving: only the weak invariants hold there. Once every spring settled
31- {"Left", ports.ModAlt | ports.ModShift}, {"Right", ports.ModAlt | ports.ModShift},33+// (the test moves the clock and sends the page flips) the full ones do,
32- {"Left", ports.ModAlt}, {"Right", ports.ModAlt},34+// whatever the actions that ran mid-transition. Insets and lit lines come
33- {"Left", ports.ModAlt | ports.ModCtrl}, {"Right", ports.ModAlt | ports.ModCtrl},35+// from the settled topology by design: separators follow the shown layout.
34- {"Left", ports.ModAlt | ports.ModCtrl | ports.ModShift}, {"Right", ports.ModAlt | ports.ModCtrl | ports.ModShift},36+func TestBorderSceneInvariantsAnimated(t *testing.T) {
35- {"bracketleft", ports.ModAlt}, {"bracketright", ports.ModAlt},37+ for _, overflow := range []string{"fixed", "scroll"} {
36- {"f", ports.ModAlt}, {"f", ports.ModAlt | ports.ModShift},38+ for _, gap := range []int{0, 6} {
37- {"s", ports.ModAlt}, {"S", ports.ModAlt | ports.ModShift}, {"Up", ports.ModAlt}, {"Down", ports.ModAlt},39+ t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {
38- }40+ r := startSwipeOn(t, func(c *ports.Config) {
39- for step := range 180 {41+ c.Animations.On = true
40- var scenes []ports.Scene42+ c.Border.Width = 2
41- switch {43+ c.Layout.MaxColumns = 3
42- case len(ids) < 2 || rng.Intn(5) == 0 && len(ids) < 12:44+ c.Layout.Overflow = overflow
43- scenes = r.mapWindow(t, next)45+ c.Layout.Gaps = gap
44- ids = append(ids, next)46+ }, left, right)
45- next++47+ moved := 0
46- case rng.Intn(9) == 0:48+ borderScenario(t, r.multiRig, gap, func(set []ports.Scene) []ports.Scene {
47- i := rng.Intn(len(ids))49+ settled, ok := r.settleAll(t, left.Name, right.Name)
48- r.client <- ports.WindowUnmapped{ID: ids[i]}50+ if !ok {
49- scenes = receive(t, r.scenes)51+ return set
50- ids = append(ids[:i], ids[i+1:]...)52+ }
51- default:53+ if !sameRects(set, settled) {
52- k := keys[rng.Intn(len(keys))]54+ moved++
53- scenes = r.key(t, k.sym, k.mods)
54 }55 }
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")
56 }64 }
65+ t.Logf("%d steps checked mid-transition and settled", moved)
57 })66 })
58 }67 }
59 }68 }
60 }69 }
61 70
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+
62 func checkBorderScenes(t *testing.T, step, gap int, scenes []ports.Scene) {166 func checkBorderScenes(t *testing.T, step, gap int, scenes []ports.Scene) {
63 t.Helper()167 t.Helper()
64 focused := 0168 focused := 0
M internal/core/capindicator_test.go +7 -1
@@ -12,10 +12,11 @@ import (
12 12
13 // indicatorClock is a clock the test moves; its timers never fire by13 // indicatorClock is a clock the test moves; its timers never fire by
14 // themselves: the test calls captureFlashTick, and the durations asked for14 // 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.
16 type indicatorClock struct {16 type indicatorClock struct {
17 now time.Time17 now time.Time
18 timers []time.Duration18 timers []time.Duration
19+ resets []time.Duration
19 }20 }
20 21
21 func indicatorCore(t *testing.T) (*Core, *indicatorClock) {22 func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
@@ -28,6 +29,10 @@ func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
28 timer := portsmocks.NewMockTimer(t)29 timer := portsmocks.NewMockTimer(t)
29 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()30 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
30 timer.EXPECT().Stop().Return(true).Maybe()31 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()
31 return timer36 return timer
32 }).Maybe()37 }).Maybe()
33 var cfg ports.Config38 var cfg ports.Config
@@ -193,6 +198,7 @@ func TestCaptureIndicatorHiddenWorkspacePill(t *testing.T) {
193 timer := portsmocks.NewMockTimer(t)198 timer := portsmocks.NewMockTimer(t)
194 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()199 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
195 timer.EXPECT().Stop().Return(true).Maybe()200 timer.EXPECT().Stop().Return(true).Maybe()
201+ timer.EXPECT().Reset(mock.Anything).Return(false).Maybe()
196 clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()202 clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()
197 c.ch.Clock = clock203 c.ch.Clock = clock
198 c.configures.cw.reset()204 c.configures.cw.reset()
M internal/core/capture_workspace_test.go +2 -2
@@ -193,7 +193,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
193 c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}}193 c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}}
194 c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}194 c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
195 c.popupOrder = append(c.popupOrder, 10)195 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)
197 ctx := context.Background()197 ctx := context.Background()
198 if err := c.publish(ctx); err != nil {198 if err := c.publish(ctx); err != nil {
199 t.Fatal(err)199 t.Fatal(err)
@@ -207,7 +207,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
207 if v, ok := c.configures.sent[1]; !ok || !v.Captured || v.Visible || v.Activated || v.Width <= 0 {207 if v, ok := c.configures.sent[1]; !ok || !v.Captured || v.Visible || v.Activated || v.Width <= 0 {
208 t.Fatalf("window during the slide %+v (sent %v)", v, ok)208 t.Fatalf("window during the slide %+v (sent %v)", v, ok)
209 }209 }
210- ws.motion = nil210+ ws.motion = motion{}
211 if err := c.publish(ctx); err != nil {211 if err := c.publish(ctx); err != nil {
212 t.Fatal(err)212 t.Fatal(err)
213 }213 }
M internal/core/configures.go +14 -0
@@ -130,6 +130,20 @@ func (s *configures) mark(v ports.ConfigureWindow) {
130 delete(s.answer, v.ID)130 delete(s.answer, v.ID)
131 }131 }
132 132
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+
133 // prune forgets the windows no next call saw since the last prune: they147 // prune forgets the windows no next call saw since the last prune: they
134 // left the layout.148 // left the layout.
135 func (s *configures) prune() {149 func (s *configures) prune() {
M internal/core/core.go +158 -55
@@ -67,6 +67,11 @@ type binding struct {
67 key string67 key string
68 }68 }
69 type Core struct {69 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
70 security ports.SecurityState75 security ports.SecurityState
71 lockSurfaces []ports.LockSurfacePlacement76 lockSurfaces []ports.LockSurfacePlacement
72 lockFocus WindowID77 lockFocus WindowID
@@ -126,10 +131,15 @@ type Core struct {
126 // (ports.UserActivity), sent at most once per ActivityInterval.131 // (ports.UserActivity), sent at most once per ActivityInterval.
127 activity time.Time132 activity time.Time
128 // swipe is the touchpad swipe in progress. frameC fires when no page133 // 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).134+ // flip came in time to move a running slide; nil when not armed. It is
130- swipe *swipeGesture135+ // the channel of frameTimer, made once and reset per frame: an
131- frameC <-chan time.Time136+ // animation frame allocates no timer.
132- frameStop func() bool137+ 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
133 // motionTime is the time of the last pointer motion; pointerAt is the143 // motionTime is the time of the last pointer motion; pointerAt is the
134 // last position sent in the pointer's window.144 // last position sent in the pointer's window.
135 motionTime time.Duration145 motionTime time.Duration
@@ -355,14 +365,26 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
355 365
356 // newTimer makes a timer on clock, or the system timer when clock is nil.366 // newTimer makes a timer on clock, or the system timer when clock is nil.
357 func newTimer(clock ports.Clock, d time.Duration) (<-chan time.Time, func() bool) {367 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 {
358 if clock != nil {375 if clock != nil {
359- t := clock.NewTimer(d)376+ return clock.NewTimer(d)
360- return t.C(), t.Stop
361 }377 }
362- t := time.NewTimer(d)378+ return sysTimer{time.NewTimer(d)}
363- return t.C, t.Stop
364 }379 }
365 380
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+
366 func (c *Core) now() time.Time {388 func (c *Core) now() time.Time {
367 if c.ch.Clock != nil {389 if c.ch.Clock != nil {
368 return c.ch.Clock.Now()390 return c.ch.Clock.Now()
@@ -405,21 +427,20 @@ func onScreen(p Placement, o Rect) bool {
405 427
406 // floatDim is the veil opacity of a layout: dim only when a float is428 // floatDim is the veil opacity of a layout: dim only when a float is
407 // drawn above the tiles, never for a demoted covering float alone nor for429 // 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.
409 func floatDim(layout []Placement, o Rect, dim float64) float64 {434 func floatDim(layout []Placement, o Rect, dim float64) float64 {
410- shown := false
411 for _, p := range layout {435 for _, p := range layout {
412 if p.Floating && !p.Below && p.Preview == 0 && onScreen(p, o) {436 if p.Floating && !p.Below && p.Preview == 0 && onScreen(p, o) {
413 if p.Fullscreen {437 if p.Fullscreen {
414 return 0438 return 0
415 }439 }
416- shown = true440+ return dim
417 }441 }
418 }442 }
419- if !shown {443+ return 0
420- return 0
421- }
422- return dim
423 }444 }
424 445
425 // visible reports whether the window, layer surface or popup is on screen446 // visible reports whether the window, layer surface or popup is on screen
@@ -491,7 +512,13 @@ func (c *Core) setLayers(all []ports.LayerSurface) {
491 }512 }
492 }513 }
493 514
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 {
495 if c.inputEpochChanged() {522 if c.inputEpochChanged() {
496 return errSecurityChanged523 return errSecurityChanged
497 }524 }
@@ -505,6 +532,7 @@ func (c *Core) publish(ctx context.Context) error {
505 sc.mon.Current().settleLeft()532 sc.mon.Current().settleLeft()
506 }533 }
507 }534 }
535+ c.refreshShown()
508 c.captureExpire()536 c.captureExpire()
509 capture, err := c.captureEvaluate(ctx)537 capture, err := c.captureEvaluate(ctx)
510 if err != nil {538 if err != nil {
@@ -530,17 +558,20 @@ func (c *Core) publish(ctx context.Context) error {
530 focus := c.keyboardFocus()558 focus := c.keyboardFocus()
531 window, _ := c.cur().mon.Focused()559 window, _ := c.cur().mon.Focused()
532 c.pulseFocus(window, focus == window)560 c.pulseFocus(window, focus == window)
533- var pulse float64561+ // The pulse runs on the focused output: a flip of another output reuses
534- if c.pulse.id != 0 {562+ // its value so the focused scene keeps its Seq.
535- pulse = c.advancePulse(c.now())563+ if c.pulse.id == 0 {
536- }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
537 // drawable: the scene can show a pulse on the focused window.569 // drawable: the scene can show a pulse on the focused window.
538 drawable := false570 drawable := false
539 scenes := make([]ports.Scene, 0, len(c.screens))571 scenes := make([]ports.Scene, 0, len(c.screens))
540 // Before the first output (and after the last is unplugged) the572 // Before the first output (and after the last is unplugged) the
541 // placeholder's scene has no output name: no renderer draws it.573 // placeholder's scene has no output name: no renderer draws it.
542 for i, sc := range c.screens {574 for i, sc := range c.screens {
543- c.seq++
544 o := sc.mon.Output()575 o := sc.mon.Output()
545 // frame is the viewport of the workspace on screen: the whole output576 // frame is the viewport of the workspace on screen: the whole output
546 // unless it has a size override (never in the overview).577 // unless it has a size override (never in the overview).
@@ -549,13 +580,21 @@ func (c *Core) publish(ctx context.Context) error {
549 if frame != (Rect{W: o.W, H: o.H}) {580 if frame != (Rect{W: o.W, H: o.H}) {
550 clip = frame581 clip = frame
551 }582 }
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)}583+ // layout is what is drawn; settled (same indexes) is where the
553- layout := sc.mon.Layout()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)}
554 var real map[WindowID]Placement587 var real map[WindowID]Placement
555 if sc.mon.ov.open {588 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
557 for w := range sc.mon.all() {595 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 {
559 real[p.ID] = p598 real[p.ID] = p
560 }599 }
561 }600 }
@@ -569,21 +608,42 @@ func (c *Core) publish(ctx context.Context) error {
569 if d := c.drag; d != nil && d.target.screen == sc && d.target.kind != dropNone {608 if d := c.drag; d != nil && d.target.screen == sc && d.target.kind != dropNone {
570 scene.DropHints = slices.Clone(d.target.hints)609 scene.DropHints = slices.Clone(d.target.hints)
571 }610 }
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)
574 if sc.mon.ov.open {611 if sc.mon.ov.open {
575 // Frame the selection and separate numbered and named row groups.612 // Frame the selection and separate numbered and named row groups.
576 _, scene.Separators = sc.mon.overviewRows()613 _, scene.Separators = sc.mon.overviewRows()
577 scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)614 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)
578 }618 }
579 // A window alone on screen needs no pulse to show it has the focus.619 // A window alone on screen needs no pulse to show it has the focus.
580 alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1620 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]
582 // Only the focused output has an activated window.623 // Only the focused output has an activated window.
583 focused := p.Focused && i == c.focusScreen624 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}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}
585- if p.Peek {626+ if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
586- sw.Dim = c.cfg.Stash.Dim627+ // 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
587 }647 }
588 if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {648 if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
589 drawable = true649 drawable = true
@@ -595,7 +655,7 @@ func (c *Core) publish(ctx context.Context) error {
595 t := configureTarget{output: sc.name(), area: frame, focused: focused, captured: capture != nil && capture.window == p.ID}655 t := configureTarget{output: sc.name(), area: frame, focused: focused, captured: capture != nil && capture.window == p.ID}
596 if !p.Hidden && p.Preview == 0 {656 if !p.Hidden && p.Preview == 0 {
597 // Only a sized configure needs the client size.657 // 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)
599 } else if t.captured && p.Hidden {659 } else if t.captured && p.Hidden {
600 // A captured hidden window is sized like its capture.660 // A captured hidden window is sized like its capture.
601 t.client = capture.windowSz661 t.client = capture.windowSz
@@ -605,8 +665,8 @@ func (c *Core) publish(ctx context.Context) error {
605 t.client = c.clientRect(rp)665 t.client = c.clientRect(rp)
606 }666 }
607 }667 }
608- cp, t := c.captureConfigure(sc, p, t)668+ cp, t := c.captureConfigure(sc, ps, t)
609- if v, send := c.configures.nextWithCapture(p, t, cp); send {669+ if v, send := c.configures.nextWithCapture(ps, t, cp); send {
610 if err := c.command(ctx, v); err != nil {670 if err := c.command(ctx, v); err != nil {
611 return err671 return err
612 }672 }
@@ -615,7 +675,23 @@ func (c *Core) publish(ctx context.Context) error {
615 }675 }
616 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)676 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
617 scene.CaptureIndicators = c.captureIndicators(sc)677 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 {
619 switch sc {695 switch sc {
620 case capture.hiddenScr:696 case capture.hiddenScr:
621 scene.CaptureScene = c.captureScene(scene.Seq)697 scene.CaptureScene = c.captureScene(scene.Seq)
@@ -623,6 +699,7 @@ func (c *Core) publish(ctx context.Context) error {
623 scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)699 scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)
624 }700 }
625 }701 }
702+ sc.last = scene
626 scenes = append(scenes, scene)703 scenes = append(scenes, scene)
627 }704 }
628 c.configures.prune()705 c.configures.prune()
@@ -651,7 +728,7 @@ func (c *Core) publish(ctx context.Context) error {
651 return errSecurityChanged728 return errSecurityChanged
652 }729 }
653 if c.syncSecurity() {730 if c.syncSecurity() {
654- return c.publish(ctx)731+ return c.publishFrame(ctx, only)
655 }732 }
656 latest(c.ch.Scenes, scenes)733 latest(c.ch.Scenes, scenes)
657 c.publishState()734 c.publishState()
@@ -668,20 +745,41 @@ func (c *Core) publish(ctx context.Context) error {
668 return nil745 return nil
669 }746 }
670 747
671-// step moves the running slides to now and publishes the frame.748+// step moves the running slides of only (nil: every screen) to now and
672-func (c *Core) step(ctx context.Context) error {749+// publishes the frame.
673- c.stopFrame()750+func (c *Core) step(ctx context.Context, only *screen) error {
674- c.animate(c.now())751+ // The fallback timer guards every animating output: a flip of one keeps
675- return c.slid(ctx, false)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
676 }769 }
677 770
678 // slid publishes a moved view; shown reports another workspace came on771 // slid publishes a moved view; shown reports another workspace came on
679 // screen.772 // screen.
680 func (c *Core) slid(ctx context.Context, shown bool) error {773 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 {
681 if err := c.workspaceVisible(ctx, shown); err != nil {779 if err := c.workspaceVisible(ctx, shown); err != nil {
682 return err780 return err
683 }781 }
684- if err := c.publish(ctx); err != nil {782+ if err := c.publishFrame(ctx, only); err != nil {
685 return err783 return err
686 }784 }
687 return c.rehit(ctx)785 return c.rehit(ctx)
@@ -788,7 +886,7 @@ func (c *Core) shownClient(id WindowID) (full, visible Rect, ok bool) {
788 if s == nil {886 if s == nil {
789 return Rect{}, Rect{}, false887 return Rect{}, Rect{}, false
790 }888 }
791- for _, p := range s.mon.Layout() {889+ for _, p := range s.shownLayout() {
792 if p.ID != id || p.Hidden || p.Preview > 0 {890 if p.ID != id || p.Hidden || p.Preview > 0 {
793 continue891 continue
794 }892 }
@@ -938,7 +1036,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
938 }1036 }
939 var id WindowID1037 var id WindowID
940 var sx, sy float641038 var sx, sy float64
941- for _, p := range sc.mon.Layout() {1039+ for _, p := range sc.shownLayout() {
942 r := c.clientRect(p)1040 r := c.clientRect(p)
943 // A peek is clickable wherever it shows, border included: it may1041 // A peek is clickable wherever it shows, border included: it may
944 // be narrower than its border. The point is clamped to its client.1042 // be narrower than its border. The point is clamped to its client.
@@ -1029,12 +1127,9 @@ func (c *Core) Run(ctx context.Context) error {
1029 case ports.InputRegionChanged:1127 case ports.InputRegionChanged:
1030 c.windows.setRegion(v)1128 c.windows.setRegion(v)
1031 case ports.WindowMapped:1129 case ports.WindowMapped:
1032- c.windows.mapped(v, c.now())1130+ c.mapWindow(v)
1033- c.placement.place(c, v)
1034 case ports.WindowResized:1131 case ports.WindowResized:
1035- if _, w := c.screenOf(v.ID); w != nil {1132+ c.resizeFloating(v)
1036- w.ResizeFloating(v.ID, v.Width, v.Height)
1037- }
1038 case ports.PopupRequest:1133 case ports.PopupRequest:
1039 if err := c.placePopup(ctx, v); err != nil {1134 if err := c.placePopup(ctx, v); err != nil {
1040 return nil1135 return nil
@@ -1076,9 +1171,7 @@ func (c *Core) Run(ctx context.Context) error {
1076 if c.drag != nil && c.drag.id == v.ID {1171 if c.drag != nil && c.drag.id == v.ID {
1077 c.abortDrag()1172 c.abortDrag()
1078 }1173 }
1079- if s, _ := c.screenOf(v.ID); s != nil {1174+ c.unmapWindow(v)
1080- s.mon.RemoveWindow(v.ID)
1081- }
1082 c.releaseSlots()1175 c.releaseSlots()
1083 if c.pointer == v.ID {1176 if c.pointer == v.ID {
1084 c.pointer = 01177 c.pointer = 0
@@ -1109,6 +1202,13 @@ func (c *Core) Run(ctx context.Context) error {
1109 }1202 }
1110 if s, _ := c.screenOf(v.ID); s != nil {1203 if s, _ := c.screenOf(v.ID); s != nil {
1111 s.mon.SetFullscreen(v.ID, v.Fullscreen)1204 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+ }
1112 }1212 }
1113 case ports.WorkspaceActivate:1213 case ports.WorkspaceActivate:
1114 before := c.cur().mon.Current()1214 before := c.cur().mon.Current()
@@ -1145,7 +1245,7 @@ func (c *Core) Run(ctx context.Context) error {
1145 c.ch.Frames = nil1245 c.ch.Frames = nil
1146 continue1246 continue
1147 }1247 }
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 {
1149 return nil1249 return nil
1150 }1250 }
1151 continue1251 continue
@@ -1153,8 +1253,8 @@ func (c *Core) Run(ctx context.Context) error {
1153 if c.securityCheckpoint(ctx) != nil {1253 if c.securityCheckpoint(ctx) != nil {
1154 return nil1254 return nil
1155 }1255 }
1156- c.frameC, c.frameStop = nil, nil1256+ c.frameC = nil
1157- if c.step(ctx) != nil {1257+ if c.step(ctx, nil) != nil {
1158 return nil1258 return nil
1159 }1259 }
1160 continue1260 continue
@@ -1217,6 +1317,9 @@ func (c *Core) Run(ctx context.Context) error {
1217 }1317 }
1218 continue1318 continue
1219 }1319 }
1320+ if !c.animOn() {
1321+ c.stopAnimations()
1322+ }
1220 // A rotation can shrink the layout under a still pointer.1323 // A rotation can shrink the layout under a still pointer.
1221 c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)1324 c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
1222 if c.workspaceVisible(ctx, false) != nil {1325 if c.workspaceVisible(ctx, false) != nil {
M internal/core/core_test.go +176 -49
@@ -5,13 +5,11 @@ import (
5 "errors"5 "errors"
6 "reflect"6 "reflect"
7 "slices"7 "slices"
8- "sync/atomic"
9 "testing"8 "testing"
10 "time"9 "time"
11 10
12 "github.com/bnema/neferwl/internal/adapters/config"11 "github.com/bnema/neferwl/internal/adapters/config"
13 "github.com/bnema/neferwl/internal/core"12 "github.com/bnema/neferwl/internal/core"
14- portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
15 "github.com/bnema/neferwl/internal/ports"13 "github.com/bnema/neferwl/internal/ports"
16 )14 )
17 15
@@ -46,7 +44,56 @@ func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
46 }44 }
47 }45 }
48 46
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) {
50 cfg := altCmdDefaults()97 cfg := altCmdDefaults()
51 cfg.Border.Width = 098 cfg.Border.Width = 0
52 cfg.Layout.Gaps = 899 cfg.Layout.Gaps = 8
@@ -58,7 +105,9 @@ func TestOwner(t *testing.T) {
58 spawn := make(chan ports.SpawnRequest, 8)105 spawn := make(chan ports.SpawnRequest, 8)
59 scenes := make(chan []ports.Scene, 1)106 scenes := make(chan []ports.Scene, 1)
60 errs := make(chan error, 8)107 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)
62 if err != nil {111 if err != nil {
63 t.Fatal(err)112 t.Fatal(err)
64 }113 }
@@ -72,7 +121,7 @@ func TestOwner(t *testing.T) {
72 t.Fatal(s)121 t.Fatal(s)
73 }122 }
74 client <- ports.WindowMapped{ID: 1}123 client <- ports.WindowMapped{ID: 1}
75- s = scene(t, scenes)124+ s = l.one(scene(t, scenes))
76 // A single column fills the usable width.125 // A single column fills the usable width.
77 if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 {126 if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 {
78 t.Fatal(s)127 t.Fatal(s)
@@ -117,7 +166,7 @@ func TestOwner(t *testing.T) {
117 }166 }
118 cfg.Layout.Gaps = 4167 cfg.Layout.Gaps = 4
119 reload <- ports.ConfigChanged{Config: cfg}168 reload <- ports.ConfigChanged{Config: cfg}
120- s = scene(t, scenes)169+ s = l.one(scene(t, scenes))
121 if s.Windows[0].Rect.W != 92 {170 if s.Windows[0].Rect.W != 92 {
122 t.Fatal(s)171 t.Fatal(s)
123 }172 }
@@ -283,7 +332,9 @@ func TestPointerFocusAndGrab(t *testing.T) {
283 }332 }
284 }333 }
285 334
286-func TestBorderInset(t *testing.T) {335+func TestBorderInset(t *testing.T) { both(t, borderInset) }
336+
337+func borderInset(t *testing.T, animated bool) {
287 cfg := config.Defaults()338 cfg := config.Defaults()
288 cfg.Border.Width = 2339 cfg.Border.Width = 2
289 client := make(chan ports.ClientEvent, 8)340 client := make(chan ports.ClientEvent, 8)
@@ -291,7 +342,9 @@ func TestBorderInset(t *testing.T) {
291 output := make(chan ports.OutputEvent, 8)342 output := make(chan ports.OutputEvent, 8)
292 commands := make(chan ports.ClientCommand, 64)343 commands := make(chan ports.ClientCommand, 64)
293 scenes := make(chan []ports.Scene, 1)344 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)
295 if err != nil {348 if err != nil {
296 t.Fatal(err)349 t.Fatal(err)
297 }350 }
@@ -302,14 +355,14 @@ func TestBorderInset(t *testing.T) {
302 scene(t, scenes)355 scene(t, scenes)
303 // Alone, the window is borderless and gets the full rect.356 // Alone, the window is borderless and gets the full rect.
304 client <- ports.WindowMapped{ID: 1}357 client <- ports.WindowMapped{ID: 1}
305- r := scene(t, scenes).Windows[0].Rect358+ r := l.one(scene(t, scenes)).Windows[0].Rect
306 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1", Visible: true}) {359 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1", Visible: true}) {
307 t.Fatalf("%v for outer %v", v, r)360 t.Fatalf("%v for outer %v", v, r)
308 }361 }
309 // With a second column, the left window owns the shared separator: its362 // With a second column, the left window owns the shared separator: its
310 // client is 2px narrower on the right; the right window keeps its rect.363 // client is 2px narrower on the right; the right window keeps its rect.
311 client <- ports.WindowMapped{ID: 2}364 client <- ports.WindowMapped{ID: 2}
312- s := scene(t, scenes)365+ s := l.one(scene(t, scenes))
313 r = s.Windows[1].Rect366 r = s.Windows[1].Rect
314 if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 {367 if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 {
315 t.Fatal(s)368 t.Fatal(s)
@@ -383,14 +436,46 @@ func TestLayerKeyboardFocus(t *testing.T) {
383 }436 }
384 }437 }
385 438
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) {
387 cfg := config.Defaults()464 cfg := config.Defaults()
465+ cfg.Animations.On = animated
388 client := make(chan ports.ClientEvent, 8)466 client := make(chan ports.ClientEvent, 8)
389 input := make(chan ports.InputEvent, 8)467 input := make(chan ports.InputEvent, 8)
390 output := make(chan ports.OutputEvent, 8)468 output := make(chan ports.OutputEvent, 8)
391 commands := make(chan ports.ClientCommand, 64)469 commands := make(chan ports.ClientCommand, 64)
392 scenes := make(chan []ports.Scene, 1)470 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)
394 if err != nil {479 if err != nil {
395 t.Fatal(err)480 t.Fatal(err)
396 }481 }
@@ -406,7 +491,7 @@ func TestWorkspaceSwitch(t *testing.T) {
406 }491 }
407 // AZERTY: the 2 key prints eacute; the default bind is on its physical key (code 3).492 // AZERTY: the 2 key prints eacute; the default bind is on its physical key (code 3).
408 input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Keycode: 3, Mods: ports.ModSuper, Pressed: true}493 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)
410 if len(s.Windows) != 1 || !s.Windows[0].Hidden {495 if len(s.Windows) != 1 || !s.Windows[0].Hidden {
411 t.Fatal(s)496 t.Fatal(s)
412 }497 }
@@ -425,20 +510,32 @@ func TestWorkspaceSwitch(t *testing.T) {
425 }510 }
426 // A new window opens on workspace 2, alone and borderless.511 // A new window opens on workspace 2, alone and borderless.
427 client <- ports.WindowMapped{ID: 2}512 client <- ports.WindowMapped{ID: 2}
428- s = scene(t, scenes)513+ s = settledScene(t, sc, frames, scenes, scene(t, scenes), false)
429 if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || s.Windows[1].Inset != 0 {514 if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || s.Windows[1].Inset != 0 {
430 t.Fatal(s)515 t.Fatal(s)
431 }516 }
432 }517 }
433 518
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) {
435 cfg := config.Defaults()524 cfg := config.Defaults()
525+ cfg.Animations.On = animated
436 client := make(chan ports.ClientEvent, 8)526 client := make(chan ports.ClientEvent, 8)
437 input := make(chan ports.InputEvent, 8)527 input := make(chan ports.InputEvent, 8)
438 output := make(chan ports.OutputEvent, 8)528 output := make(chan ports.OutputEvent, 8)
439 commands := make(chan ports.ClientCommand, 64)529 commands := make(chan ports.ClientCommand, 64)
440 scenes := make(chan []ports.Scene, 1)530 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)
442 if err != nil {539 if err != nil {
443 t.Fatal(err)540 t.Fatal(err)
444 }541 }
@@ -458,7 +555,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) {
458 }555 }
459 // Cmd+2 hides window 1; the pointer must leave it.556 // Cmd+2 hides window 1; the pointer must leave it.
460 input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}557 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)
462 var cleared bool559 var cleared bool
463 for len(commands) > 0 {560 for len(commands) > 0 {
464 if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 {561 if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 {
@@ -472,7 +569,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) {
472 input <- ports.PointerButton{Button: 0x110, Pressed: true}569 input <- ports.PointerButton{Button: 0x110, Pressed: true}
473 input <- ports.PointerButton{Button: 0x110}570 input <- ports.PointerButton{Button: 0x110}
474 client <- ports.WindowMapped{ID: 2}571 client <- ports.WindowMapped{ID: 2}
475- s := scene(t, scenes)572+ s := settledScene(t, sc, frames, scenes, scene(t, scenes), false)
476 if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden {573 if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden {
477 t.Fatal(s)574 t.Fatal(s)
478 }575 }
@@ -512,7 +609,9 @@ func TestShiftReleasedFirst(t *testing.T) {
512 }609 }
513 }610 }
514 611
515-func TestOutputScale(t *testing.T) {612+func TestOutputScale(t *testing.T) { both(t, outputScale) }
613+
614+func outputScale(t *testing.T, animated bool) {
516 cfg := config.Defaults()615 cfg := config.Defaults()
517 cfg.Border.Width = 0616 cfg.Border.Width = 0
518 cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}}617 cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}}
@@ -524,7 +623,9 @@ func TestOutputScale(t *testing.T) {
524 reload := make(chan ports.ConfigChanged, 1)623 reload := make(chan ports.ConfigChanged, 1)
525 commands := make(chan ports.ClientCommand, 64)624 commands := make(chan ports.ClientCommand, 64)
526 scenes := make(chan []ports.Scene, 1)625 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)
528 if err != nil {629 if err != nil {
529 t.Fatal(err)630 t.Fatal(err)
530 }631 }
@@ -541,7 +642,7 @@ func TestOutputScale(t *testing.T) {
541 t.Fatal(v)642 t.Fatal(v)
542 }643 }
543 client <- ports.WindowMapped{ID: 1}644 client <- ports.WindowMapped{ID: 1}
544- scene(t, scenes)645+ l.one(scene(t, scenes))
545 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2", Visible: true}) {646 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2", Visible: true}) {
546 t.Fatal(v)647 t.Fatal(v)
547 }648 }
@@ -550,7 +651,13 @@ func TestOutputScale(t *testing.T) {
550 }651 }
551 // The pointer arrives in global logical pixels.652 // The pointer arrives in global logical pixels.
552 input <- ports.PointerMotion{X: 500, Y: 200}653 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}) {
554 t.Fatal(v)661 t.Fatal(v)
555 }662 }
556 // Cmd+- steps down through the clean scales of 5120x2160: 2 → 5/3.663 // Cmd+- steps down through the clean scales of 5120x2160: 2 → 5/3.
@@ -635,7 +742,9 @@ func TestPointerWarp(t *testing.T) {
635 }742 }
636 }743 }
637 744
638-func TestPointerConstraint(t *testing.T) {745+func TestPointerConstraint(t *testing.T) { both(t, pointerConstraint) }
746+
747+func pointerConstraint(t *testing.T, animated bool) {
639 cfg := config.Defaults()748 cfg := config.Defaults()
640 cfg.Border.Width = 0749 cfg.Border.Width = 0
641 cfg.Layout.Gaps = 0750 cfg.Layout.Gaps = 0
@@ -645,7 +754,9 @@ func TestPointerConstraint(t *testing.T) {
645 commands := make(chan ports.ClientCommand, 64)754 commands := make(chan ports.ClientCommand, 64)
646 scenes := make(chan []ports.Scene, 1)755 scenes := make(chan []ports.Scene, 1)
647 constraints := make(chan ports.PointerConstraint, 1)756 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)
649 if err != nil {760 if err != nil {
650 t.Fatal(err)761 t.Fatal(err)
651 }762 }
@@ -659,7 +770,7 @@ func TestPointerConstraint(t *testing.T) {
659 input <- ports.PointerMotion{X: 150, Y: 40}770 input <- ports.PointerMotion{X: 150, Y: 40}
660 scene(t, scenes) // the pointer moves the focus to B771 scene(t, scenes) // the pointer moves the focus to B
661 client <- ports.WindowMapped{ID: 1}772 client <- ports.WindowMapped{ID: 1}
662- r := receive(t, scenes)[1].Windows[0].Rect773+ r := l.land(receive(t, scenes))[1].Windows[0].Rect
663 // The confine region is window-local and clipped to the window.774 // The confine region is window-local and clipped to the window.
664 client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 10, Y: 10, W: 1000, H: 20}}}775 client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 10, Y: 10, W: 1000, H: 20}}}
665 scene(t, scenes)776 scene(t, scenes)
@@ -843,7 +954,9 @@ func TestDialogOverFullscreenHit(t *testing.T) {
843 // A window that asks for fullscreen as it maps (Wine at a remembered954 // A window that asks for fullscreen as it maps (Wine at a remembered
844 // monitor size) stays in its column: the windows it opens next are seen.955 // monitor size) stays in its column: the windows it opens next are seen.
845 // A later request is honoured.956 // 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) {
847 cfg := config.Defaults()960 cfg := config.Defaults()
848 cfg.Layout.Overflow = "fixed"961 cfg.Layout.Overflow = "fixed"
849 cfg.Focus.Animation = ports.FocusAnimationOff962 cfg.Focus.Animation = ports.FocusAnimationOff
@@ -851,11 +964,15 @@ func TestFullscreenAtMapIgnored(t *testing.T) {
851 output := make(chan ports.OutputEvent, 8)964 output := make(chan ports.OutputEvent, 8)
852 commands := make(chan ports.ClientCommand, 64)965 commands := make(chan ports.ClientCommand, 64)
853 scenes := make(chan []ports.Scene, 1)966 scenes := make(chan []ports.Scene, 1)
854- // Core reads the time on its goroutine.967+ // Core reads the time on its goroutine: the clock is the test's.
855- var now atomic.Int64968+ ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}
856- clock := portsmocks.NewMockClock(t)969+ l := newLanding(t, animated, &cfg, &ch, "OUT-1")
857- clock.EXPECT().Now().RunAndReturn(func() time.Time { return time.Unix(now.Load(), 0) })970+ clk := l.clk
858- c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock})971+ if clk == nil {
972+ clk = newStepClock(t)
973+ ch.Clock = clk.clock
974+ }
975+ c, err := core.New(cfg, ch)
859 if err != nil {976 if err != nil {
860 t.Fatal(err)977 t.Fatal(err)
861 }978 }
@@ -879,7 +996,10 @@ func TestFullscreenAtMapIgnored(t *testing.T) {
879 t.Fatalf("startup fullscreen applied: %+v", s.Windows)996 t.Fatalf("startup fullscreen applied: %+v", s.Windows)
880 }997 }
881 }998 }
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)
883 // Only the focused window may cover the screen (ADR 011).1003 // Only the focused window may cover the screen (ADR 011).
884 client <- ports.WindowUnmapped{ID: 3}1004 client <- ports.WindowUnmapped{ID: 3}
885 scene(t, scenes)1005 scene(t, scenes)
@@ -924,16 +1044,21 @@ func TestFixedFullscreenArrivalWaits(t *testing.T) {
924 }1044 }
925 1045
926 // A taskbar's fullscreen request is the user's: it applies at once.1046 // 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) {
928 cfg := config.Defaults()1050 cfg := config.Defaults()
929 cfg.Focus.Animation = ports.FocusAnimationOff1051 cfg.Focus.Animation = ports.FocusAnimationOff
930 client := make(chan ports.ClientEvent, 8)1052 client := make(chan ports.ClientEvent, 8)
931 output := make(chan ports.OutputEvent, 8)1053 output := make(chan ports.OutputEvent, 8)
932 commands := make(chan ports.ClientCommand, 64)1054 commands := make(chan ports.ClientCommand, 64)
933 scenes := make(chan []ports.Scene, 1)1055 scenes := make(chan []ports.Scene, 1)
934- clock := portsmocks.NewMockClock(t)1056+ ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}
935- clock.EXPECT().Now().Return(time.Unix(0, 0))1057+ l := newLanding(t, animated, &cfg, &ch, "OUT-1")
936- c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock})1058+ if l.clk == nil {
1059+ ch.Clock = newStepClock(t).clock
1060+ }
1061+ c, err := core.New(cfg, ch)
937 if err != nil {1062 if err != nil {
938 t.Fatal(err)1063 t.Fatal(err)
939 }1064 }
@@ -943,7 +1068,7 @@ func TestExternalFullscreenAtMapApplies(t *testing.T) {
943 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}1068 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
944 scene(t, scenes)1069 scene(t, scenes)
945 client <- ports.WindowMapped{ID: 1}1070 client <- ports.WindowMapped{ID: 1}
946- scene(t, scenes)1071+ l.one(scene(t, scenes))
947 client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}1072 client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
948 s := scene(t, scenes)1073 s := scene(t, scenes)
949 if len(s.Windows) != 1 || !s.Windows[0].Fullscreen {1074 if len(s.Windows) != 1 || !s.Windows[0].Fullscreen {
@@ -1022,18 +1147,20 @@ func TestExternalFullscreenInOverview(t *testing.T) {
1022 // focus-window-down from a fullscreen tile at the bottom of its column1147 // focus-window-down from a fullscreen tile at the bottom of its column
1023 // goes to the next workspace and fullscreen stays.1148 // goes to the next workspace and fullscreen stays.
1024 func TestFocusWindowDownKeepsFullscreen(t *testing.T) {1149 func TestFocusWindowDownKeepsFullscreen(t *testing.T) {
1025- r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)1150+ both(t, func(t *testing.T, animated bool) {
1026- r.mapWindow(t, 1)1151+ r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
1027- r.mapWindow(t, 2)1152+ r.mapLanded(t, 1)
1028- r.key(t, "f", ports.ModAlt|ports.ModShift)1153+ r.mapLanded(t, 2)
1029- set := r.key(t, "Down", ports.ModAlt)1154+ r.keyLanded(t, "f", ports.ModAlt|ports.ModShift)
1030- if got, _ := windowsOf(set, "DP-1"); len(got) != 0 {1155+ set := r.keyLanded(t, "Down", ports.ModAlt)
1031- t.Fatal("still on the first workspace:", got)1156+ if got, _ := windowsOf(set, "DP-1"); len(got) != 0 {
1032- }1157+ t.Fatal("still on the first workspace:", got)
1033- set = r.key(t, "Up", ports.ModAlt)1158+ }
1034- if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 {1159+ set = r.keyLanded(t, "Up", ports.ModAlt)
1035- t.Fatal("fullscreen lost:", got, focused)1160+ if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 {
1036- }1161+ t.Fatal("fullscreen lost:", got, focused)
1162+ }
1163+ })
1037 }1164 }
1038 1165
1039 // A refused fullscreen request still gets a configure, unchanged: clients1166 // 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 {
228 }228 }
229 229
230 // endDrag drops the window on release: a tile lands on its target.230 // 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.
231 func (c *Core) endDrag(ctx context.Context) error {238 func (c *Core) endDrag(ctx context.Context) error {
232 d := c.drag239 d := c.drag
233 c.drag = nil240 c.drag = nil
234 if !d.float {241 if !d.float {
235 if t := c.dropAt(d.id, c.cursorX, c.cursorY); t.kind != dropNone {242 if t := c.dropAt(d.id, c.cursorX, c.cursorY); t.kind != dropNone {
243+ now := c.now()
244+ shots := c.snapshot(now)
236 c.drop(d.id, t)245 c.drop(d.id, t)
246+ c.transition(shots, now)
237 // Slots and guests follow the windows, as after a bind.247 // Slots and guests follow the windows, as after a bind.
238 if err := c.workspaceVisible(ctx, false); err != nil {248 if err := c.workspaceVisible(ctx, false); err != nil {
239 return err249 return err
M internal/core/drag_test.go +216 -10
@@ -21,11 +21,37 @@ type dragRig struct {
21 scenes chan []ports.Scene21 scenes chan []ports.Scene
22 last []ports.Scene22 last []ports.Scene
23 epoch *atomic.Value23 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
24 }32 }
25 33
26 // startDrag runs core with Super as Cmd, no border, 10 px gaps, and34 // startDrag runs core with Super as Cmd, no border, 10 px gaps, and
27 // the given outputs. edit may change the config.35 // the given outputs. edit may change the config.
28 func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {36 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 {
29 t.Helper()55 t.Helper()
30 cfg := config.Defaults()56 cfg := config.Defaults()
31 cfg.Border.Width = 057 cfg.Border.Width = 0
@@ -45,7 +71,18 @@ func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn
45 output: make(chan ports.OutputEvent), commands: make(chan ports.ClientCommand, 4096),71 output: make(chan ports.OutputEvent), commands: make(chan ports.ClientCommand, 4096),
46 scenes: make(chan []ports.Scene, 1), epoch: epoch,72 scenes: make(chan []ports.Scene, 1), epoch: epoch,
47 }73 }
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)
49 if err != nil {86 if err != nil {
50 t.Fatal(err)87 t.Fatal(err)
51 }88 }
@@ -55,6 +92,7 @@ func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn
55 t.Cleanup(func() { cancel(); <-done })92 t.Cleanup(func() { cancel(); <-done })
56 for _, o := range outs {93 for _, o := range outs {
57 r.output <- ports.OutputAdded{Info: o}94 r.output <- ports.OutputAdded{Info: o}
95+ r.outputs = append(r.outputs, o.Name)
58 r.settle(t)96 r.settle(t)
59 }97 }
60 return r98 return r
@@ -79,9 +117,16 @@ func send[T any](ch chan T, v T) {
79 const barrier ports.WindowID = 1 << 30117 const barrier ports.WindowID = 1 << 30
80 118
81 // settle returns the scene set once core handled every event sent so119 // 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.
83 func (r *dragRig) settle(t *testing.T) []ports.Scene {123 func (r *dragRig) settle(t *testing.T) []ports.Scene {
84 t.Helper()124 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+ }
85 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))130 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
86 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))131 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
87 select {132 select {
@@ -255,7 +300,12 @@ func TestDragEscapeRestoresFloat(t *testing.T) {
255 }300 }
256 301
257 func TestDragTileZonesAndHints(t *testing.T) {302 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) {
259 for id := ports.WindowID(1); id <= 3; id++ {309 for id := ports.WindowID(1); id <= 3; id++ {
260 r.client <- ports.WindowMapped{ID: id}310 r.client <- ports.WindowMapped{ID: id}
261 r.settle(t)311 r.settle(t)
@@ -279,6 +329,8 @@ func TestDragTileZonesAndHints(t *testing.T) {
279 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {329 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
280 t.Fatal(got)330 t.Fatal(got)
281 }331 }
332+ // The drop itself must run a spring: the guard counts from here.
333+ r.lands = 0
282 r.in(ports.PointerButton{Button: 0x110})334 r.in(ports.PointerButton{Button: 0x110})
283 set = r.settle(t)335 set = r.settle(t)
284 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {336 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{2}, {3, 1}}, slices.Equal) {
@@ -298,6 +350,8 @@ func TestDragTileZonesAndHints(t *testing.T) {
298 if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 4 {350 if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 4 {
299 t.Fatalf("swap outline %+v", hints)351 t.Fatalf("swap outline %+v", hints)
300 }352 }
353+ // The drop itself must run a spring: the guard counts from here.
354+ r.lands = 0
301 r.in(ports.PointerButton{Button: 0x110})355 r.in(ports.PointerButton{Button: 0x110})
302 set = r.settle(t)356 set = r.settle(t)
303 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {357 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) {
@@ -310,6 +364,8 @@ func TestDragTileZonesAndHints(t *testing.T) {
310 r.in(ports.PointerButton{Button: 0x110, Pressed: true})364 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
311 r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 6)365 r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 6)
312 r.settle(t)366 r.settle(t)
367+ // The drop itself must run a spring: the guard counts from here.
368+ r.lands = 0
313 r.in(ports.PointerButton{Button: 0x110})369 r.in(ports.PointerButton{Button: 0x110})
314 set = r.settle(t)370 set = r.settle(t)
315 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) {371 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {3}, {2}}, slices.Equal) {
@@ -332,8 +388,28 @@ func TestDragTileZonesAndHints(t *testing.T) {
332 }388 }
333 }389 }
334 390
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+
335 func TestDragTileGapInsert(t *testing.T) {406 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) {
337 for id := ports.WindowID(1); id <= 3; id++ {413 for id := ports.WindowID(1); id <= 3; id++ {
338 r.client <- ports.WindowMapped{ID: id}414 r.client <- ports.WindowMapped{ID: id}
339 r.settle(t)415 r.settle(t)
@@ -367,8 +443,114 @@ func TestDragTileGapInsert(t *testing.T) {
367 }443 }
368 }444 }
369 445
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+
370 func TestDragFixedNoGapZone(t *testing.T) {547 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) {
372 for id := ports.WindowID(1); id <= 2; id++ {554 for id := ports.WindowID(1); id <= 2; id++ {
373 r.client <- ports.WindowMapped{ID: id}555 r.client <- ports.WindowMapped{ID: id}
374 r.settle(t)556 r.settle(t)
@@ -385,6 +567,8 @@ func TestDragFixedNoGapZone(t *testing.T) {
385 // Top band of 2 stacks 1 above it.567 // Top band of 2 stacks 1 above it.
386 r.moveTo(float64(two.X+two.W/2), float64(two.Y+3), 3)568 r.moveTo(float64(two.X+two.W/2), float64(two.Y+3), 3)
387 r.settle(t)569 r.settle(t)
570+ // The drop itself must run a spring: the guard counts from here.
571+ r.lands = 0
388 r.in(ports.PointerButton{Button: 0x110})572 r.in(ports.PointerButton{Button: 0x110})
389 if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1, 2}}, slices.Equal) {573 if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1, 2}}, slices.Equal) {
390 t.Fatal(got)574 t.Fatal(got)
@@ -392,8 +576,12 @@ func TestDragFixedNoGapZone(t *testing.T) {
392 }576 }
393 577
394 func TestDragAcrossOutputs(t *testing.T) {578 func TestDragAcrossOutputs(t *testing.T) {
395- b := ports.OutputInfo{Name: "B", Width: 800, Height: 600}579+ both(t, func(t *testing.T, animated bool) {
396- r := startDragRig(t, nil, dragOut, b)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) {
397 r.client <- ports.WindowMapped{ID: 1}585 r.client <- ports.WindowMapped{ID: 1}
398 r.client <- ports.WindowMapped{ID: 2}586 r.client <- ports.WindowMapped{ID: 2}
399 r.settle(t)587 r.settle(t)
@@ -414,6 +602,7 @@ func TestDragAcrossOutputs(t *testing.T) {
414 if len(dragScene(set, "B").DropHints) == 0 || len(dragScene(set, "A").DropHints) != 0 {602 if len(dragScene(set, "B").DropHints) == 0 || len(dragScene(set, "A").DropHints) != 0 {
415 t.Fatalf("hints A %+v B %+v", dragScene(set, "A").DropHints, dragScene(set, "B").DropHints)603 t.Fatalf("hints A %+v B %+v", dragScene(set, "A").DropHints, dragScene(set, "B").DropHints)
416 }604 }
605+ r.lands = 0
417 r.in(ports.PointerButton{Button: 0x110})606 r.in(ports.PointerButton{Button: 0x110})
418 set = r.settle(t)607 set = r.settle(t)
419 if _, ok := windowIn(set, "B", 1); !ok {608 if _, ok := windowIn(set, "B", 1); !ok {
@@ -484,7 +673,10 @@ func TestLockClearsDrag(t *testing.T) {
484 }673 }
485 674
486 func TestDragEdgeScroll(t *testing.T) {675 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) {
488 for id := ports.WindowID(1); id <= 4; id++ {680 for id := ports.WindowID(1); id <= 4; id++ {
489 r.client <- ports.WindowMapped{ID: id}681 r.client <- ports.WindowMapped{ID: id}
490 r.settle(t)682 r.settle(t)
@@ -526,7 +718,12 @@ func TestDragEdgeScroll(t *testing.T) {
526 }718 }
527 719
528 func TestDragFullFixedStacksInOutlinedColumn(t *testing.T) {720 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) {
530 for id := ports.WindowID(1); id <= 3; id++ {727 for id := ports.WindowID(1); id <= 3; id++ {
531 r.client <- ports.WindowMapped{ID: id}728 r.client <- ports.WindowMapped{ID: id}
532 r.settle(t)729 r.settle(t)
@@ -542,6 +739,8 @@ func TestDragFullFixedStacksInOutlinedColumn(t *testing.T) {
542 r.press(t, 0x110, ports.ModSuper)739 r.press(t, 0x110, ports.ModSuper)
543 r.moveTo(float64(one.X+one.W/2), float64(one.Y+3), 2)740 r.moveTo(float64(one.X+one.W/2), float64(one.Y+3), 2)
544 r.settle(t)741 r.settle(t)
742+ // The drop itself must run a spring: the guard counts from here.
743+ r.lands = 0
545 r.in(ports.PointerButton{Button: 0x110})744 r.in(ports.PointerButton{Button: 0x110})
546 set = r.settle(t)745 set = r.settle(t)
547 if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4, 1}, {2}, {3}}, slices.Equal) {746 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) {
553 r.in(ports.PointerButton{Button: 0x110, Pressed: true})752 r.in(ports.PointerButton{Button: 0x110, Pressed: true})
554 r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 4)753 r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 4)
555 r.settle(t)754 r.settle(t)
755+ // The drop itself must run a spring: the guard counts from here.
756+ r.lands = 0
556 r.in(ports.PointerButton{Button: 0x110})757 r.in(ports.PointerButton{Button: 0x110})
557 if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4}, {2, 1}, {3}}, slices.Equal) {758 if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4}, {2, 1}, {3}}, slices.Equal) {
558 t.Fatal(got)759 t.Fatal(got)
@@ -623,7 +824,12 @@ func TestClientDragUsesLastPress(t *testing.T) {
623 }824 }
624 825
625 func TestEdgeScrollPublishesWithoutTarget(t *testing.T) {826 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) {
627 for id := ports.WindowID(1); id <= 4; id++ {833 for id := ports.WindowID(1); id <= 4; id++ {
628 r.client <- ports.WindowMapped{ID: id}834 r.client <- ports.WindowMapped{ID: id}
629 r.settle(t)835 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
21 }21 }
22 22
23 func TestFreeFloatKeepsPlaceAcrossMonitors(t *testing.T) {23 func TestFreeFloatKeepsPlaceAcrossMonitors(t *testing.T) {
24- r := startMulti(t, func(c *ports.Config) { c.Layout.Gaps = 0 }, left, right)24+ both(t, func(t *testing.T, animated bool) {
25- r.mapWindow(t, 1)25+ r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Gaps = 0 }, left, right)
26- r.mapWindow(t, 2)26+ r.mapLanded(t, 1)
27- r.key(t, "space", ports.ModAlt|ports.ModShift)27+ r.mapLanded(t, 2)
28- // Nudge left twice: 100 px left of centre on a 200 px output stops at28+ r.keyLanded(t, "space", ports.ModAlt|ports.ModShift)
29- // the edge.29+ // Nudge left twice: 100 px left of centre on a 200 px output stops at
30- r.key(t, "Left", ports.ModAlt|ports.ModShift)30+ // the edge.
31- set := r.key(t, "Left", ports.ModAlt|ports.ModShift)31+ r.keyLanded(t, "Left", ports.ModAlt|ports.ModShift)
32- before, ok := windowIn(set, "DP-1", 2)32+ set := r.keyLanded(t, "Left", ports.ModAlt|ports.ModShift)
33- if !ok || !before.Floating || before.Rect.X != 0 {33+ before, ok := windowIn(set, "DP-1", 2)
34- t.Fatalf("%+v", before)34+ if !ok || !before.Floating || before.Rect.X != 0 {
35- }35+ t.Fatalf("%+v", before)
36- // The workspace moves to the 400x200 output: the centre keeps its36+ }
37- // fractions of the usable area (1/4, 1/2), the size stays.37+ // The workspace moves to the 400x200 output: the centre keeps its
38- set = r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)38+ // fractions of the usable area (1/4, 1/2), the size stays.
39- after, ok := windowIn(set, "DP-2", 2)39+ set = r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
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 {40+ after, ok := windowIn(set, "DP-2", 2)
41- t.Fatalf("%+v", after)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- }42+ t.Fatalf("%+v", after)
43+ }
44+ })
43 }45 }
M internal/core/gesture.go +115 -22
@@ -36,7 +36,9 @@ const (
36 swipeWorkspaces36 swipeWorkspaces
37 swipeDiscrete37 swipeDiscrete
38 // swipeOverview is a vertical four-finger swipe: up opens the38 // 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.
40 swipeOverview42 swipeOverview
41 // swipeDropped ignores the rest of a swipe whose workspace changed.43 // swipeDropped ignores the rest of a swipe whose workspace changed.
42 swipeDropped44 swipeDropped
@@ -166,12 +168,14 @@ func (c *Core) decide(g *swipeGesture) {
166 case g.fingers == 4:168 case g.fingers == 4:
167 // Four fingers sideways do nothing.169 // Four fingers sideways do nothing.
168 g.mode = swipeDropped170 g.mode = swipeDropped
169- case m.ov.open:171+ case m.ov.open || !c.animOn():
170 // The overview does not slide: the swipe moves its selection.172 // 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.
171 g.mode, g.snap = swipeDiscrete, newStepSwipe()175 g.mode, g.snap = swipeDiscrete, newStepSwipe()
172 case g.horizontal && w.slidable():176 case g.horizontal && w.slidable():
173 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()177 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()
174- w.motion = nil178+ w.motion = motion{}
175 g.snap = newSnapSwipe(float64(w.ViewX)+w.shift, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W)))179 g.snap = newSnapSwipe(float64(w.ViewX)+w.shift, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W)))
176 case !g.horizontal && m.shown == nil:180 case !g.horizontal && m.shown == nil:
177 // A landing slide measured in an older list lands at once first.181 // A landing slide measured in an older list lands at once first.
@@ -180,7 +184,7 @@ func (c *Core) decide(g *swipeGesture) {
180 }184 }
181 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]185 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
182 g.list = slices.Clone(m.Workspaces)186 g.list = slices.Clone(m.Workspaces)
183- m.switchMotion, m.switchList = nil, nil187+ m.switchMotion, m.switchList = motion{}, nil
184 g.snap = newSnapSwipe(float64(m.Active)+m.switchOff, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)188 g.snap = newSnapSwipe(float64(m.Active)+m.switchOff, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)
185 default:189 default:
186 g.mode, g.snap = swipeDiscrete, newStepSwipe()190 g.mode, g.snap = swipeDiscrete, newStepSwipe()
@@ -216,7 +220,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
216 c.keyboard.takeBack()220 c.keyboard.takeBack()
217 }221 }
218 w.shift = shown - float64(w.ViewX)222 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+ }
220 case swipeWorkspaces:228 case swipeWorkspaces:
221 if g.listChanged(m) {229 if g.listChanged(m) {
222 m.stopSwitch()230 m.stopSwitch()
@@ -240,16 +248,28 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
240 c.keyboard.takeBack()248 c.keyboard.takeBack()
241 }249 }
242 m.switchOff, m.switchList = off, g.list250 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+ }
244 case swipeOverview:256 case swipeOverview:
245 step := g.snap.step(e.Cancelled, e.Time)257 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 {
247 return false265 return false
248 }266 }
249 before := m.Current()267 before := m.Current()
268+ shots := c.snapshot(now)
250 m.ToggleOverview()269 m.ToggleOverview()
251 c.focusScreen = c.screenIndex(g.screen.name())270 c.focusScreen = c.screenIndex(g.screen.name())
252 c.keyboard.takeBack()271 c.keyboard.takeBack()
272+ c.transition(shots, now)
253 return m.Current() != before273 return m.Current() != before
254 case swipeDiscrete:274 case swipeDiscrete:
255 step := g.snap.step(e.Cancelled, e.Time)275 step := g.snap.step(e.Cancelled, e.Time)
@@ -271,17 +291,36 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
271 return false291 return false
272 }292 }
273 before := c.cur().mon.Current()293 before := c.cur().mon.Current()
294+ shots := c.snapshot(now)
274 c.keyboard.takeBack()295 c.keyboard.takeBack()
275 if mon := c.cur().mon; mon.ov.open {296 if mon := c.cur().mon; mon.ov.open {
276 mon.overviewFocus(a)297 mon.overviewFocus(a)
277 } else {298 } else {
278 c.applyAction(a)299 c.applyAction(a)
279 }300 }
301+ c.transition(shots, now)
280 return c.cur().mon.Current() != before302 return c.cur().mon.Current() != before
281 }303 }
282 return shown304 return shown
283 }305 }
284 306
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+
285 // swipedWorkspace is the workspace on the swipe's screen, or nil without324 // swipedWorkspace is the workspace on the swipe's screen, or nil without
286 // a swipe.325 // a swipe.
287 func (c *Core) swipedWorkspace() *Workspace {326 func (c *Core) swipedWorkspace() *Workspace {
@@ -317,11 +356,49 @@ func (c *Core) hasScreen(s *screen) bool {
317 return false356 return false
318 }357 }
319 358
320-// animate moves the running springs to now; settled ones stop.359+// animOn reports whether transitions run (animations = on).
321-func (c *Core) animate(now time.Time) {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() {
322 for _, sc := range c.screens {374 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+ }
323 m := sc.mon400 m := sc.mon
324- if m.switchMotion != nil {401+ if m.switchMotion.on {
325 v, done := m.switchMotion.at(now)402 v, done := m.switchMotion.at(now)
326 m.switchOff = v403 m.switchOff = v
327 if done {404 if done {
@@ -329,7 +406,7 @@ func (c *Core) animate(now time.Time) {
329 }406 }
330 }407 }
331 for w := range m.all() {408 for w := range m.all() {
332- if w.motion != nil {409+ if w.motion.on {
333 v, done := w.motion.at(now)410 v, done := w.motion.at(now)
334 w.shift = v411 w.shift = v
335 if done {412 if done {
@@ -337,13 +414,21 @@ func (c *Core) animate(now time.Time) {
337 }414 }
338 }415 }
339 }416 }
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+ }
340 }425 }
341 }426 }
342 427
343 // sliding reports whether a spring runs on the named output.428 // sliding reports whether a spring runs on the named output.
344 func (c *Core) sliding(output string) bool {429 func (c *Core) sliding(output string) bool {
345 i := c.screenIndex(output)430 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]))
347 }432 }
348 433
349 // animating reports whether a spring or the focus pulse runs on any output.434 // animating reports whether a spring or the focus pulse runs on any output.
@@ -352,7 +437,7 @@ func (c *Core) animating() bool {
352 return true437 return true
353 }438 }
354 for _, sc := range c.screens {439 for _, sc := range c.screens {
355- if sc.mon.springing() {440+ if sc.springing() {
356 return true441 return true
357 }442 }
358 }443 }
@@ -360,11 +445,11 @@ func (c *Core) animating() bool {
360 }445 }
361 446
362 func (m *Monitor) springing() bool {447 func (m *Monitor) springing() bool {
363- if m.switchMotion != nil {448+ if m.switchMotion.on {
364 return true449 return true
365 }450 }
366 for w := range m.all() {451 for w := range m.all() {
367- if w.motion != nil {452+ if w.motion.on {
368 return true453 return true
369 }454 }
370 }455 }
@@ -376,22 +461,30 @@ func (m *Monitor) springing() bool {
376 func (c *Core) frameFallback() time.Duration {461 func (c *Core) frameFallback() time.Duration {
377 refresh := 60000462 refresh := 60000
378 for _, sc := range c.screens {463 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)) {
380 refresh = min(refresh, r)465 refresh = min(refresh, r)
381 }466 }
382 }467 }
383 return 2 * time.Second * 1000 / time.Duration(refresh)468 return 2 * time.Second * 1000 / time.Duration(refresh)
384 }469 }
385 470
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.
387 func (c *Core) armFrame() {474 func (c *Core) armFrame() {
388- c.stopFrame()475+ d := c.frameFallback()
389- c.frameC, c.frameStop = newTimer(c.ch.Clock, 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()
390 }483 }
391 484
392 func (c *Core) stopFrame() {485 func (c *Core) stopFrame() {
393- if c.frameStop != nil {486+ if c.frameC != nil {
394- c.frameStop()487+ c.frameTimer.Stop()
395 }488 }
396- c.frameC, c.frameStop = nil, nil489+ c.frameC = nil
397 }490 }
M internal/core/input.go +17 -0
@@ -125,8 +125,11 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
125 // A click focuses the window and its output.125 // A click focuses the window and its output.
126 s, w := c.screenOf(id)126 s, w := c.screenOf(id)
127 if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) {127 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)
128 w.Click(id)130 w.Click(id)
129 c.focusScreen = c.screenIndex(s.name())131 c.focusScreen = c.screenIndex(s.name())
132+ c.transition(before, now)
130 if err := c.publish(ctx); err != nil {133 if err := c.publish(ctx); err != nil {
131 return err134 return err
132 }135 }
@@ -140,7 +143,12 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
140 c.scrollStop(v)143 c.scrollStop(v)
141 // In the overview, scrolling moves the selection.144 // In the overview, scrolling moves the selection.
142 if c.cur().mon.ov.open && !c.overviewKeyboardTaken() {145 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)
143 if c.cur().mon.overviewScroll(v) {150 if c.cur().mon.overviewScroll(v) {
151+ c.transition(shots, now)
144 if err := c.workspaceVisible(ctx, true); err != nil {152 if err := c.workspaceVisible(ctx, true); err != nil {
145 return err153 return err
146 }154 }
@@ -189,8 +197,14 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
189 // run binds, and are not forwarded.197 // run binds, and are not forwarded.
190 // A launcher or a menu holding the keyboard gets them first.198 // A launcher or a menu holding the keyboard gets them first.
191 if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() {199 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+ }
192 if key.Pressed && mon.overviewKey(key) {205 if key.Pressed && mon.overviewKey(key) {
193 c.pressed[heldKey(key)] = true206 c.pressed[heldKey(key)] = true
207+ c.transition(shots, now)
194 if err := c.workspaceVisible(ctx, true); err != nil {208 if err := c.workspaceVisible(ctx, true); err != nil {
195 return err209 return err
196 }210 }
@@ -260,6 +274,8 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
260 }274 }
261 before := c.cur().mon.Current()275 before := c.cur().mon.Current()
262 swiped := c.swipedWorkspace()276 swiped := c.swipedWorkspace()
277+ now := c.now()
278+ shots := c.snapshot(now)
263 c.keyboard.takeBack() // a bind acts on the windows279 c.keyboard.takeBack() // a bind acts on the windows
264 effect := c.applyAction(action)280 effect := c.applyAction(action)
265 if effect.Quit {281 if effect.Quit {
@@ -280,6 +296,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
280 if c.swipedWorkspace() != swiped {296 if c.swipedWorkspace() != swiped {
281 c.dropSwipe()297 c.dropSwipe()
282 }298 }
299+ c.transition(shots, now)
283 if err := c.workspaceVisible(ctx, c.cur().mon.Current() != before); err != nil {300 if err := c.workspaceVisible(ctx, c.cur().mon.Current() != before); err != nil {
284 return err301 return err
285 }302 }
M internal/core/layout.go +112 -23
@@ -122,6 +122,18 @@ type Placement struct {
122 // Inset reserves room for drawn lines: all sides of a float; for122 // Inset reserves room for drawn lines: all sides of a float; for
123 // tiles without gaps, only the right and bottom shared sides.123 // tiles without gaps, only the right and bottom shared sides.
124 Neighbors, Inset ports.Sides124 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
125 }137 }
126 138
127 // Overflow says what happens past MaxColumns columns.139 // Overflow says what happens past MaxColumns columns.
@@ -154,7 +166,7 @@ type Workspace struct {
154 // shift slides the columns on screen past ViewX, in logical pixels,166 // shift slides the columns on screen past ViewX, in logical pixels,
155 // while a swipe follows the fingers or its spring (motion) lands.167 // while a swipe follows the fingers or its spring (motion) lands.
156 shift float64168 shift float64
157- motion *motion169+ motion motion
158 // Output is the effective viewport in monitor coordinates: the whole170 // Output is the effective viewport in monitor coordinates: the whole
159 // monitor unless the workspace has a size override, then a centered171 // monitor unless the workspace has a size override, then a centered
160 // rectangle no larger than the monitor. Fullscreen fills it.172 // rectangle no larger than the monitor. Fullscreen fills it.
@@ -204,6 +216,14 @@ type Workspace struct {
204 homePos int216 homePos int
205 // termAt is when core last spawned a terminal for this workspace.217 // termAt is when core last spawned a terminal for this workspace.
206 termAt time.Time218 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
207 }227 }
208 228
209 // origPlace remembers where a stashed window was: its column, its row in a229 // origPlace remembers where a stashed window was: its column, its row in a
@@ -242,6 +262,35 @@ func (w *Workspace) empty() bool {
242 return len(w.Columns) == 0 && len(w.Floats) == 0 && len(w.Stash) == 0262 return len(w.Columns) == 0 && len(w.Floats) == 0 && len(w.Stash) == 0
243 }263 }
244 264
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+
245 // windows lists every window: columns first, then the stash, then native294 // windows lists every window: columns first, then the stash, then native
246 // floats.295 // floats.
247 func (w *Workspace) windows() []WindowID {296 func (w *Workspace) windows() []WindowID {
@@ -1349,15 +1398,22 @@ func (w *Workspace) scroll() {
1349 1398
1350 // columnRects returns each column's area on screen, before stacking windows.1399 // columnRects returns each column's area on screen, before stacking windows.
1351 func (w *Workspace) columnRects() []Rect {1400 func (w *Workspace) columnRects() []Rect {
1352- return w.columnRectsFor(false)1401+ return w.columnRectsInto(nil, false)
1353 }1402 }
1354 1403
1355 // columnRectsFor shares the fixed layout geometry with the overview's hidden1404 // columnRectsFor shares the fixed layout geometry with the overview's hidden
1356 // columns card, without changing FullWidth or the clients' saved buffers.1405 // columns card, without changing FullWidth or the clients' saved buffers.
1357 func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {1406 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 {
1358 g := w.gap()1414 g := w.gap()
1359 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)1415 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)]
1361 view := w.ViewX + w.shiftPixels()1417 view := w.ViewX + w.shiftPixels()
1362 x := w.Usable.X + g1418 x := w.Usable.X + g
1363 for i := range w.Columns {1419 for i := range w.Columns {
@@ -1371,7 +1427,8 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
1371 }1427 }
1372 k := max(w.MaxColumns, 1)1428 k := max(w.MaxColumns, 1)
1373 if e := slices.IndexFunc(w.Columns, func(c Column) bool { return c.Expanded }); w.Overflow == OverflowFixed && e >= 0 && k > 1 && len(w.Columns) > 1 {1429 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)
1375 }1432 }
1376 if w.Overflow != OverflowFixed || len(w.Columns) <= k {1433 if w.Overflow != OverflowFixed || len(w.Columns) <= k {
1377 return rects1434 return rects
@@ -1388,7 +1445,7 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
1388 // expandedRects places expanded column e over k-1 cells in its place. The1445 // expandedRects places expanded column e over k-1 cells in its place. The
1389 // columns before it stack in a strip on its left, those after it on its1446 // columns before it stack in a strip on its left, those after it on its
1390 // right; the two strips share the last cell.1447 // 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 {
1392 g := w.gap()1449 g := w.gap()
1393 cell := max((w.Usable.W-g*(k+1))/k, 0)1450 cell := max((w.Usable.W-g*(k+1))/k, 0)
1394 wide := (k-1)*cell + (k-2)*g1451 wide := (k-1)*cell + (k-2)*g
@@ -1399,15 +1456,14 @@ func (w *Workspace) expandedRects(e, k, y, h int) []Rect {
1399 leftW = max((rest-g)/2, 0)1456 leftW = max((rest-g)/2, 0)
1400 rightW = max(rest-g-leftW, 0)1457 rightW = max(rest-g-leftW, 0)
1401 }1458 }
1402- rects := make([]Rect, 0, len(w.Columns))
1403 x := w.Usable.X + g1459 x := w.Usable.X + g
1404 if len(before) > 0 {1460 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)
1406 x += leftW + g1462 x += leftW + g
1407 }1463 }
1408 rects = append(rects, Rect{X: x, Y: y, W: wide, H: h})1464 rects = append(rects, Rect{X: x, Y: y, W: wide, H: h})
1409 if len(after) > 0 {1465 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)
1411 }1467 }
1412 // Gaps wider than a narrow output would push columns past its edge.1468 // Gaps wider than a narrow output would push columns past its edge.
1413 right := w.Usable.X + w.Usable.W1469 right := w.Usable.X + w.Usable.W
@@ -1422,7 +1478,15 @@ func (w *Workspace) expandedRects(e, k, y, h int) []Rect {
1422 // the rounding remainder. Rows past the bottom (gaps taller than r) are1478 // the rounding remainder. Rows past the bottom (gaps taller than r) are
1423 // clamped to it, like windows stacked in a column.1479 // clamped to it, like windows stacked in a column.
1424 func stackRects(r Rect, n, gap int) []Rect {1480 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:]
1426 avail := max(r.H-(n-1)*gap, 0)1490 avail := max(r.H-(n-1)*gap, 0)
1427 height := avail / n1491 height := avail / n
1428 yy, bottom := r.Y, r.Y+r.H1492 yy, bottom := r.Y, r.Y+r.H
@@ -1436,17 +1500,25 @@ func stackRects(r Rect, n, gap int) []Rect {
1436 rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}1500 rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}
1437 yy += hh + gap1501 yy += hh + gap
1438 }1502 }
1439- return rows1503+ return dst
1440 }1504 }
1441 1505
1442 // rowRects splits column rect r into the rows of c: by c.Shares when they1506 // rowRects splits column rect r into the rows of c: by c.Shares when they
1443 // are valid, else equally (stackRects).1507 // are valid, else equally (stackRects).
1444 func rowRects(r Rect, c Column, gap int) []Rect {1508 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 {
1445 n := len(c.Windows)1515 n := len(c.Windows)
1446 if !validShares(c) {1516 if !validShares(c) {
1447- return stackRects(r, n, gap)1517+ return stackRectsInto(dst, r, n, gap)
1448 }1518 }
1449- rows := make([]Rect, n)1519+ base := len(dst)
1520+ dst = slices.Grow(dst, n)[:base+n]
1521+ rows := dst[base:]
1450 avail := max(r.H-(n-1)*gap, 0)1522 avail := max(r.H-(n-1)*gap, 0)
1451 yy, bottom, used := r.Y, r.Y+r.H, 01523 yy, bottom, used := r.Y, r.Y+r.H, 0
1452 for i, share := range c.Shares {1524 for i, share := range c.Shares {
@@ -1460,7 +1532,7 @@ func rowRects(r Rect, c Column, gap int) []Rect {
1460 rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}1532 rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}
1461 yy += hh + gap1533 yy += hh + gap
1462 }1534 }
1463- return rows1535+ return dst
1464 }1536 }
1465 1537
1466 // validShares reports whether c.Shares has one share per window, each at1538 // 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) {
1489 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}1561 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}
1490 }1562 }
1491 1563
1492-func (w *Workspace) Layout() []Placement {1564+// Layout places every window of the workspace in a fresh slice the caller
1493- var result []Placement1565+// 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 {
1494 gap := w.gap()1576 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
1496 focusedID, _ := w.Focused()1582 focusedID, _ := w.Focused()
1497 cover := w.cover()1583 cover := w.cover()
1584+ tiles := w.tileBuf[:0]
1498 for i, c := range w.Columns {1585 for i, c := range w.Columns {
1499 col := cols[i]1586 col := cols[i]
1500 n := len(c.Windows)1587 n := len(c.Windows)
@@ -1505,7 +1592,8 @@ func (w *Workspace) Layout() []Placement {
1505 if fullColumn {1592 if fullColumn {
1506 col.W = w.Output.W1593 col.W = w.Output.W
1507 }1594 }
1508- rows := rowRects(col, c, gap)1595+ w.rowBuf = rowRectsInto(w.rowBuf[:0], col, c, gap)
1596+ rows := w.rowBuf
1509 for j, id := range c.Windows {1597 for j, id := range c.Windows {
1510 r := rows[j]1598 r := rows[j]
1511 full := w.fullscreen == id && id != 01599 full := w.fullscreen == id && id != 0
@@ -1523,14 +1611,15 @@ func (w *Workspace) Layout() []Placement {
1523 if hidden {1611 if hidden {
1524 r = Rect{}1612 r = Rect{}
1525 }1613 }
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})
1527 }1615 }
1528 }1616 }
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
1530 // The column/stash group sits between demoted covering floats and1620 // The column/stash group sits between demoted covering floats and
1531 // floats above it. Keep the order within each group stable.1621 // floats above it. Keep the order within each group stable.
1532- tiles := result1622+ result := dst
1533- result = nil
1534 const (1623 const (
1535 belowTiles = iota1624 belowTiles = iota
1536 coveringFloats1625 coveringFloats
@@ -1570,9 +1659,9 @@ func (w *Workspace) Layout() []Placement {
1570 // The stash the user showed over a covering window tops it, under1659 // The stash the user showed over a covering window tops it, under
1571 // the dialogs it may open meanwhile.1660 // the dialogs it may open meanwhile.
1572 appendFloats(coveringFloats)1661 appendFloats(coveringFloats)
1573- result = append(result, w.stashLayout(focusedID, cover)...)1662+ result = w.appendStash(result, focusedID, cover)
1574 } else {1663 } else {
1575- result = append(result, w.stashLayout(focusedID, cover)...)1664+ result = w.appendStash(result, focusedID, cover)
1576 appendFloats(coveringFloats)1665 appendFloats(coveringFloats)
1577 }1666 }
1578 appendFloats(dialogs)1667 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 {
24 shown *Workspace // hidden workspace on screen, nil for Workspaces[Active]24 shown *Workspace // hidden workspace on screen, nil for Workspaces[Active]
25 back *Workspace // where a named workspace toggle returns to25 back *Workspace // where a named workspace toggle returns to
26 template Workspace26 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
27 nextID *uint64 // shared by the monitors of one Core; owner goroutine only31 nextID *uint64 // shared by the monitors of one Core; owner goroutine only
28 named []NamedWorkspace32 named []NamedWorkspace
29 // followMove shows the target workspace after a move to it.33 // followMove shows the target workspace after a move to it.
@@ -34,7 +38,7 @@ type Monitor struct {
34 // at Active+switchOff while a swipe follows the fingers or its spring38 // at Active+switchOff while a swipe follows the fingers or its spring
35 // (switchMotion) lands.39 // (switchMotion) lands.
36 switchOff float6440 switchOff float64
37- switchMotion *motion41+ switchMotion motion
38 // switchList is the numbered list a landing slide measures from (the42 // switchList is the numbered list a landing slide measures from (the
39 // one its swipe began on); switchOff is then from the current43 // one its swipe began on); switchOff is then from the current
40 // workspace's place in it.44 // workspace's place in it.
@@ -157,6 +161,7 @@ func (m *Monitor) Current() *Workspace {
157 161
158 func (m *Monitor) newWorkspace() *Workspace {162 func (m *Monitor) newWorkspace() *Workspace {
159 w := m.template163 w := m.template
164+ w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil
160 w.presets = append([]Width(nil), m.template.presets...)165 w.presets = append([]Width(nil), m.template.presets...)
161 w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""166 w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""
162 w.overviewAfter = nil167 w.overviewAfter = nil
@@ -408,20 +413,24 @@ func (m *Monitor) Focused() (WindowID, bool) {
408 }413 }
409 414
410 // Layout places every window: the workspace on screen laid out, others hidden.415 // 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 {
412 if m.ov.open {423 if m.ov.open {
413- return m.overviewLayout()424+ return m.overviewLayout(dst[:0])
414 }425 }
415- var result []Placement426+ result := dst[:0]
416 cur := m.Current()427 cur := m.Current()
417 for w := range m.all() {428 for w := range m.all() {
418 if w == cur {429 if w == cur {
419- result = append(result, w.Layout()...)430+ result = w.appendLayout(result)
420 continue431 continue
421 }432 }
422- for _, id := range w.windows() {433+ result = w.appendHidden(result, true)
423- result = append(result, Placement{ID: id, Hidden: true})
424- }
425 }434 }
426 return m.slideLayout(result)435 return m.slideLayout(result)
427 }436 }
M internal/core/motion.go +112 -7
@@ -70,18 +70,57 @@ type spring struct {
70 DampingRatio float6470 DampingRatio float64
71 Stiffness float6471 Stiffness float64
72 Epsilon float6472 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
73 }80 }
74 81
75-// viewSpring moves a scrolled view; workspaceSpring switches workspaces.82+// pixelSnap is the Snap of a spring in logical pixels: offsets are rounded
76-// Both are critically damped: fast, with no overshoot.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.
77 func viewSpring(from, velocity float64) spring {92 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
79 }102 }
80 103
81 func workspaceSpring(from, velocity float64) spring {104 func workspaceSpring(from, velocity float64) spring {
82 return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 1000, Epsilon: 0.0001}105 return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 1000, Epsilon: 0.0001}
83 }106 }
84 107
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+
85 // f32Epsilon compares damping terms: float64's epsilon is too small for124 // f32Epsilon compares damping terms: float64's epsilon is too small for
86 // them (libadwaita, niri).125 // them (libadwaita, niri).
87 const f32Epsilon = 1.1920929e-07126 const f32Epsilon = 1.1920929e-07
@@ -150,16 +189,53 @@ func (s spring) duration() time.Duration {
150 189
151 func seconds(s float64) time.Duration { return time.Duration(s * float64(time.Second)) }190 func seconds(s float64) time.Duration { return time.Duration(s * float64(time.Second)) }
152 191
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+
153 // motion is a spring started at a given time; a zero start begins at the215 // 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.
155 type motion struct {219 type motion struct {
156 spring spring220 spring spring
157 start time.Time221 start time.Time
158 end time.Duration222 end time.Duration
223+ slow float64
224+ v float64
225+ on bool
159 }226 }
160 227
161-func newMotion(s spring, now time.Time) *motion {228+// newMotion starts s at now, slowed down slow times (slow <= 0 is 1). The
162- return &motion{spring: s, start: now, end: s.duration()}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}
163 }239 }
164 240
165 // at is the position at now and whether the motion has settled.241 // 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) {
169 }245 }
170 t := now.Sub(m.start)246 t := now.Sub(m.start)
171 if t >= m.end {247 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
172 return m.spring.To, true255 return m.spring.To, true
173 }256 }
174- return m.spring.valueAt(max(t, 0)), false257+ 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
175 }277 }
176 278
279+// velocity is the speed of the last sample, in units per second.
280+func (m motion) velocity() float64 { return m.v }
281+
177 // rubberBand resists movement past the edges: the further out, the less282 // rubberBand resists movement past the edges: the further out, the less
178 // it moves, never more than limit.283 // it moves, never more than limit.
179 type rubberBand struct{ stiffness, limit float64 }284 type rubberBand struct{ stiffness, limit float64 }
M internal/core/motion_test.go +147 -1
@@ -90,7 +90,7 @@ func TestSpringOverdamped(t *testing.T) {
90 90
91 func TestMotionEndsOnTarget(t *testing.T) {91 func TestMotionEndsOnTarget(t *testing.T) {
92 start := time.Unix(10, 0)92 start := time.Unix(10, 0)
93- m := newMotion(workspaceSpring(0.4, 0), start)93+ m := newMotion(workspaceSpring(0.4, 0), start, 1)
94 if v, done := m.at(start); done || v != 0.4 {94 if v, done := m.at(start); done || v != 0.4 {
95 t.Fatalf("start %v %t", v, done)95 t.Fatalf("start %v %t", v, done)
96 }96 }
@@ -366,3 +366,149 @@ func TestSnapFocusWideColumn(t *testing.T) {
366 }366 }
367 }367 }
368 }368 }
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) {
518 // overviewLayout places the current workspace and its overview neighbors,518 // overviewLayout places the current workspace and its overview neighbors,
519 // each with its full-width stash pile and covering-float stack. Other rows519 // each with its full-width stash pile and covering-float stack. Other rows
520 // stay hidden; numbered and named workspaces share the same preview geometry.520 // 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 {
522 cur := m.Current()522 cur := m.Current()
523 rows, _ := m.overviewRows()523 rows, _ := m.overviewRows()
524- var result []Placement
525 for w := range m.all() {524 for w := range m.all() {
526 ry, shown := rows[w]525 ry, shown := rows[w]
527 if shown {526 if shown {
@@ -535,11 +534,8 @@ func (m *Monitor) overviewLayout() []Placement {
535 }534 }
536 }535 }
537 if !shown {536 if !shown {
538- for _, id := range w.windows() {537+ // Columns and stash only: the floats were listed above.
539- if w.floatIndex(id) < 0 {538+ result = w.appendHidden(result, false)
540- result = append(result, Placement{ID: id, Hidden: true})
541- }
542- }
543 }539 }
544 }540 }
545 return result541 return result
@@ -664,7 +660,9 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
664 // overviewZoom is the scale that fits every column of w in the usable660 // overviewZoom is the scale that fits every column of w in the usable
665 // width, within the overview bounds.661 // width, within the overview bounds.
666 func (w *Workspace) overviewZoom() float64 {662 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])
668 if span <= 0 {666 if span <= 0 {
669 return overviewMaxZoom667 return overviewMaxZoom
670 }668 }
@@ -705,13 +703,23 @@ func (w *Workspace) rowHeight() int {
705 // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses703 // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses
706 // an unscrolled row. span is the row's width; sel is the focused column.704 // an unscrolled row. span is the row's width; sel is the focused column.
707 func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {705 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
708 if w.pinned() {715 if w.pinned() {
709 c := w.cover()716 c := w.cover()
710 // The row shows the columns a pinned fullscreen window hides,717 // The row shows the columns a pinned fullscreen window hides,
711 // laid out as without it; its own tile stays marked fullscreen.718 // laid out as without it; its own tile stays marked fullscreen.
712 v := *w719 v := *w
713 v.fullscreen = 0720 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
715 for i := range tiles {723 for i := range tiles {
716 tiles[i].Fullscreen = tiles[i].ID == c724 tiles[i].Fullscreen = tiles[i].ID == c
717 }725 }
@@ -721,7 +729,8 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
721 h := max(w.Usable.H-2*g, 0)729 h := max(w.Usable.H-2*g, 0)
722 var rects []Rect730 var rects []Rect
723 if w.Overflow == OverflowFixed {731 if w.Overflow == OverflowFixed {
724- rects = w.columnRects()732+ w.colBuf = w.columnRectsInto(w.colBuf, false)
733+ rects = w.colBuf
725 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.734 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
726 }735 }
727 maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth736 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) {
743 }752 }
744 continue753 continue
745 }754 }
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 {
747 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})757 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})
748 }758 }
749 if w.Overflow != OverflowFixed {759 if w.Overflow != OverflowFixed {
@@ -810,22 +820,26 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
810 return nil820 return nil
811 }821 }
812 822
813-// overviewAt is the preview under the output-local point, if any. Front823+// overviewClickFade is the fade from which a card takes no click.
814-// cards take priority over peeks, including peeks appended after a front824+const overviewClickFade = 0.5
815-// tile in another row's layout.825+
816-func (m *Monitor) overviewAt(x, y float64) WindowID {826+// overviewIn is the preview under the output-local point in layout, if
817- layout := m.Layout()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 {
818 for i := len(layout) - 1; i >= 0; i-- {832 for i := len(layout) - 1; i >= 0; i-- {
819 p := layout[i]833 p := layout[i]
820 r := p.Rect834 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) {
822 return p.ID836 return p.ID
823 }837 }
824 }838 }
825 for i := len(layout) - 1; i >= 0; i-- {839 for i := len(layout) - 1; i >= 0; i-- {
826 p := layout[i]840 p := layout[i]
827 r := p.Rect841 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) {
829 return p.ID843 return p.ID
830 }844 }
831 }845 }
@@ -843,11 +857,16 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) {
843 if !sc.mon.ov.open {857 if !sc.mon.ov.open {
844 return false, nil858 return false, nil
845 }859 }
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))
847 if id == 0 {863 if id == 0 {
848 return false, nil864 return false, nil
849 }865 }
866+ now := c.now()
867+ shots := c.snapshot(now)
850 sc.mon.OverviewPick(id)868 sc.mon.OverviewPick(id)
869+ c.transition(shots, now)
851 c.focusScreen = i870 c.focusScreen = i
852 c.keyboard.takeBack()871 c.keyboard.takeBack()
853 if err := c.workspaceVisible(ctx, true); err != nil {872 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) {
49 }49 }
50 50
51 func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) {51 func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) {
52- r := startMulti(t, nil, left, right)52+ both(t, func(t *testing.T, animated bool) {
53- r.mapWindow(t, 1)53+ overviewClickPicksOnPointerOutput(t, startLanding(t, animated, nil, left, right))
54- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)54+ })
55- r.mapWindow(t, 2)55+}
56- r.mapWindow(t, 3)56+
57- set := r.key(t, "o", ports.ModAlt)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)
58 p, ok := scenePreview(outputScene(t, set, "DP-2"), 2)64 p, ok := scenePreview(outputScene(t, set, "DP-2"), 2)
59 if !ok {65 if !ok {
60 t.Fatal("no preview of window 2 on DP-2")66 t.Fatal("no preview of window 2 on DP-2")
@@ -82,10 +88,17 @@ func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) {
82 }88 }
83 89
84 func TestOverviewCoreClickOutsidePreviewKeepsOverview(t *testing.T) {90 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) {
86 r.mapWindow(t, 1)97 r.mapWindow(t, 1)
87 r.mapWindow(t, 2)98 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)
89 for _, w := range set[0].Windows {102 for _, w := range set[0].Windows {
90 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 {103 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 {
91 t.Fatalf("(1, 1) is inside preview %+v", w)104 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) {
304 if p.Hidden {304 if p.Hidden {
305 t.Fatal("named stash neighbor hidden")305 t.Fatal("named stash neighbor hidden")
306 }306 }
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))
308 if id != 6 {308 if id != 6 {
309 t.Fatalf("stash hit: %d", id)309 t.Fatalf("stash hit: %d", id)
310 }310 }
M internal/core/overview_regression_test.go +1 -1
@@ -62,7 +62,7 @@ func TestOverviewSingleCoveringFloat(t *testing.T) {
62 }62 }
63 }63 }
64 if click {64 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))
66 if id != 9 {66 if id != 9 {
67 t.Fatalf("hit %d, want 9", id)67 t.Fatalf("hit %d, want 9", id)
68 }68 }
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
257 r := rects[i]257 r := rects[i]
258 r.X -= w.Usable.X258 r.X -= w.Usable.X
259 r.Y -= w.Usable.Y259 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 {
261 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == selected && j == c.Focus})262 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == selected && j == c.Focus})
262 }263 }
263 }264 }
@@ -287,7 +288,8 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
287 r.X = (w.Usable.W - r.W) / 2288 r.X = (w.Usable.W - r.W) / 2
288 r.Y = (w.Usable.H - r.H) / 2289 r.Y = (w.Usable.H - r.H) / 2
289 tiles := make([]Placement, 0, len(c.Windows))290 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 {
291 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()})293 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()})
292 }294 }
293 return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r)295 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) {
115 if frontColumn {115 if frontColumn {
116 x = front.Rect.X + front.Rect.W/2116 x = front.Rect.X + front.Rect.W/2
117 }117 }
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 {
119 t.Fatalf("overlap hit %d want %d (front %+v back %+v)", got, front.ID, front.Rect, back.Rect)119 t.Fatalf("overlap hit %d want %d (front %+v back %+v)", got, front.ID, front.Rect, back.Rect)
120 }120 }
121 // The up/right exposed corner belongs to the peeking card.121 // The up/right exposed corner belongs to the peeking card.
122 x, y = back.Rect.X+back.Rect.W-1, back.Rect.Y+1122 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 {
124 t.Fatalf("peek hit %d want %d", got, back.ID)124 t.Fatalf("peek hit %d want %d", got, back.ID)
125 }125 }
126 m.OverviewPick(back.ID)126 m.OverviewPick(back.ID)
M internal/core/overview_test.go +4 -4
@@ -116,7 +116,7 @@ func TestOverviewPick(t *testing.T) {
116 m := overviewMonitor()116 m := overviewMonitor()
117 m.ToggleOverview()117 m.ToggleOverview()
118 below := previewOf(t, m.Layout(), 4).Rect118 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))
120 if id != 4 {120 if id != 4 {
121 t.Fatalf("hit %d", id)121 t.Fatalf("hit %d", id)
122 }122 }
@@ -173,7 +173,7 @@ func TestOverviewFullscreenFloatPickAndCancel(t *testing.T) {
173 m := gameMonitor(OverflowFixed)173 m := gameMonitor(OverflowFixed)
174 m.ToggleOverview()174 m.ToggleOverview()
175 r := previewOf(t, m.Layout(), 2).Rect175 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 {
177 t.Fatalf("hit %d", id)177 t.Fatalf("hit %d", id)
178 }178 }
179 m.OverviewMove(0, 1)179 m.OverviewMove(0, 1)
@@ -412,7 +412,7 @@ func TestOverviewPilePick(t *testing.T) {
412 m := pileMonitor()412 m := pileMonitor()
413 m.ToggleOverview()413 m.ToggleOverview()
414 r := previewOf(t, m.Layout(), 7).Rect414 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))
416 if id != 7 {416 if id != 7 {
417 t.Fatalf("hit %d", id)417 t.Fatalf("hit %d", id)
418 }418 }
@@ -486,7 +486,7 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) {
486 t.Fatalf("%d overlaps %d", id, other)486 t.Fatalf("%d overlaps %d", id, other)
487 }487 }
488 }488 }
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 {
490 t.Fatalf("%d not hit at %+v", id, p.Rect)490 t.Fatalf("%d not hit at %+v", id, p.Rect)
491 }491 }
492 }492 }
M internal/core/persist_test.go +1 -0
@@ -28,6 +28,7 @@ func steppingClock(t *testing.T) *portsmocks.MockClock {
28 timer := portsmocks.NewMockTimer(t)28 timer := portsmocks.NewMockTimer(t)
29 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()29 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
30 timer.EXPECT().Stop().Return(true).Maybe()30 timer.EXPECT().Stop().Return(true).Maybe()
31+ timer.EXPECT().Reset(mock.Anything).Return(false).Maybe()
31 return timer32 return timer
32 }).Maybe()33 }).Maybe()
33 return clock34 return clock
M internal/core/popup.go +2 -2
@@ -144,7 +144,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
144 return sc, l.Rect, true144 return sc, l.Rect, true
145 }145 }
146 for _, sc := range c.screens {146 for _, sc := range c.screens {
147- for _, pl := range sc.mon.Layout() {147+ for _, pl := range sc.shownLayout() {
148 if pl.ID == id {148 if pl.ID == id {
149 // Hidden or scrolled off: not on screen, like its popups.149 // Hidden or scrolled off: not on screen, like its popups.
150 // A preview takes no input and shows no popups.150 // A preview takes no input and shows no popups.
@@ -161,7 +161,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
161 // peeking reports whether id is a stashed window peeking in on screen.161 // peeking reports whether id is a stashed window peeking in on screen.
162 func (c *Core) peeking(id WindowID) bool {162 func (c *Core) peeking(id WindowID) bool {
163 for _, sc := range c.screens {163 for _, sc := range c.screens {
164- for _, pl := range sc.mon.Layout() {164+ for _, pl := range sc.shownLayout() {
165 if pl.ID == id {165 if pl.ID == id {
166 return pl.Peek166 return pl.Peek
167 }167 }
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 (
32 // are the previous pulse, for the cooldown. drawable is whether the last32 // are the previous pulse, for the cooldown. drawable is whether the last
33 // scene could show a pulse on target.33 // scene could show a pulse on target.
34 type focusPulse struct {34 type focusPulse struct {
35- target WindowID35+ target WindowID
36- since time.Time36+ since time.Time
37- id WindowID37+ id WindowID
38- start time.Time38+ start time.Time
39- last WindowID39+ last WindowID
40- lastAt time.Time40+ lastAt time.Time
41- drawable bool41+ 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
42 timerC <-chan time.Time45 timerC <-chan time.Time
43 timerStop func() bool46 timerStop func() bool
44 }47 }
45 48
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+}
47 52
48 // pulseFocus follows the window focus. held is false while something else53 // pulseFocus follows the window focus. held is false while something else
49 // (a popup grab, a layer, a lock surface) has the keyboard: the running pulse54 // (a popup grab, a layer, a lock surface) has the keyboard: the running pulse
@@ -99,7 +104,7 @@ func (c *Core) pulseTick() bool {
99 // A swipe or its landing slide still shows the neighbours: decide on104 // A swipe or its landing slide still shows the neighbours: decide on
100 // the settled layout, or a window left alone would start a pulse cut105 // the settled layout, or a window left alone would start a pulse cut
101 // short.106 // short.
102- if !c.cur().mon.settled() {107+ if !c.cur().settled() {
103 p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseRecheck)108 p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseRecheck)
104 return false109 return false
105 }110 }
@@ -132,13 +137,14 @@ func (c *Core) advancePulse(now time.Time) float64 {
132 return 0137 return 0
133 }138 }
134 139
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).
136 func (m *Monitor) settled() bool {142 func (m *Monitor) settled() bool {
137- if m.switchMotion != nil || m.switchOff != 0 {143+ if m.switchMotion.on || m.switchOff != 0 {
138 return false144 return false
139 }145 }
140 for w := range m.all() {146 for w := range m.all() {
141- if w.motion != nil || w.shift != 0 {147+ if w.motion.on || w.shift != 0 {
142 return false148 return false
143 }149 }
144 }150 }
M internal/core/pulse_test.go +49 -7
@@ -12,6 +12,7 @@ import (
12 func pulseCore(t *testing.T) (*Core, *indicatorClock) {12 func pulseCore(t *testing.T) (*Core, *indicatorClock) {
13 t.Helper()13 t.Helper()
14 c, ic := indicatorCore(t)14 c, ic := indicatorCore(t)
15+ c.cfg.Animations.On = true
15 c.cfg.Focus.Animation = ports.FocusAnimationPulse16 c.cfg.Focus.Animation = ports.FocusAnimationPulse
16 c.cfg.Focus.Strength = 0.0517 c.cfg.Focus.Strength = 0.05
17 c.cur().mon.AddWindow(1)18 c.cur().mon.AddWindow(1)
@@ -184,30 +185,54 @@ func TestFocusPulseSkipsAloneOnScreen(t *testing.T) {
184 }185 }
185 }186 }
186 187
187-// A swipe or its landing slide still shows the neighbours: the pulse waits188+// A swipe, its landing slide or a transition still shows the neighbours: the
188-// for the layout to settle, then decides on it.189+// pulse waits for the layout to settle, then decides on it.
189 func TestFocusPulseWaitsForSlide(t *testing.T) {190 func TestFocusPulseWaitsForSlide(t *testing.T) {
190 for _, tc := range []struct {191 for _, tc := range []struct {
191 name string192 name string
192- slide func(*Monitor, time.Time)193+ slide func(*testing.T, *Core, time.Time)
193 alone bool194 alone bool
194 }{195 }{
195- {"workspace landing, alone", func(m *Monitor, now time.Time) { m.switchMotion = newMotion(workspaceSpring(0.5, 0), now) }, true},196+ {"workspace landing, alone", func(_ *testing.T, c *Core, now time.Time) {
196- {"workspace drag, alone", func(m *Monitor, _ time.Time) { m.switchOff = 0.3 }, true},197+ c.cur().mon.switchMotion = newMotion(workspaceSpring(0.5, 0), now, 1)
197- {"column drag, beside another", func(m *Monitor, _ time.Time) { m.Current().shift = 40 }, false},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},
198 } {221 } {
199 t.Run(tc.name, func(t *testing.T) {222 t.Run(tc.name, func(t *testing.T) {
200 c, ic := pulseCore(t)223 c, ic := pulseCore(t)
201 m := c.cur().mon224 m := c.cur().mon
202 m.Current().FocusID(1)225 m.Current().FocusID(1)
203 indicatorScene(t, c)226 indicatorScene(t, c)
204- tc.slide(m, ic.now)227+ tc.slide(t, c, ic.now)
228+ indicatorScene(t, c)
205 ic.now = ic.now.Add(pulseSettle)229 ic.now = ic.now.Add(pulseSettle)
206 if c.pulseTick() || c.pulse.timerC == nil {230 if c.pulseTick() || c.pulse.timerC == nil {
207 t.Fatal("pulse decided during a slide")231 t.Fatal("pulse decided during a slide")
208 }232 }
209 m.stopSwitch()233 m.stopSwitch()
210 m.Current().stopSlide()234 m.Current().stopSlide()
235+ c.cur().stopRects()
211 if tc.alone {236 if tc.alone {
212 m.Current().ToggleFullWidth()237 m.Current().ToggleFullWidth()
213 }238 }
@@ -259,3 +284,20 @@ func TestFocusPulseOff(t *testing.T) {
259 t.Fatal("enabling the pulse pulses the focused window")284 t.Fatal("enabling the pulse pulses the focused window")
260 }285 }
261 }286 }
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 {
28 // CaptureIndicators): immutable, shared by the next one while unchanged.28 // CaptureIndicators): immutable, shared by the next one while unchanged.
29 capScene *ports.SceneCapture29 capScene *ports.SceneCapture
30 capMarks []ports.CaptureIndicator30 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()
31 }82 }
32 83
33 func (s *screen) name() string { return s.info.Name }84 func (s *screen) name() string { return s.info.Name }
@@ -293,6 +344,10 @@ func (c *Core) removeScreen(name string) {
293 if len(c.screens) == 1 {344 if len(c.screens) == 1 {
294 c.screens[0].info = ports.OutputInfo{}345 c.screens[0].info = ports.OutputInfo{}
295 c.screens[0].layers = nil346 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()
296 return351 return
297 }352 }
298 gone := c.screens[i]353 gone := c.screens[i]
M internal/core/screens_test.go +135 -98
@@ -127,15 +127,21 @@ func TestOutputsLeftToRight(t *testing.T) {
127 }127 }
128 128
129 func TestLayoutPerOutput(t *testing.T) {129 func TestLayoutPerOutput(t *testing.T) {
130- r := startMulti(t, func(c *ports.Config) {130+ both(t, func(t *testing.T, animated bool) {
131- c.Layout.MaxColumns = 2131+ r := startLanding(t, animated, func(c *ports.Config) {
132- c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", MaxColumns: 4, Overflow: "fixed"}}132+ c.Layout.MaxColumns = 2
133- }, left, right)133+ c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", MaxColumns: 4, Overflow: "fixed"}}
134- r.mapWindow(t, 1)134+ }, left, right)
135- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)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)
136 var set []ports.Scene142 var set []ports.Scene
137 for id := ports.WindowID(2); id <= 6; id++ {143 for id := ports.WindowID(2); id <= 6; id++ {
138- set = r.mapWindow(t, id)144+ set = r.mapLanded(t, id)
139 }145 }
140 // DP-2: four columns of 100; the fifth window splits the last one146 // DP-2: four columns of 100; the fifth window splits the last one
141 // (fixed) instead of scrolling.147 // (fixed) instead of scrolling.
@@ -166,26 +172,32 @@ func widths(set []ports.Scene, output string) []int {
166 }172 }
167 173
168 func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) {174 func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) {
169- r := startMulti(t, func(c *ports.Config) {175+ both(t, func(t *testing.T, animated bool) {
170- c.Layout.MaxColumns = 1176+ r := startLanding(t, animated, func(c *ports.Config) {
171- c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", LayoutRules: ports.LayoutRules{MaxColumns: 2}}}177+ c.Layout.MaxColumns = 1
172- }, right)178+ c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", LayoutRules: ports.LayoutRules{MaxColumns: 2}}}
173- r.mapWindow(t, 1)179+ }, right)
174- if got := widths(r.mapWindow(t, 2), "DP-2"); !slices.Equal(got, []int{200, 200}) {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}) {
175 t.Fatal(got)187 t.Fatal(got)
176 }188 }
177 // Another monitor on DP-2: the key rule no longer applies.189 // Another monitor on DP-2: the key rule no longer applies.
178 other := right190 other := right
179 other.Serial = "3"191 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}) {
181 t.Fatal(got)193 t.Fatal(got)
182 }194 }
183 // Back to the first monitor, then a reload drops the rule.195 // Back to the first monitor, then a reload drops the rule.
184- r.plug(t, right)196+ r.land(r.plug(t, right))
185 cfg := r.cfg197 cfg := r.cfg
186 cfg.Layout.Outputs = nil198 cfg.Layout.Outputs = nil
187 r.reload <- ports.ConfigChanged{Config: cfg}199 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}) {
189 t.Fatal(got)201 t.Fatal(got)
190 }202 }
191 }203 }
@@ -301,10 +313,15 @@ func TestFocusColumnCrossesOutputs(t *testing.T) {
301 }313 }
302 314
303 func TestPointerCrossesOutputs(t *testing.T) {315 func TestPointerCrossesOutputs(t *testing.T) {
304- r := startMulti(t, nil, left, right)316+ both(t, func(t *testing.T, animated bool) {
305- r.mapWindow(t, 1)317+ pointerCrossesOutputs(t, startLanding(t, animated, nil, left, right))
306- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)318+ })
307- r.mapWindow(t, 2)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)
308 for len(r.commands) > 0 {325 for len(r.commands) > 0 {
309 <-r.commands326 <-r.commands
310 }327 }
@@ -400,33 +417,35 @@ func TestMonitorActionsFollowGeometry(t *testing.T) {
400 }417 }
401 418
402 func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {419 func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {
403- r := startMulti(t, nil, left, right)420+ both(t, func(t *testing.T, animated bool) {
404- r.mapWindow(t, 1)421+ r := startLanding(t, animated, nil, left, right)
405- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)422+ r.mapLanded(t, 1)
406- r.mapWindow(t, 2)423+ r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
407- // Unplug DP-2 while it is focused: its workspace joins DP-1 below its own.424+ r.mapLanded(t, 2)
408- r.output <- ports.OutputRemoved{Name: "DP-2"}425+ // Unplug DP-2 while it is focused: its workspace joins DP-1 below its own.
409- set := receive(t, r.scenes)426+ r.output <- ports.OutputRemoved{Name: "DP-2"}
410- if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 {427+ set := r.land(receive(t, r.scenes))
411- t.Fatal(shown(set))428+ if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 {
412- }429+ t.Fatal(shown(set))
413- // Cmd+2 shows the guest.430+ }
414- r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}431+ // Cmd+2 shows the guest.
415- set = receive(t, r.scenes)432+ r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
416- r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt}433+ set = r.land(receive(t, r.scenes))
417- if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 {434+ r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt}
418- t.Fatal(got)435+ if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 {
419- }436+ t.Fatal(got)
420- // Replugged while on screen: the guest stays until the user leaves it.437+ }
421- set = r.plug(t, right)438+ // Replugged while on screen: the guest stays until the user leaves it.
422- if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 {439+ set = r.land(r.plug(t, right))
423- t.Fatal(got)440+ if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 {
424- }441+ t.Fatal(got)
425- r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true}442+ }
426- set = receive(t, r.scenes)443+ r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true}
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 {444+ set = r.land(receive(t, r.scenes))
428- t.Fatal(got)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 {
429- }446+ t.Fatal(got)
447+ }
448+ })
430 }449 }
431 450
432 func TestReplugReturnsHiddenGuestAtOnce(t *testing.T) {451 func TestReplugReturnsHiddenGuestAtOnce(t *testing.T) {
@@ -763,38 +782,40 @@ func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) {
763 }782 }
764 783
765 func TestStateSnapshot(t *testing.T) {784 func TestStateSnapshot(t *testing.T) {
766- r := startMulti(t, nil, left, right)785+ both(t, func(t *testing.T, animated bool) {
767- r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}786+ r := startLanding(t, animated, nil, left, right)
768- receive(t, r.scenes)787+ r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}
769- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)788+ r.land(receive(t, r.scenes))
770- <-r.state // the channel holds the latest snapshot only789+ r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
771- r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200}790+ <-r.state // the channel holds the latest snapshot only
772- receive(t, r.scenes)791+ r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200}
773- // Snapshots from before window 2 may still be queued: wait for it.792+ r.land(receive(t, r.scenes))
774- st := receive(t, r.state)793+ // Snapshots from before window 2 may still be queued: wait for it.
775- for len(st.Windows) < 2 {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))
776 st = receive(t, r.state)813 st = receive(t, r.state)
777- }814+ // The window keeps its workspace ID; the output now shows the new one.
778- want := ports.State{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 {
779- Output: "DP-2",816+ t.Fatalf("%+v", st)
780- Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}},817+ }
781- Windows: []ports.WindowState{818+ })
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- }
798 }819 }
799 820
800 func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {821 func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
@@ -1003,18 +1024,24 @@ func TestFocusColumnFromFloatStaysOnMonitor(t *testing.T) {
1003 // and their popups wait, a click selects a peek, and the script state1024 // and their popups wait, a click selects a peek, and the script state
1004 // tells the stash and whether it is hidden.1025 // tells the stash and whether it is hidden.
1005 func TestStashEndToEnd(t *testing.T) {1026 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) {
1007 for id := ports.WindowID(1); id <= 3; id++ {1034 for id := ports.WindowID(1); id <= 3; id++ {
1008- r.mapWindow(t, id)1035+ r.mapLanded(t, id)
1009 }1036 }
1010- r.key(t, "s", ports.ModAlt|ports.ModShift) // 3 stashed1037+ r.keyLanded(t, "s", ports.ModAlt|ports.ModShift) // 3 stashed
1011- r.key(t, "Left", ports.ModAlt) // stays on 3: its stash's only window1038+ r.keyLanded(t, "Left", ports.ModAlt) // stays on 3: its stash's only window
1012- sc := r.key(t, "s", ports.ModAlt)1039+ sc := r.keyLanded(t, "s", ports.ModAlt)
1013 if got := shown(sc)["DP-1"]; !slices.Equal(got, []ports.WindowID{1, 2}) {1040 if got := shown(sc)["DP-1"]; !slices.Equal(got, []ports.WindowID{1, 2}) {
1014 t.Fatalf("hidden stash: %v", got)1041 t.Fatalf("hidden stash: %v", got)
1015 }1042 }
1016- r.key(t, "Left", ports.ModAlt) // tiles: from 2 to 11043+ r.keyLanded(t, "Left", ports.ModAlt) // tiles: from 2 to 1
1017- sc = r.key(t, "s", ports.ModAlt|ports.ModShift)1044+ sc = r.keyLanded(t, "s", ports.ModAlt|ports.ModShift)
1018 // Stash 3 1: 1 selected in the middle, 3 peeking left, dimmed.1045 // Stash 3 1: 1 selected in the middle, 3 peeking left, dimmed.
1019 var peek, sel ports.SceneWindow1046 var peek, sel ports.SceneWindow
1020 for _, w := range sc[0].Windows {1047 for _, w := range sc[0].Windows {
@@ -1040,7 +1067,7 @@ func TestStashEndToEnd(t *testing.T) {
1040 // A click on the peek (x 0..19 of 200) selects it.1067 // A click on the peek (x 0..19 of 200) selects it.
1041 r.input <- ports.PointerMotion{X: 5, Y: 50}1068 r.input <- ports.PointerMotion{X: 5, Y: 50}
1042 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}1069 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
1043- receive(t, r.scenes)1070+ r.land(receive(t, r.scenes))
1044 st := receive(t, r.state)1071 st := receive(t, r.state)
1045 for st.Window == nil || st.Window.ID != 3 {1072 for st.Window == nil || st.Window.ID != 3 {
1046 st = receive(t, r.state)1073 st = receive(t, r.state)
@@ -1056,7 +1083,7 @@ func TestStashEndToEnd(t *testing.T) {
1056 t.Fatalf("tile %+v", w)1083 t.Fatalf("tile %+v", w)
1057 }1084 }
1058 r.input <- ports.PointerButton{Button: 0x110}1085 r.input <- ports.PointerButton{Button: 0x110}
1059- r.key(t, "s", ports.ModAlt)1086+ r.keyLanded(t, "s", ports.ModAlt)
1060 st = receive(t, r.state)1087 st = receive(t, r.state)
1061 for !st.Windows[0].Hidden {1088 for !st.Windows[0].Hidden {
1062 st = receive(t, r.state)1089 st = receive(t, r.state)
@@ -1182,11 +1209,16 @@ func TestDialogFocusStaysOnMonitor(t *testing.T) {
1182 // A click on a tile behind the shown stash hides the stash and focuses1209 // A click on a tile behind the shown stash hides the stash and focuses
1183 // the tile.1210 // the tile.
1184 func TestStashHidesOnBackgroundClick(t *testing.T) {1211 func TestStashHidesOnBackgroundClick(t *testing.T) {
1185- // Gap 10 at 80%: no peeks, the margins show the tile behind.1212+ both(t, func(t *testing.T, animated bool) {
1186- r := startMulti(t, func(c *ports.Config) { c.Stash.Gap = 10 }, left)1213+ // Gap 10 at 80%: no peeks, the margins show the tile behind.
1187- r.mapWindow(t, 1)1214+ stashHidesOnBackgroundClick(t, startLanding(t, animated, func(c *ports.Config) { c.Stash.Gap = 10 }, left))
1188- r.mapWindow(t, 2)1215+ })
1189- r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed and focused, over tile 11216+}
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
1190 r.input <- ports.PointerMotion{X: 5, Y: 50}1222 r.input <- ports.PointerMotion{X: 5, Y: 50}
1191 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}1223 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
1192 st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 1 })1224 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) {
1510 // A reload that shrinks the layout under a still pointer brings it back onto1542 // A reload that shrinks the layout under a still pointer brings it back onto
1511 // an output.1543 // an output.
1512 func TestTransformReloadClampsPointer(t *testing.T) {1544 func TestTransformReloadClampsPointer(t *testing.T) {
1513- r := startMulti(t, func(c *ports.Config) {1545+ both(t, func(t *testing.T, animated bool) {
1514- c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}}1546+ transformReloadClampsPointer(t, startLanding(t, animated, func(c *ports.Config) {
1515- }, left, right)1547+ c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}}
1516- r.mapWindow(t, 1)1548+ }, left, right))
1517- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)1549+ })
1518- r.mapWindow(t, 2)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)
1519 // DP-2 spans x 200..600: (550, 50) is (350, 50) on window 2.1556 // DP-2 spans x 200..600: (550, 50) is (350, 50) on window 2.
1520 for len(r.commands) > 0 {1557 for len(r.commands) > 0 {
1521 <-r.commands1558 <-r.commands
M internal/core/security.go +4 -7
@@ -33,13 +33,7 @@ func (c *Core) syncSecurity() bool {
33 }33 }
34 c.dropSwipe()34 c.dropSwipe()
35 c.swipe = nil35 c.swipe = nil
36- for _, sc := range c.screens {36+ c.stopAnimations()
37- sc.mon.stopSwitch()
38- for w := range sc.mon.all() {
39- w.stopSlide()
40- }
41- }
42- c.stopFrame()
43 c.stopPulse()37 c.stopPulse()
44 if state.Protected {38 if state.Protected {
45 c.dropCaptureSessions()39 c.dropCaptureSessions()
@@ -185,6 +179,9 @@ func (c *Core) publishProtected(ctx context.Context) error {
185 scenes := make([]ports.Scene, 0, len(c.screens))179 scenes := make([]ports.Scene, 0, len(c.screens))
186 for _, sc := range c.screens {180 for _, sc := range c.screens {
187 c.seq++181 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
188 o := sc.mon.Output()185 o := sc.mon.Output()
189 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"}186 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"}
190 if s, ok := c.lockSurface(sc); ok {187 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
38 if p.Floating {38 if p.Floating {
39 id = p.ID39 id = p.ID
40 }40 }
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+ }
41 out = append(out, ports.Separator{Rect: r, Active: active, Window: id})46 out = append(out, ports.Separator{Rect: r, Active: active, Window: id})
42 }47 }
43 var focused *Placement48 var focused *Placement
44 tiles := 049 tiles := 0
45 for i := range ps {50 for i := range ps {
46 p := &ps[i]51 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)) {
48 continue55 continue
49 }56 }
50 if !p.Floating {57 if !p.Floating {
M internal/core/slide.go +22 -6
@@ -17,21 +17,32 @@ func (w *Workspace) slidable() bool {
17 return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat()17 return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat()
18 }18 }
19 19
20-func (w *Workspace) stopSlide() { w.shift, w.motion = 0, nil }20+func (w *Workspace) stopSlide() { w.shift, w.motion = 0, motion{} }
21 21
22 // retarget keeps the view where it is on screen when ViewX moved from22 // retarget keeps the view where it is on screen when ViewX moved from
23 // before during a landing slide: the slide heads for the new view.23 // before during a landing slide: the slide heads for the new view.
24 // Without a landing slide the view moves at once, as it always has.24 // Without a landing slide the view moves at once, as it always has.
25 func (w *Workspace) retarget(before int) {25 func (w *Workspace) retarget(before int) {
26- if w.ViewX == before || w.motion == nil {26+ if w.ViewX == before || !w.motion.on {
27 return27 return
28 }28 }
29 w.shift += float64(before - w.ViewX)29 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)
31 }31 }
32 32
33 func (w *Workspace) shiftPixels() int { return int(math.Round(w.shift)) }33 func (w *Workspace) shiftPixels() int { return int(math.Round(w.shift)) }
34 34
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+
35 // swipeScale turns touchpad distance into pixels: viewSwipeMovement46 // swipeScale turns touchpad distance into pixels: viewSwipeMovement
36 // scrolls one usable width.47 // scrolls one usable width.
37 func (w *Workspace) swipeScale() float64 {48 func (w *Workspace) swipeScale() float64 {
@@ -105,7 +116,7 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) {
105 return focus116 return focus
106 }117 }
107 118
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 }
109 120
110 // framedSwitch keeps transitions involving a sized workspace settled. A121 // framedSwitch keeps transitions involving a sized workspace settled. A
111 // monitor-wide animation has no per-workspace crop; it must not expose122 // monitor-wide animation has no per-workspace crop; it must not expose
@@ -163,12 +174,17 @@ func (m *Monitor) slideLayout(cur []Placement) []Placement {
163 if k == base || k < 0 || k >= len(list) || !m.has(list[k]) {174 if k == base || k < 0 || k >= len(list) || !m.has(list[k]) {
164 continue175 continue
165 }176 }
166- next := list[k].Layout()177+ next := list[k].layoutInto(m.slideBuf)
178+ m.slideBuf = next
167 for i := range next {179 for i := range next {
168 next[i].Focused = false180 next[i].Focused = false
169 }181 }
170 // Windows of the neighbor are already listed hidden: replace them.182 // 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)
172 for _, p := range offset(next, k) {188 for _, p := range offset(next, k) {
173 shown[p.ID] = p189 shown[p.ID] = p
174 }190 }
M internal/core/slots_test.go +53 -5
@@ -21,14 +21,30 @@ type slotRig struct {
21 workspaces chan ports.Workspaces21 workspaces chan ports.Workspaces
22 errs chan error22 errs chan error
23 cfg ports.Config23 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
24 }29 }
25 30
26 // startSlots runs core with a hidden "dev" workspace of two slots, toggled31 // startSlots runs core with a hidden "dev" workspace of two slots, toggled
27 // by Alt+d, on a 100x80 output. One flag makes dev numbered, two also set32 // by Alt+d, on a 100x80 output. One flag makes dev numbered, two also set
28 // fixed overflow.33 // fixed overflow.
29 func startSlots(t *testing.T, numbered ...bool) *slotRig {34 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 {
30 t.Helper()43 t.Helper()
31 cfg := altCmdDefaults()44 cfg := altCmdDefaults()
45+ if animated != nil {
46+ cfg.Animations.On = *animated
47+ }
32 cfg.Border.Width = 048 cfg.Border.Width = 0
33 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{49 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{
34 {Index: 1, Width: "70%", Argv: []string{"code"}},50 {Index: 1, Width: "70%", Argv: []string{"code"}},
@@ -45,7 +61,17 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
45 }61 }
46 output := make(chan ports.OutputEvent, 1)62 output := make(chan ports.OutputEvent, 1)
47 commands := make(chan ports.ClientCommand, 1024)63 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)
49 if err != nil {75 if err != nil {
50 t.Fatal(err)76 t.Fatal(err)
51 }77 }
@@ -62,6 +88,22 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
62 return r88 return r
63 }89 }
64 90
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+
65 // token returns the SlotEnv value of a spawn request.107 // token returns the SlotEnv value of a spawn request.
66 func token(t *testing.T, req ports.SpawnRequest) string {108 func token(t *testing.T, req ports.SpawnRequest) string {
67 t.Helper()109 t.Helper()
@@ -194,7 +236,10 @@ func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
194 }236 }
195 237
196 func TestSlotRefilledOnlyWhenShown(t *testing.T) {238 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) {
198 // Wait for both windows: a one-window scene from before the unmap243 // Wait for both windows: a one-window scene from before the unmap
199 // would let the key below race it.244 // would let the key below race it.
200 _, foot := r.fill(t)245 _, foot := r.fill(t)
@@ -209,7 +254,7 @@ func TestSlotRefilledOnlyWhenShown(t *testing.T) {
209 }254 }
210 r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)}255 r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)}
211 s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 })256 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 {
213 t.Fatal(got)258 t.Fatal(got)
214 }259 }
215 // Leaving and showing dev again with full slots spawns nothing.260 // Leaving and showing dev again with full slots spawns nothing.
@@ -344,7 +389,10 @@ func TestSlotOldTokenIsNormal(t *testing.T) {
344 // A slot whose command never maps a window gets one show to do it; on the389 // A slot whose command never maps a window gets one show to do it; on the
345 // next show it is respawned and its old token no longer fills it.390 // next show it is respawned and its old token no longer fills it.
346 func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {391 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) {
348 code, foot := receive(t, r.spawn), receive(t, r.spawn)396 code, foot := receive(t, r.spawn), receive(t, r.spawn)
349 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}397 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
350 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })398 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
@@ -363,7 +411,7 @@ func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {
363 }411 }
364 r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)}412 r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)}
365 s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })413 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 {
367 t.Fatalf("respawned window not in its slot: %v", got)415 t.Fatalf("respawned window not in its slot: %v", got)
368 }416 }
369 }417 }
M internal/core/stash.go +8 -3
@@ -191,6 +191,12 @@ func (w *Workspace) stashOverCover() bool {
191 return w.stashOver != 0 && w.stashOver == w.cover() && !w.stashHidden && len(w.Stash) > 0191 return w.stashOver != 0 && w.stashOver == w.cover() && !w.stashHidden && len(w.Stash) > 0
192 }192 }
193 193
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+
194 // dropStashOver ends the stash override when the focus leaves it: the200 // dropStashOver ends the stash override when the focus leaves it: the
195 // stash hides again under the covering window, as it was.201 // stash hides again under the covering window, as it was.
196 func (w *Workspace) dropStashOver() {202 func (w *Workspace) dropStashOver() {
@@ -217,18 +223,17 @@ func (w *Workspace) stashRect() Rect {
217 return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh}223 return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh}
218 }224 }
219 225
220-// stashLayout places the stash: the selected window centred, its226+// appendStash appends the placements of the stash to out: the selected window centred, its
221 // neighbors gap% of the usable width away, showing what the margins227 // neighbors gap% of the usable width away, showing what the margins
222 // leave of them, the others hidden. cover is the covering fullscreen228 // leave of them, the others hidden. cover is the covering fullscreen
223 // window, if any.229 // window, if any.
224-func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement {230+func (w *Workspace) appendStash(out []Placement, focusedID, cover WindowID) []Placement {
225 u := w.Usable231 u := w.Usable
226 center := w.stashRect()232 center := w.stashRect()
227 fw, fh := center.W, center.H233 fw, fh := center.W, center.H
228 // Peeks stay in the margins: they never overlap the selected window.234 // Peeks stay in the margins: they never overlap the selected window.
229 gap := u.W * w.stashGap / 100235 gap := u.W * w.stashGap / 100
230 peek := max((u.W-fw)/2-gap, 0)236 peek := max((u.W-fw)/2-gap, 0)
231- out := make([]Placement, 0, len(w.Stash))
232 for i, f := range w.Stash {237 for i, f := range w.Stash {
233 p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll}238 p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll}
234 switch {239 switch {
M internal/core/state.go +4 -1
@@ -38,7 +38,10 @@ func (c *Core) state() ports.State {
38 st.Outputs = append(st.Outputs, o)38 st.Outputs = append(st.Outputs, o)
39 // Visible follows the configures: drawn on the output.39 // Visible follows the configures: drawn on the output.
40 shown := map[WindowID]bool{}40 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 {
42 shown[p.ID] = onScreen(p, m.Frame())45 shown[p.ID] = onScreen(p, m.Frame())
43 }46 }
44 for w := range m.all() {47 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
2 2
3 import (3 import (
4 "context"4 "context"
5- "sync"5+ "slices"
6 "testing"6 "testing"
7 "time"7 "time"
8 8
9 "github.com/bnema/neferwl/internal/core"9 "github.com/bnema/neferwl/internal/core"
10- portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11 "github.com/bnema/neferwl/internal/ports"10 "github.com/bnema/neferwl/internal/ports"
12- "github.com/stretchr/testify/mock"
13 )11 )
14 12
15 // swipeRig runs core on one 800x600 output with no gaps or border, a13 // swipeRig runs core on one 800x600 output with no gaps or border, a
@@ -17,43 +15,40 @@ import (
17 type swipeRig struct {15 type swipeRig struct {
18 *multiRig16 *multiRig
19 frames chan ports.OutputFrame17 frames chan ports.OutputFrame
20- mu sync.Mutex18+ clk *stepClock
21- now time.Time
22 // at is the device time of the next swipe event.19 // at is the device time of the next swipe event.
23 at time.Duration20 at time.Duration
21+ // outs are the plugged outputs, for landed.
22+ outs []string
24 }23 }
25 24
26 var wide = ports.OutputInfo{Name: "DP-1", Width: 800, Height: 600, RefreshMilli: 60000}25 var wide = ports.OutputInfo{Name: "DP-1", Width: 800, Height: 600, RefreshMilli: 60000}
27 26
28 func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig {27 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 {
29 t.Helper()34 t.Helper()
30 cfg := altCmdDefaults()35 cfg := altCmdDefaults()
31 cfg.Border.Width = 036 cfg.Border.Width = 0
32 cfg.Layout.Gaps = 037 cfg.Layout.Gaps = 0
33 cfg.Terminal.AutoOpen = "never"38 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
34 if edit != nil {42 if edit != nil {
35 edit(&cfg)43 edit(&cfg)
36 }44 }
37- r := &swipeRig{frames: make(chan ports.OutputFrame), now: time.Unix(100, 0)}45+ r := &swipeRig{frames: make(chan ports.OutputFrame), clk: newStepClock(t)}
38 r.multiRig = &multiRig{46 r.multiRig = &multiRig{
39 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 64),47 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 64),
40 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),48 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
41 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,49 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,
42 }50 }
43- clock := portsmocks.NewMockClock(t)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})
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})
57 if err != nil {52 if err != nil {
58 t.Fatal(err)53 t.Fatal(err)
59 }54 }
@@ -61,14 +56,28 @@ func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig {
61 done := make(chan error, 1)56 done := make(chan error, 1)
62 go func() { done <- c.Run(ctx) }()57 go func() { done <- c.Run(ctx) }()
63 t.Cleanup(func() { cancel(); <-done })58 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+ }
65 return r63 return r
66 }64 }
67 65
68 func (r *swipeRig) advance(d time.Duration) {66 func (r *swipeRig) advance(d time.Duration) {
69- r.mu.Lock()67+ r.clk.advance(d)
70- r.now = r.now.Add(d)68+}
71- r.mu.Unlock()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
72 }81 }
73 82
74 // begin starts a swipe; nothing moves until it picks an axis.83 // begin starts a swipe; nothing moves until it picks an axis.
@@ -105,6 +114,13 @@ func (r *swipeRig) settle(t *testing.T) ports.Scene {
105 return r.frame(t, 5*time.Second)114 return r.frame(t, 5*time.Second)
106 }115 }
107 116
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+
108 // flick sends a quick swipe of n updates of (dx, dy) and lifts the124 // flick sends a quick swipe of n updates of (dx, dy) and lifts the
109 // fingers; it returns the scene right after the lift.125 // fingers; it returns the scene right after the lift.
110 func (r *swipeRig) flick(t *testing.T, n int, dx, dy float64) ports.Scene {126 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 {
121 func (r *swipeRig) workspaces(t *testing.T) {137 func (r *swipeRig) workspaces(t *testing.T) {
122 t.Helper()138 t.Helper()
123 r.mapWindow(t, 1)139 r.mapWindow(t, 1)
124- r.key(t, "Next", ports.ModAlt)140+ r.keySettled(t, "Next")
125 r.mapWindow(t, 2)141 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+ }
127 }153 }
128 154
129 func rectOf(s ports.Scene, id ports.WindowID) (ports.Rect, bool) {155 func rectOf(s ports.Scene, id ports.WindowID) (ports.Rect, bool) {
@@ -324,8 +350,8 @@ func TestSwipeFromEmptyWorkspaceLandsWithoutJump(t *testing.T) {
324 r := startSwipe(t, nil)350 r := startSwipe(t, nil)
325 r.workspaces(t)351 r.workspaces(t)
326 // Workspace 3 is the trailing empty one; start there and swipe up to 2.352 // Workspace 3 is the trailing empty one; start there and swipe up to 2.
327- r.key(t, "Next", ports.ModAlt)353+ r.keySettled(t, "Next")
328- r.key(t, "Next", ports.ModAlt)354+ r.keySettled(t, "Next")
329 r.begin()355 r.begin()
330 var s ports.Scene356 var s ports.Scene
331 for range 6 {357 for range 6 {
@@ -382,6 +408,10 @@ func TestSwipeDropsWhenWorkspacesChange(t *testing.T) {
382 r.move(t, 0, 30)408 r.move(t, 0, 30)
383 // A key switches workspace mid-swipe: the swipe lets go at once.409 // A key switches workspace mid-swipe: the swipe lets go at once.
384 s := r.key(t, "Next", ports.ModAlt)[0]410 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)
385 if got, ok := rectOf(s, 2); !ok || got.Y != 0 {415 if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
386 t.Fatalf("workspace 2 at %v %t after the key", got, ok)416 t.Fatalf("workspace 2 at %v %t after the key", got, ok)
387 }417 }
@@ -406,16 +436,8 @@ func TestSlideMovesPointerFocus(t *testing.T) {
406 r.settle(t)436 r.settle(t)
407 var focus ports.PointerFocus437 var focus ports.PointerFocus
408 var motion ports.PointerMotionTo438 var motion ports.PointerMotionTo
409- // Core sends the pointer update right after the scene: wait for it.439+ // Core sends the pointer update right after the scene.
410- for {440+ for _, v := range r.sent() {
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- }
419 switch v := v.(type) {441 switch v := v.(type) {
420 case ports.PointerFocus:442 case ports.PointerFocus:
421 focus = v443 focus = v
@@ -478,7 +500,11 @@ func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) {
478 // The pointer goes to DP-2 and a bind switches its workspace.500 // The pointer goes to DP-2 and a bind switches its workspace.
479 r.input <- ports.PointerMotion{X: 1000, Y: 300}501 r.input <- ports.PointerMotion{X: 1000, Y: 300}
480 scene(t, r.scenes)502 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) {
482 if _, ok := rectOf(sc, 4); ok {508 if _, ok := rectOf(sc, 4); ok {
483 t.Fatal("DP-2 did not switch workspace")509 t.Fatal("DP-2 did not switch workspace")
484 }510 }
@@ -527,15 +553,7 @@ func TestDiscreteSwipeSkippedWhenPointerChangesOutput(t *testing.T) {
527 scene(t, r.scenes)553 scene(t, r.scenes)
528 r.drain()554 r.drain()
529 r.input <- ports.SwipeEnd{Time: r.at}555 r.input <- ports.SwipeEnd{Time: r.at}
530- for {556+ for _, v := range r.sent() {
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- }
539 if f, ok := v.(ports.FocusWindow); ok && f.ID == 3 {557 if f, ok := v.(ports.FocusWindow); ok && f.ID == 3 {
540 t.Fatal("swipe from DP-1 moved the focus on DP-2")558 t.Fatal("swipe from DP-1 moved the focus on DP-2")
541 }559 }
@@ -544,22 +562,22 @@ func TestDiscreteSwipeSkippedWhenPointerChangesOutput(t *testing.T) {
544 562
545 // drain waits for core to handle what was sent, then empties commands.563 // drain waits for core to handle what was sent, then empties commands.
546 func (r *swipeRig) drain() {564 func (r *swipeRig) drain() {
547- for {565+ for range r.sent() {
548- select {
549- case <-r.commands:
550- case <-time.After(50 * time.Millisecond):
551- return
552- }
553 }566 }
554 }567 }
555 568
556-func TestKeyWithoutSlideMovesAtOnce(t *testing.T) {569+// sent waits for core to handle what was sent and returns the commands it
557- r := startSwipe(t, nil)570+// sent meanwhile (the sync flip is a barrier, see published).
558- threeColumns(t, r)571+func (r *swipeRig) sent() []ports.ClientCommand {
559- r.key(t, "Left", ports.ModAlt)572+ send(r.frames, ports.OutputFrame{Output: "sync"})
560- s := r.key(t, "Left", ports.ModAlt)[0]573+ var out []ports.ClientCommand
561- if got, _ := rectOf(s, 1); got.X != 0 {574+ for {
562- t.Fatalf("column 1 at %d", got.X)575+ select {
576+ case v := <-r.commands:
577+ out = append(out, v)
578+ default:
579+ return out
580+ }
563 }581 }
564 }582 }
565 583
@@ -650,6 +668,179 @@ func TestFourFingerSwipeOverview(t *testing.T) {
650 }668 }
651 }669 }
652 670
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+
653 // Opening the overview during a column swipe drops the swipe: the rest844 // Opening the overview during a column swipe drops the swipe: the rest
654 // of it neither slides the columns under the overview nor lands them,845 // of it neither slides the columns under the overview nor lands them,
655 // also once the overview closed again (escape before the rest).846 // also once the overview closed again (escape before the rest).
@@ -661,28 +852,40 @@ func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) {
661 r.move(t, -40, 0)852 r.move(t, -40, 0)
662 r.move(t, -40, 0)853 r.move(t, -40, 0)
663 r.key(t, "o", ports.ModAlt)854 r.key(t, "o", ports.ModAlt)
855+ // s is the latest scene.
856+ var s ports.Scene
664 if closeFirst {857 if closeFirst {
665- r.key(t, "Escape", 0)858+ s = r.key(t, "Escape", 0)[0]
666 }859 }
667 for range 20 {860 for range 20 {
668 r.at += 8 * time.Millisecond861 r.at += 8 * time.Millisecond
669 r.input <- ports.SwipeUpdate{DX: -80, Time: r.at}862 r.input <- ports.SwipeUpdate{DX: -80, Time: r.at}
670 }863 }
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.
671 r.input <- ports.SwipeEnd{Time: r.at}868 r.input <- ports.SwipeEnd{Time: r.at}
869+ s = scene(t, r.scenes)
672 if !closeFirst {870 if !closeFirst {
673- r.input <- ports.KeyEvent{Keysym: "Escape", Pressed: true}871+ s = r.key(t, "Escape", 0)[0]
674 }872 }
675- // Past any spring: the view shows where it settled.873+ // Past any spring: the escape's own springs (the cards zooming
676- r.advance(5 * time.Second)874+ // back) may chain after the swipe's landing, so settle until
677- r.frames <- ports.OutputFrame{Output: wide.Name}875+ // none runs.
678- s := sceneMatch(t, r.scenes, func(s ports.Scene) bool {876+ for range 4 {
679- for _, w := range s.Windows {877+ set, ok := r.settleAll(t, wide.Name)
680- if w.Preview > 0 {878+ if !ok {
681- return false879+ break
682- }
683 }880 }
684- return true881+ s = set[0]
685- })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+ }
686 for _, id := range []ports.WindowID{1, 2, 3} {889 for _, id := range []ports.WindowID{1, 2, 3} {
687 before, _ := rectOf(settled, id)890 before, _ := rectOf(settled, id)
688 if after, _ := rectOf(s, id); before != after {891 if after, _ := rectOf(s, id); before != after {
@@ -825,3 +1028,39 @@ func TestSwipeWindowMappedMidSwipe(t *testing.T) {
825 }1028 }
826 }1029 }
827 }1030 }
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) {
91 t.Fatal("popup of a visible window not on screen")91 t.Fatal("popup of a visible window not on screen")
92 }92 }
93 m.AddWindow(4)93 m.AddWindow(4)
94+ publish() // visibility reads the layout of the last publish
94 if c.visible(1) || c.visible(10) {95 if c.visible(1) || c.visible(10) {
95 t.Fatal("popup of a scrolled-off window still on screen")96 t.Fatal("popup of a scrolled-off window still on screen")
96 }97 }
M internal/core/workspace_size_test.go +113 -62
@@ -8,11 +8,10 @@ import (
8 8
9 var ultrawide = ports.OutputInfo{Name: "DP-3", Make: "Acme", Model: "W", Serial: "3", Width: 3440, Height: 1440}9 var ultrawide = ports.OutputInfo{Name: "DP-3", Make: "Acme", Model: "W", Serial: "3", Width: 3440, Height: 1440}
10 10
11-// sizedRig runs an ultrawide with the named workspace "focus" (1920x1080)11+// sizedConfig is the config of the sized-workspace rigs: gaps off, two
12-// bound to Alt+w, and shows it.12+// columns, the named workspace "focus" (1920x1080) bound to Alt+w.
13-func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRig, ports.Scene) {13+func sizedConfig(overflow string, edit func(*ports.Config)) func(*ports.Config) {
14- t.Helper()14+ return func(c *ports.Config) {
15- r := startMulti(t, func(c *ports.Config) {
16 c.Layout.Gaps = 015 c.Layout.Gaps = 0
17 c.Layout.MaxColumns = 216 c.Layout.MaxColumns = 2
18 c.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1920, 1080}, LayoutRules: ports.LayoutRules{Overflow: overflow}}}17 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
20 if edit != nil {19 if edit != nil {
21 edit(c)20 edit(c)
22 }21 }
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)
24 return r, r.key(t, "w", ports.ModAlt)[0]31 return r, r.key(t, "w", ports.ModAlt)[0]
25 }32 }
26 33
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+
27 var frame = ports.Rect{X: 760, Y: 180, W: 1920, H: 1080}42 var frame = ports.Rect{X: 760, Y: 180, W: 1920, H: 1080}
28 43
29 func within(r, o ports.Rect) bool {44 func within(r, o ports.Rect) bool {
@@ -31,18 +46,26 @@ func within(r, o ports.Rect) bool {
31 }46 }
32 47
33 func TestWorkspaceSizeInheritsByDefault(t *testing.T) {48 func TestWorkspaceSizeInheritsByDefault(t *testing.T) {
34- r := startMulti(t, func(c *ports.Config) { c.Layout.Gaps = 0 }, ultrawide)49+ both(t, func(t *testing.T, animated bool) {
35- set := r.mapWindow(t, 1)50+ r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Gaps = 0 }, ultrawide)
36- if set[0].WorkspaceClip != (ports.Rect{}) || set[0].Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) {51+ set := r.mapLanded(t, 1)
37- t.Fatalf("%+v", set[0])52+ if set[0].WorkspaceClip != (ports.Rect{}) || set[0].Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) {
38- }53+ t.Fatalf("%+v", set[0])
54+ }
55+ })
39 }56 }
40 57
41 func TestWorkspaceSizeScrollColumnsStayInFrame(t *testing.T) {58 func TestWorkspaceSizeScrollColumnsStayInFrame(t *testing.T) {
42- r, _ := sizedRig(t, "scroll", nil)59+ both(t, func(t *testing.T, animated bool) {
43- r.mapWindow(t, 1)60+ r, _ := sizedLanding(t, animated, "scroll", nil)
44- r.mapWindow(t, 2)61+ workspaceSizeScrollColumnsStayInFrame(t, r)
45- s := r.mapWindow(t, 3)[0]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]
46 if s.WorkspaceClip != frame || s.OutputWidth != 3440 || s.OutputHeight != 1440 {69 if s.WorkspaceClip != frame || s.OutputWidth != 3440 || s.OutputHeight != 1440 {
47 t.Fatalf("clip %+v output %dx%d", s.WorkspaceClip, s.OutputWidth, s.OutputHeight)70 t.Fatalf("clip %+v output %dx%d", s.WorkspaceClip, s.OutputWidth, s.OutputHeight)
48 }71 }
@@ -62,9 +85,15 @@ func TestWorkspaceSizeScrollColumnsStayInFrame(t *testing.T) {
62 }85 }
63 86
64 func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) {87 func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) {
65- r, _ := sizedRig(t, "fixed", nil)88+ both(t, func(t *testing.T, animated bool) {
66- r.mapWindow(t, 1)89+ r, _ := sizedLanding(t, animated, "fixed", nil)
67- s := r.mapWindow(t, 2)[0]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]
68 for i, want := range []ports.Rect{{X: 760, Y: 180, W: 960, H: 1080}, {X: 1720, Y: 180, W: 960, H: 1080}} {97 for i, want := range []ports.Rect{{X: 760, Y: 180, W: 960, H: 1080}, {X: 1720, Y: 180, W: 960, H: 1080}} {
69 if s.Windows[i].Rect != want {98 if s.Windows[i].Rect != want {
70 t.Fatalf("%d: %+v", i, s.Windows[i])99 t.Fatalf("%d: %+v", i, s.Windows[i])
@@ -73,48 +102,52 @@ func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) {
73 }102 }
74 103
75 func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) {104 func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) {
76- r, _ := sizedRig(t, "scroll", nil)105+ both(t, func(t *testing.T, animated bool) {
77- r.mapWindow(t, 1)106+ r, _ := sizedLanding(t, animated, "scroll", nil)
78- r.mapWindow(t, 2)107+ r.mapLanded(t, 1)
79- s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]108+ r.mapLanded(t, 2)
80- full := 0109+ s := r.keyLanded(t, "f", ports.ModAlt|ports.ModShift)[0]
81- for _, w := range s.Windows {110+ full := 0
82- if w.Fullscreen {111+ for _, w := range s.Windows {
83- full++112+ if w.Fullscreen {
84- if w.Rect != frame {113+ full++
85- t.Fatalf("%+v", w)114+ if w.Rect != frame {
115+ t.Fatalf("%+v", w)
116+ }
86 }117 }
87 }118 }
88- }119+ if full != 1 {
89- if full != 1 {120+ t.Fatal(s.Windows)
90- t.Fatal(s.Windows)121+ }
91- }122+ })
92 }123 }
93 124
94 func TestWorkspaceSizeFixedFullscreenKeepsFrame(t *testing.T) {125 func TestWorkspaceSizeFixedFullscreenKeepsFrame(t *testing.T) {
95- r, _ := sizedRig(t, "fixed", nil)126+ both(t, func(t *testing.T, animated bool) {
96- r.mapWindow(t, 1)127+ r, _ := sizedLanding(t, animated, "fixed", nil)
97- r.mapWindow(t, 2)128+ r.mapLanded(t, 1)
98- s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]129+ r.mapLanded(t, 2)
99- if s.WorkspaceClip != frame {130+ s := r.keyLanded(t, "f", ports.ModAlt|ports.ModShift)[0]
100- t.Fatalf("fullscreen clip %+v", s.WorkspaceClip)131+ if s.WorkspaceClip != frame {
101- }132+ t.Fatalf("fullscreen clip %+v", s.WorkspaceClip)
102- for _, w := range s.Windows {
103- if w.Fullscreen && w.Rect != frame {
104- t.Fatalf("%+v", w)
105 }133 }
106- }134+ for _, w := range s.Windows {
107- // Reload keeps the size; removing it frees the workspace.135+ if w.Fullscreen && w.Rect != frame {
108- cfg := r.cfg136+ t.Fatalf("%+v", w)
109- r.reload <- ports.ConfigChanged{Config: cfg}137+ }
110- if s := receive(t, r.scenes)[0]; s.WorkspaceClip != frame {138+ }
111- t.Fatalf("after reload %+v", s.WorkspaceClip)139+ // Reload keeps the size; removing it frees the workspace.
112- }140+ cfg := r.cfg
113- cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", LayoutRules: ports.LayoutRules{Overflow: "fixed"}}}141+ r.reload <- ports.ConfigChanged{Config: cfg}
114- r.reload <- ports.ConfigChanged{Config: cfg}142+ if s := r.land(receive(t, r.scenes))[0]; s.WorkspaceClip != frame {
115- if s := receive(t, r.scenes)[0]; s.WorkspaceClip != (ports.Rect{}) {143+ t.Fatalf("after reload %+v", s.WorkspaceClip)
116- t.Fatalf("after size removal %+v", s.WorkspaceClip)144+ }
117- }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+ })
118 }151 }
119 152
120 func TestWorkspaceSizeFloatsContained(t *testing.T) {153 func TestWorkspaceSizeFloatsContained(t *testing.T) {
@@ -130,8 +163,14 @@ func TestWorkspaceSizeFloatsContained(t *testing.T) {
130 }163 }
131 164
132 func TestWorkspaceSizeReloadAndRemoval(t *testing.T) {165 func TestWorkspaceSizeReloadAndRemoval(t *testing.T) {
133- r, _ := sizedRig(t, "scroll", nil)166+ both(t, func(t *testing.T, animated bool) {
134- r.mapWindow(t, 1)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)
135 cfg := r.cfg174 cfg := r.cfg
136 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1000, 600}}}175 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1000, 600}}}
137 r.reload <- ports.ConfigChanged{Config: cfg}176 r.reload <- ports.ConfigChanged{Config: cfg}
@@ -159,28 +198,40 @@ func TestWorkspaceSizeReloadAndRemoval(t *testing.T) {
159 }198 }
160 199
161 func TestWorkspaceSizeFollowsOutputResize(t *testing.T) {200 func TestWorkspaceSizeFollowsOutputResize(t *testing.T) {
162- r, _ := sizedRig(t, "scroll", nil)201+ both(t, func(t *testing.T, animated bool) {
163- r.mapWindow(t, 1)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)
164 smaller := ultrawide209 smaller := ultrawide
165 smaller.Width, smaller.Height = 1600, 900210 smaller.Width, smaller.Height = 1600, 900
166- s := r.plug(t, smaller)[0]211+ s := r.land(r.plug(t, smaller))[0]
167 // 1920x1080 no longer fits: it clamps to the monitor.212 // 1920x1080 no longer fits: it clamps to the monitor.
168 if s.WorkspaceClip != (ports.Rect{}) || s.Windows[0].Rect != (ports.Rect{W: 1600, H: 900}) {213 if s.WorkspaceClip != (ports.Rect{}) || s.Windows[0].Rect != (ports.Rect{W: 1600, H: 900}) {
169 t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])214 t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
170 }215 }
171- s = r.plug(t, ultrawide)[0]216+ s = r.land(r.plug(t, ultrawide))[0]
172 if s.WorkspaceClip != frame || s.Windows[0].Rect != frame {217 if s.WorkspaceClip != frame || s.Windows[0].Rect != frame {
173 t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])218 t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
174 }219 }
175 }220 }
176 221
177 func TestWorkspaceSizeReservedZoneIntersectsFrame(t *testing.T) {222 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) {
179 // A bar inside the top of the monitor but above the frame reserves nothing of it.230 // A bar inside the top of the monitor but above the frame reserves nothing of it.
180 bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 100, ExclusiveZone: 100}231 bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 100, ExclusiveZone: 100}
181 r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}232 r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
182 receive(t, r.scenes)233 receive(t, r.scenes)
183- s := r.mapWindow(t, 1)[0]234+ s := r.mapLanded(t, 1)[0]
184 if s.Windows[0].Rect != frame {235 if s.Windows[0].Rect != frame {
185 t.Fatalf("%+v", s.Windows[0])236 t.Fatalf("%+v", s.Windows[0])
186 }237 }
M internal/mocks/ports/devices_mock.go +18 -12
@@ -385,8 +385,8 @@ func (_c *MockRenderer_EndCapture_Call) RunAndReturn(run func(captureFrame ports
385 }385 }
386 386
387 // ExportTargets provides a mock function for the type MockRenderer387 // ExportTargets provides a mock function for the type MockRenderer
388-func (_mock *MockRenderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) {388+func (_mock *MockRenderer) ExportTargets(n int, modifiers []uint64, singlePlane bool) ([]ports.DMABuf, error) {
389- ret := _mock.Called(n, modifiers)389+ ret := _mock.Called(n, modifiers, singlePlane)
390 390
391 if len(ret) == 0 {391 if len(ret) == 0 {
392 panic("no return value specified for ExportTargets")392 panic("no return value specified for ExportTargets")
@@ -394,18 +394,18 @@ func (_mock *MockRenderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMA
394 394
395 var r0 []ports.DMABuf395 var r0 []ports.DMABuf
396 var r1 error396 var r1 error
397- if returnFunc, ok := ret.Get(0).(func(int, []uint64) ([]ports.DMABuf, error)); ok {397+ if returnFunc, ok := ret.Get(0).(func(int, []uint64, bool) ([]ports.DMABuf, error)); ok {
398- return returnFunc(n, modifiers)398+ return returnFunc(n, modifiers, singlePlane)
399 }399 }
400- if returnFunc, ok := ret.Get(0).(func(int, []uint64) []ports.DMABuf); ok {400+ if returnFunc, ok := ret.Get(0).(func(int, []uint64, bool) []ports.DMABuf); ok {
401- r0 = returnFunc(n, modifiers)401+ r0 = returnFunc(n, modifiers, singlePlane)
402 } else {402 } else {
403 if ret.Get(0) != nil {403 if ret.Get(0) != nil {
404 r0 = ret.Get(0).([]ports.DMABuf)404 r0 = ret.Get(0).([]ports.DMABuf)
405 }405 }
406 }406 }
407- if returnFunc, ok := ret.Get(1).(func(int, []uint64) error); ok {407+ if returnFunc, ok := ret.Get(1).(func(int, []uint64, bool) error); ok {
408- r1 = returnFunc(n, modifiers)408+ r1 = returnFunc(n, modifiers, singlePlane)
409 } else {409 } else {
410 r1 = ret.Error(1)410 r1 = ret.Error(1)
411 }411 }
@@ -420,11 +420,12 @@ type MockRenderer_ExportTargets_Call struct {
420 // ExportTargets is a helper method to define mock.On call420 // ExportTargets is a helper method to define mock.On call
421 // - n int421 // - n int
422 // - modifiers []uint64422 // - modifiers []uint64
423-func (_e *MockRenderer_Expecter) ExportTargets(n any, modifiers any) *MockRenderer_ExportTargets_Call {423+// - singlePlane bool
424- return &MockRenderer_ExportTargets_Call{Call: _e.mock.On("ExportTargets", n, modifiers)}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)}
425 }426 }
426 427
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 {
428 _c.Call.Run(func(args mock.Arguments) {429 _c.Call.Run(func(args mock.Arguments) {
429 var arg0 int430 var arg0 int
430 if args[0] != nil {431 if args[0] != nil {
@@ -434,9 +435,14 @@ func (_c *MockRenderer_ExportTargets_Call) Run(run func(n int, modifiers []uint6
434 if args[1] != nil {435 if args[1] != nil {
435 arg1 = args[1].([]uint64)436 arg1 = args[1].([]uint64)
436 }437 }
438+ var arg2 bool
439+ if args[2] != nil {
440+ arg2 = args[2].(bool)
441+ }
437 run(442 run(
438 arg0,443 arg0,
439 arg1,444 arg1,
445+ arg2,
440 )446 )
441 })447 })
442 return _c448 return _c
@@ -447,7 +453,7 @@ func (_c *MockRenderer_ExportTargets_Call) Return(dMABufs []ports.DMABuf, err er
447 return _c453 return _c
448 }454 }
449 455
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 {
451 _c.Call.Return(run)457 _c.Call.Return(run)
452 return _c458 return _c
453 }459 }
M internal/ports/config.go +8 -1
@@ -143,7 +143,14 @@ type Config struct {
143 // Cursor.HideAfter hides the pointer cursor after this long without143 // Cursor.HideAfter hides the pointer cursor after this long without
144 // motion; the next motion shows it. 0 never hides it.144 // motion; the next motion shows it. 0 never hides it.
145 Cursor struct{ HideAfter time.Duration }145 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 {
147 // FollowMove shows the target workspace after a column or window154 // FollowMove shows the target workspace after a column or window
148 // moves to it.155 // moves to it.
149 FollowMove bool156 FollowMove bool
M internal/ports/damage_test.go +20 -0
@@ -35,3 +35,23 @@ func TestDamageSince(t *testing.T) {
35 t.Fatal("stale history used")35 t.Fatal("stale history used")
36 }36 }
37 }37 }
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 {
47 // ExportTargets allocates n images of the renderer's size that the47 // ExportTargets allocates n images of the renderer's size that the
48 // display can scan out, with one of the given XRGB8888 (SDR) or48 // display can scan out, with one of the given XRGB8888 (SDR) or
49 // XRGB2101010 (HDR) modifiers (none: any the device exports),49 // 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.
51 // n = 0 drops the targets.53 // n = 0 drops the targets.
52- ExportTargets(n int, modifiers []uint64) ([]DMABuf, error)54+ ExportTargets(n int, modifiers []uint64, singlePlane bool) ([]DMABuf, error)
53 // UseTarget selects the exported image the next Render draws into.55 // UseTarget selects the exported image the next Render draws into.
54 UseTarget(i int)56 UseTarget(i int)
55 // CursorBuffers allocates two linear ARGB8888 images of size×size the57 // CursorBuffers allocates two linear ARGB8888 images of size×size the
M internal/ports/scene.go +65 -7
@@ -4,6 +4,7 @@ import (
4 "image"4 "image"
5 "math"5 "math"
6 "os"6 "os"
7+ "slices"
7 )8 )
8 9
9 // Scene carries core → renderer immutable snapshots with fresh Windows slices,10 // Scene carries core → renderer immutable snapshots with fresh Windows slices,
@@ -68,6 +69,37 @@ type Scene struct {
68 CaptureIndicators []CaptureIndicator69 CaptureIndicators []CaptureIndicator
69 }70 }
70 71
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+
71 // Shows reports whether the scene draws the surface of id: only its103 // Shows reports whether the scene draws the surface of id: only its
72 // content changes need a new frame. A window scrolled off the output is104 // content changes need a new frame. A window scrolled off the output is
73 // not drawn. WorkspaceClip also excludes off-viewport windows; OverLayers105 // not drawn. WorkspaceClip also excludes off-viewport windows; OverLayers
@@ -175,6 +207,11 @@ type SurfaceContent struct {
175 // Opaque ignores the alpha byte: an x format, or an opaque region that207 // Opaque ignores the alpha byte: an x format, or an opaque region that
176 // covers the whole surface. It is false while Fade is set.208 // covers the whole surface. It is false while Fade is set.
177 Opaque bool209 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
178 // Fade is how much wp_alpha_modifier_v1 fades the surface: 0 none,215 // Fade is how much wp_alpha_modifier_v1 fades the surface: 0 none,
179 // 1 invisible. Its opacity is multiplied by 1-Fade.216 // 1 invisible. Its opacity is multiplied by 1-Fade.
180 Fade float32217 Fade float32
@@ -270,24 +307,35 @@ type SeqDamage struct {
270 // DamageSince is the union of changes after content seq up to this one,307 // DamageSince is the union of changes after content seq up to this one,
271 // and false when the history does not reach back to seq (redraw all).308 // and false when the history does not reach back to seq (redraw all).
272 func (c SurfaceContent) DamageSince(seq uint64) ([]Rect, bool) {309 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) {
273 if seq >= c.Seq {321 if seq >= c.Seq {
274- return nil, true322+ return dst, true
275 }323 }
276 h := c.DamageHistory324 h := c.DamageHistory
277 if len(h) == 0 || h[0].Seq > seq+1 || h[len(h)-1].Seq != c.Seq {325 if len(h) == 0 || h[0].Seq > seq+1 || h[len(h)-1].Seq != c.Seq {
278- return nil, false326+ return dst, false
279 }327 }
280- var out []Rect328+ n := len(dst)
281 for _, d := range h {329 for _, d := range h {
282 if d.Seq <= seq {330 if d.Seq <= seq {
283 continue331 continue
284 }332 }
285 if d.Full {333 if d.Full {
286- return nil, false334+ return dst[:n], false
287 }335 }
288- out = append(out, d.Rects...)336+ dst = append(dst, d.Rects...)
289 }337 }
290- return out, true338+ return dst, true
291 }339 }
292 340
293 // Subsurface is a child surface at X, Y logical pixels from the root341 // Subsurface is a child surface at X, Y logical pixels from the root
@@ -371,6 +419,11 @@ type SceneWindow struct {
371 // Dim darkens the window, border included, with black at this419 // Dim darkens the window, border included, with black at this
372 // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing.420 // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing.
373 Dim float64421 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
374 // FocusEffect, above 0, is the focus indicator's effect at this frame427 // FocusEffect, above 0, is the focus indicator's effect at this frame
375 // (focus.animation, focus.effect): the window's surfaces are lifted428 // (focus.animation, focus.effect): the window's surfaces are lifted
376 // toward white by that fraction (screen blend). Renderers without the429 // toward white by that fraction (screen blend). Renderers without the
@@ -382,8 +435,13 @@ type SceneWindow struct {
382 // needs its kind here and in the push constants (a misc flag).435 // needs its kind here and in the push constants (a misc flag).
383 FocusEffect float64436 FocusEffect float64
384 // Preview, above 0, draws the window's surfaces that much smaller in437 // 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.
386 Preview float64440 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
387 // Popups are drawn from their content only: no border, no background.445 // Popups are drawn from their content only: no border, no background.
388 Popup bool446 Popup bool
389 // OverLayers popups hang from a layer surface: drawn over the top and447 // 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 23

Tags 10

↑↓ moveenter openesc close