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 Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.4.0
122 files · +11107 -810
M Makefile +1 -1
| @@ -60,7 +60,7 @@ arch: | |||
| 60 | 60 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | |
| 61 | 61 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 62 | 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=1 | |
| 63 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | |
| 64 | 64 | check: vet test arch fakes-check log-check perf-check | |
| 65 | 65 | ||
| 66 | 66 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | |
M README.md +2 -2
| @@ -18,7 +18,7 @@ | |||
| 18 | 18 | ||
| 19 | 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 | 23 | ## OK, but why Go? | |
| 24 | 24 | ||
| @@ -65,7 +65,7 @@ A full build takes seconds, and tests, race detection, profiling and formatting | |||
| 65 | 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 | 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 | 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 | 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 | 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 | 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 | 44 | | `mouse.accel-profile` | `adaptive` | `adaptive`: faster moves go further; `flat`: constant speed | | |
| 45 | 45 | | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons | | |
| 46 | 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 | 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 | 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 | 52 | | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` | | |
| 51 | 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 | 137 | ## 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 | 141 | - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen. | |
| 140 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 21 | ## 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 | 25 | ## Measuring | |
| 23 | 26 | ||
M examples/config +9 -0
| @@ -72,6 +72,15 @@ cursor.hide-after = 5s | |||
| 72 | 72 | ||
| 73 | 73 | # on: moving a column or window to another workspace shows that workspace. | |
| 74 | 74 | 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 | 84 | # Focus indicator on the newly focused window: an animation (pulse or off) | |
| 76 | 85 | # of an effect (screen: a slight brightening), peaking at strength | |
| 77 | 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 | 169 | c.Touchpad.ScrollFactor = 1 | |
| 170 | 170 | c.Mouse.AccelProfile = ports.AccelAdaptive | |
| 171 | 171 | 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 | 175 | c.Focus.Animation = ports.FocusAnimationPulse | |
| 173 | 176 | c.Focus.Effect = ports.FocusEffectScreen | |
| 174 | 177 | c.Focus.Strength = 0.04 | |
| @@ -749,6 +752,23 @@ func set(c *ports.Config, key, v string) error { | |||
| 749 | 752 | return fmt.Errorf("must be off or a duration between 100ms and %s, e.g. 5s", maxCursorHideAfter) | |
| 750 | 753 | } | |
| 751 | 754 | 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 | 772 | case "focus.follow-move": | |
| 753 | 773 | b, err := onOff(v) | |
| 754 | 774 | if err != nil { | |
| @@ -1007,6 +1027,14 @@ func positive(dst *int, v string, max int) error { | |||
| 1007 | 1027 | 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 | 1038 | func onOff(v string) (bool, error) { | |
| 1011 | 1039 | switch v { | |
| 1012 | 1040 | case "on": | |
M internal/adapters/config/config_test.go +31 -0
| @@ -2,6 +2,7 @@ package config | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "math" | |
| 5 | 6 | "os" | |
| 6 | 7 | "path/filepath" | |
| 7 | 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 | 112 | func TestFocusIndicator(t *testing.T) { | |
| 82 | 113 | d := Defaults().Focus | |
| 83 | 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 | 420 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 421 | 421 | } | |
| 422 | 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 | 424 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 425 | 425 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 426 | 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 | 339 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 340 | 340 | } | |
| 341 | 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 | 343 | display.EXPECT().UseTarget(mock.Anything).Return() | |
| 344 | 344 | display.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 345 | 345 | display.EXPECT().Close().Return().Once() | |
| @@ -447,7 +447,7 @@ func TestRunCaptureWaitsForItsIndicatorScene(t *testing.T) { | |||
| 447 | 447 | } | |
| 448 | 448 | r := portsmocks.NewMockRenderer(t) | |
| 449 | 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 | 451 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 452 | 452 | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 453 | 453 | switch { | |
| @@ -543,7 +543,7 @@ func TestRunCaptureWithoutIndicatorFailsAfterTheBoundedWait(t *testing.T) { | |||
| 543 | 543 | } | |
| 544 | 544 | r := portsmocks.NewMockRenderer(t) | |
| 545 | 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 | 547 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 548 | 548 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 549 | 549 | r.EXPECT().Close().Return().Once() | |
M internal/adapters/drm/commit_test.go +231 -10
| @@ -2,6 +2,7 @@ package drm | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "os" | |
| 6 | 7 | "testing" | |
| 7 | 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 back | |
| 115 | - | // to linear images when KMS refuses the driver's. | |
| 115 | + | // tiledMod stands for a non-linear driver modifier in the image tests. | |
| 116 | + | const tiledMod = 0x200000000401b03 | |
| 117 | + | ||
| 118 | + | // showImages validates the images with a TEST_ONLY modeset, retries with | |
| 119 | + | // single-plane driver images, then falls back to linear images when KMS | |
| 120 | + | // refuses both. | |
| 116 | 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 | 123 | o.cursor = nil | |
| 119 | 124 | r := portsmocks.NewMockRenderer(t) | |
| 120 | 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 | 136 | f, w, _ := os.Pipe() | |
| 122 | 137 | w.Close() | |
| 123 | 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 | 145 | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |
| 126 | - | r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 127 | - | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |
| 128 | - | r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 146 | + | r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 147 | + | r.EXPECT().ExportTargets(2, []uint64(nil), true).Return([]ports.DMABuf{tiled1(), tiled1()}, nil).Once() | |
| 148 | + | r.EXPECT().ExportTargets(2, []uint64{0}, false).Return([]ports.DMABuf{linear(), linear()}, nil).Once() | |
| 129 | 149 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 130 | 150 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 131 | 151 | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil) | |
| @@ -135,7 +155,7 @@ func TestShowImagesTestsBeforeModeset(t *testing.T) { | |||
| 135 | 155 | if err := o.showImages(r, imagesDriver, nil); err != nil { | |
| 136 | 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 | 159 | if len(*commits) != len(want) { | |
| 140 | 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 | 373 | // Enabling an output whose images were not made yet (started switched | |
| 153 | 374 | // away) tests them and modesets once. | |
| 154 | 375 | func TestEnableAfterStartAwayModesetsOnce(t *testing.T) { | |
| @@ -161,7 +382,7 @@ func TestEnableAfterStartAwayModesetsOnce(t *testing.T) { | |||
| 161 | 382 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 162 | 383 | } | |
| 163 | 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 | 386 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 166 | 387 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 167 | 388 | r.EXPECT().Close().Return().Once() | |
| @@ -262,7 +483,7 @@ func TestRunReportsSeenAfterCursorCommit(t *testing.T) { | |||
| 262 | 483 | r.EXPECT().CursorBuffers(64).Return([2]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 263 | 484 | r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).Return(nil) | |
| 264 | 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 | 487 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 267 | 488 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 268 | 489 | r.EXPECT().Close().Return().Once() | |
| @@ -355,7 +576,7 @@ func TestRunClosesFrameFences(t *testing.T) { | |||
| 355 | 576 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 356 | 577 | } | |
| 357 | 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 | 580 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 360 | 581 | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 361 | 582 | f, w, _ := os.Pipe() | |
M internal/adapters/drm/drop_hint_test.go +3 -3
| @@ -9,15 +9,15 @@ import ( | |||
| 9 | 9 | func TestDropHintsBlockPlanes(t *testing.T) { | |
| 10 | 10 | s, c := overlayScene() | |
| 11 | 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 | 13 | t.Fatalf("overlay reason %q", reason) | |
| 14 | 14 | } | |
| 15 | 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 | 17 | t.Fatal("no hints, still refused") | |
| 18 | 18 | } | |
| 19 | 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 | 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 | 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 | 77 | o.cursor = nil | |
| 78 | 78 | o.hdr.cap = hdrCapability{Capable: true} | |
| 79 | 79 | o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: 203} | |
| @@ -88,9 +88,9 @@ func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) { | |||
| 88 | 88 | var calls []string | |
| 89 | 89 | r.EXPECT().SetHDR(float64(203)).Run(func(float64) { calls = append(calls, "hdr") }).Return().Once() | |
| 90 | 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() | |
| 92 | - | r.EXPECT().ExportTargets(0, []uint64(nil)).Run(func(int, []uint64) { calls = append(calls, "drop") }).Return(nil, nil).Once() | |
| 93 | - | r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 91 | + | r.EXPECT().ExportTargets(2, []uint64{0}, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 92 | + | r.EXPECT().ExportTargets(0, []uint64(nil), false).Run(func(int, []uint64, bool) { calls = append(calls, "drop") }).Return(nil, nil).Once() | |
| 93 | + | r.EXPECT().ExportTargets(2, []uint64(nil), false).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 94 | 94 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 95 | 95 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 96 | 96 | k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil) | |
| @@ -108,16 +108,16 @@ func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) { | |||
| 108 | 108 | if o.hdr.on || !o.hdr.failed { | |
| 109 | 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 | 114 | t.Fatalf("commits: %d", len(*commits)) | |
| 113 | 115 | } | |
| 114 | - | for i := range 2 { | |
| 115 | - | if (*commits)[i].flags != atomicTestOnly|atomicAllowModes { | |
| 116 | - | t.Fatalf("HDR test %d flags: %#x", i, (*commits)[i].flags) | |
| 117 | - | } | |
| 116 | + | if (*commits)[0].flags != atomicTestOnly|atomicAllowModes { | |
| 117 | + | t.Fatalf("HDR test flags: %#x", (*commits)[0].flags) | |
| 118 | 118 | } | |
| 119 | - | if (*commits)[2].flags != atomicTestOnly|atomicAllowModes || (*commits)[3].flags != atomicAllowModes { | |
| 120 | - | t.Fatalf("SDR commits: %#x %#x", (*commits)[2].flags, (*commits)[3].flags) | |
| 119 | + | if (*commits)[1].flags != atomicTestOnly|atomicAllowModes || (*commits)[2].flags != atomicAllowModes { | |
| 120 | + | t.Fatalf("SDR commits: %#x %#x", (*commits)[1].flags, (*commits)[2].flags) | |
| 121 | 121 | } | |
| 122 | 122 | if v, _ := (*commits)[0].req.value(o.conn.id, 100); v != 321 { | |
| 123 | 123 | t.Fatalf("HDR test metadata: %d", v) | |
| @@ -160,7 +160,7 @@ func TestHDRSuccessfulModeset(t *testing.T) { | |||
| 160 | 160 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 161 | 161 | } | |
| 162 | 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 | 164 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 165 | 165 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 166 | 166 | k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil).Twice() | |
| @@ -201,7 +201,7 @@ func TestHDRBlobReusedAcrossImageSetup(t *testing.T) { | |||
| 201 | 201 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 202 | 202 | } | |
| 203 | 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 | 205 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 206 | 206 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 207 | 207 | k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil) | |
| @@ -251,7 +251,7 @@ func TestHDRBlobReplacementWaitsForCommit(t *testing.T) { | |||
| 251 | 251 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 252 | 252 | } | |
| 253 | 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 | 255 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 256 | 256 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 257 | 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 | 17 | type imageKind int | |
| 18 | 18 | ||
| 19 | 19 | const ( | |
| 20 | - | imagesDriver imageKind = iota // exported, modifier chosen by the driver | |
| 21 | - | imagesLinear // exported, linear | |
| 20 | + | imagesDriver imageKind = iota // exported, modifier chosen by the driver (DCC planes allowed) | |
| 21 | + | imagesSinglePlane // exported, driver modifier with one memory plane | |
| 22 | + | imagesLinear // exported, linear | |
| 22 | 23 | ) | |
| 23 | 24 | ||
| 25 | + | // maxFBPlanes is the number of planes a KMS framebuffer holds (fbCmd2). | |
| 26 | + | const maxFBPlanes = 4 | |
| 27 | + | ||
| 24 | 28 | // showImages sets up images from kind on and modesets them. KMS may refuse | |
| 25 | 29 | // an image only in a modeset: a TEST_ONLY modeset checks it first, and the | |
| 26 | 30 | // 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 | 61 | o.hdr.on = false | |
| 58 | 62 | o.freeImages() | |
| 59 | 63 | r.SetHDR(0) | |
| 60 | - | _, _ = r.ExportTargets(0, nil) | |
| 64 | + | _, _ = r.ExportTargets(0, nil, false) | |
| 61 | 65 | // A failed test commit can leave the old HDR mode on screen; | |
| 62 | 66 | // retain its blob until the SDR modeset succeeds or Close restores it. | |
| 63 | 67 | } else { | |
| @@ -97,9 +101,20 @@ func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) er | |||
| 97 | 101 | o.freeImages() | |
| 98 | 102 | 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 | 110 | o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Msg("modeset refused the output images") | |
| 101 | 111 | o.freeImages() | |
| 102 | 112 | 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 | 127 | if o.runContext != nil && o.runContext.Err() != nil { | |
| 113 | 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 | 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 | 145 | if len(mods) == 0 { | |
| 123 | 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 | 154 | } else if kind == imagesLinear { | |
| 132 | 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 | 163 | return kind, nil | |
| 136 | 164 | } | |
| 137 | 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 | 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 | 190 | // exportImages makes the renderer's exported targets the output images. | |
| 143 | - | func (o *Output) exportImages(r ports.Renderer, mods []uint64) error { | |
| 144 | - | bufs, err := r.ExportTargets(len(o.fbs), mods) | |
| 191 | + | func (o *Output) exportImages(r ports.Renderer, mods []uint64, singlePlane bool) error { | |
| 192 | + | bufs, err := r.ExportTargets(len(o.fbs), mods, singlePlane) | |
| 145 | 193 | if err != nil { | |
| 146 | 194 | return err | |
| 147 | 195 | } | |
| 196 | + | o.planes, o.imageMod = 0, 0 | |
| 148 | 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 | 201 | if err == nil { | |
| 150 | - | format := uint32(fourccXRGB) | |
| 151 | - | if o.hdr.on { | |
| 152 | - | format = fourccXR30 | |
| 153 | - | } | |
| 154 | - | o.fbs[i], err = o.k.addFB(&bufs[i], format) | |
| 202 | + | o.fbs[i], err = o.k.addFB(&bufs[i], o.imageFormat()) | |
| 203 | + | } | |
| 204 | + | // Every plane owns its file; the framebuffer keeps its own | |
| 205 | + | // reference to the buffer. | |
| 206 | + | for _, p := range bufs[i].Planes { | |
| 207 | + | p.File.Close() | |
| 155 | 208 | } | |
| 156 | - | bufs[i].Planes[0].File.Close() | |
| 157 | 209 | } | |
| 158 | 210 | // GPU memory starts undefined (old VRAM contents): clear both images | |
| 159 | 211 | // before the modeset shows one. | |
| @@ -175,10 +227,11 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error { | |||
| 175 | 227 | } | |
| 176 | 228 | if err != nil { | |
| 177 | 229 | o.freeImages() | |
| 178 | - | _, _ = r.ExportTargets(0, nil) | |
| 230 | + | _, _ = r.ExportTargets(0, nil, false) | |
| 179 | 231 | 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 | 235 | 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 | 12 | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 13 | 13 | "github.com/bnema/neferwl/internal/adapters/clock" | |
| 14 | 14 | "github.com/bnema/neferwl/internal/adapters/presented" | |
| 15 | + | "github.com/bnema/neferwl/internal/adapters/surfaces" | |
| 15 | 16 | ||
| 16 | 17 | "github.com/bnema/neferwl/internal/ports" | |
| 17 | 18 | "github.com/bnema/zerowrap" | |
| @@ -35,6 +36,10 @@ type Output struct { | |||
| 35 | 36 | securityInvalid bool | |
| 36 | 37 | inactiveOnClose bool // affirmative terminal KMS result, owned by Close | |
| 37 | 38 | 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 | 44 | k kms | |
| 40 | 45 | flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents | |
| @@ -176,6 +181,10 @@ type Output struct { | |||
| 176 | 181 | protectNotBefore time.Time | |
| 177 | 182 | // kind is how the images were made. | |
| 178 | 183 | 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 | 188 | // formats receives the direct scanout formats after each modeset; | |
| 180 | 189 | // sampled and device are what they are built from (Want). | |
| 181 | 190 | formats chan<- ports.OutputFormats | |
| @@ -1026,7 +1035,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1026 | 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 | 1041 | var scene ports.Scene | |
| 1031 | 1042 | haveScene, dirty := false, false | |
| 1032 | 1043 | var want ports.CursorChange | |
| @@ -1334,6 +1345,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1334 | 1345 | if !o.sceneCurrent(s) { | |
| 1335 | 1346 | 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 | 1352 | if o.protected { | |
| 1338 | 1353 | s.CaptureScene = nil | |
| 1339 | 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 | 1363 | scene, haveScene, dirty = s, true, true | |
| 1349 | 1364 | o.wantOff = s.Off | |
| 1365 | + | // A closed window's last content goes once no scene draws it. | |
| 1366 | + | table.Prune(scene) | |
| 1350 | 1367 | case c := <-cursor: | |
| 1351 | 1368 | o.observeSecurity() | |
| 1352 | 1369 | if o.protected { | |
| @@ -1359,11 +1376,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1359 | 1376 | stateDirty = true | |
| 1360 | 1377 | } | |
| 1361 | 1378 | case c := <-contents: | |
| 1362 | - | if c.Empty() { | |
| 1363 | - | delete(surfaces, c.ID) | |
| 1364 | - | } else { | |
| 1365 | - | surfaces[c.ID] = c | |
| 1366 | - | } | |
| 1379 | + | table.Update(c, scene) | |
| 1367 | 1380 | if c.Seq > seen[c.ID] { | |
| 1368 | 1381 | seen[c.ID] = c.Seq | |
| 1369 | 1382 | reportDirty = true | |
| @@ -1463,7 +1476,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1463 | 1476 | continue | |
| 1464 | 1477 | } | |
| 1465 | 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 | 1480 | var fatal renderError | |
| 1468 | 1481 | if errors.As(err, &fatal) || errors.Is(err, errSecurityScene) { | |
| 1469 | 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 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "image" | |
| 8 | + | "maps" | |
| 8 | 9 | "os" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "sync" | |
| @@ -565,7 +566,7 @@ func TestRunReportsSeenAfterFlip(t *testing.T) { | |||
| 565 | 566 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 566 | 567 | } | |
| 567 | 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 | 570 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 570 | 571 | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 571 | 572 | f, w, _ := os.Pipe() | |
| @@ -651,7 +652,7 @@ func TestRunComposedFullscreenKeepsVRR(t *testing.T) { | |||
| 651 | 652 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 652 | 653 | } | |
| 653 | 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 | 656 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 656 | 657 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 657 | 658 | r.EXPECT().Close().Return().Once() | |
| @@ -790,7 +791,7 @@ func TestCaptureRenderFailureClosesPendingRequest(t *testing.T) { | |||
| 790 | 791 | t.Cleanup(func() { _ = f.Close() }) | |
| 791 | 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 | 795 | r.EXPECT().Render(ports.Scene{Background: "#000000"}, mock.Anything).Return(nil, nil).Twice() | |
| 795 | 796 | r.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Background != "#000000" }), mock.Anything).Return(nil, boom).Once() | |
| 796 | 797 | r.EXPECT().Close().Return().Once() | |
| @@ -852,7 +853,7 @@ func TestCaptureForcesDRMComposition(t *testing.T) { | |||
| 852 | 853 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 853 | 854 | } | |
| 854 | 855 | 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 | 857 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 857 | 858 | rendered := make(chan ports.Scene, 3) | |
| 858 | 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 | 969 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 969 | 970 | } | |
| 970 | 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 | 973 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 973 | 974 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 974 | 975 | r.EXPECT().Close().Return().Once() | |
| @@ -1052,3 +1053,111 @@ func TestRunSecurityResetClearsWantedAt(t *testing.T) { | |||
| 1052 | 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 | 59 | // overlayCandidate finds the one window the overlay can show: an opaque | |
| 60 | 60 | // dmabuf at integer physical coordinates, drawn 1:1, with no other window | |
| 61 | 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 | 66 | if s.Transform != 0 { | |
| 64 | 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 | 85 | // A viewport crop is composed: the plane would show the whole | |
| 83 | 86 | // buffer, and so is a colour the plane cannot show (raw PQ values | |
| 84 | 87 | // on an SDR output, SDR on HDR without a pipeline). A dimmed | |
| 85 | - | // window (a peeking stashed one) needs the veil drawn over it, and | |
| 86 | - | // one with the focus effect needs its lift; an overview preview is | |
| 87 | - | // drawn smaller than its buffer. | |
| 88 | - | if w.Dim <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) { | |
| 88 | + | // window (a peeking stashed one) needs the veil drawn over it, a | |
| 89 | + | // fading one its translucency, and one with the focus effect its | |
| 90 | + | // lift; an overview preview is drawn smaller than its buffer. | |
| 91 | + | if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) { | |
| 89 | 92 | if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" { | |
| 90 | 93 | pick, mode = w, m | |
| 91 | 94 | continue | |
| @@ -97,6 +100,15 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 97 | 100 | } | |
| 98 | 101 | } | |
| 99 | 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 | 112 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "no_candidate" | |
| 101 | 113 | } | |
| 102 | 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 | 43 | func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) { | |
| 44 | 44 | s, contents := fullscreenScene() | |
| 45 | 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 | 47 | t.Fatalf("fullscreen bypassed clip: %q", reason) | |
| 48 | 48 | } | |
| 49 | 49 | if fullscreenShown(&s) { | |
| @@ -54,28 +54,28 @@ func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) { | |||
| 54 | 54 | func TestOverlayWorkspaceClip(t *testing.T) { | |
| 55 | 55 | s, contents := overlayScene() | |
| 56 | 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 | 58 | t.Fatalf("overflowing workspace overlay: %q", reason) | |
| 59 | 59 | } | |
| 60 | 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 | 62 | t.Fatalf("contained workspace overlay: %v %q", win.ID, reason) | |
| 63 | 63 | } | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | 66 | func TestOverlayCandidate(t *testing.T) { | |
| 67 | 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 | 69 | t.Fatalf("candidate %v %q", w.ID, reason) | |
| 70 | 70 | } | |
| 71 | 71 | // A rotated output composes: planes are not rotated. | |
| 72 | 72 | rs, rc := overlayScene() | |
| 73 | 73 | 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 | 75 | t.Fatalf("rotated output reason %q", reason) | |
| 76 | 76 | } | |
| 77 | 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 | 79 | t.Fatalf("YUV overlay reason %q", reason) | |
| 80 | 80 | } | |
| 81 | 81 | // A transformed buffer is composed: the plane would show it unrotated. | |
| @@ -83,46 +83,58 @@ func TestOverlayCandidate(t *testing.T) { | |||
| 83 | 83 | rotated := c[2] | |
| 84 | 84 | rotated.Transform = 1 | |
| 85 | 85 | 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 | 87 | t.Fatalf("transformed overlay reason %q", reason) | |
| 88 | 88 | } | |
| 89 | 89 | _, c = overlayScene() | |
| 90 | 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 | 92 | t.Fatalf("reason %q", reason) | |
| 93 | 93 | } | |
| 94 | 94 | s, _ = overlayScene() | |
| 95 | 95 | 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 | 97 | t.Fatalf("reason %q", reason) | |
| 98 | 98 | } | |
| 99 | 99 | s, _ = overlayScene() | |
| 100 | 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 | 102 | t.Fatalf("reason %q", reason) | |
| 103 | 103 | } | |
| 104 | 104 | // The dim veil is composed under the float: nothing leaves for the plane. | |
| 105 | 105 | s, _ = overlayScene() | |
| 106 | 106 | 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 | 108 | t.Fatalf("dim reason %q", reason) | |
| 109 | 109 | } | |
| 110 | 110 | // A dimmed window (a peeking stashed one) needs its veil composed. | |
| 111 | 111 | s, _ = overlayScene() | |
| 112 | 112 | 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 | 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 | 128 | // A window with the focus effect needs it composed. | |
| 117 | 129 | s, _ = overlayScene() | |
| 118 | 130 | 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 | 132 | t.Fatalf("pulsing window reason %q", reason) | |
| 121 | 133 | } | |
| 122 | 134 | // An overview preview is drawn smaller than its buffer: composed. | |
| 123 | 135 | s, _ = overlayScene() | |
| 124 | 136 | 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 | 138 | t.Fatalf("preview reason %q", reason) | |
| 127 | 139 | } | |
| 128 | 140 | // A viewport crop is composed: the plane would show the whole buffer. | |
| @@ -130,13 +142,13 @@ func TestOverlayCandidate(t *testing.T) { | |||
| 130 | 142 | cropped := c[2] | |
| 131 | 143 | cropped.Source = [4]float32{10, 10, 50, 50} | |
| 132 | 144 | 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 | 146 | t.Fatalf("cropped overlay reason %q", reason) | |
| 135 | 147 | } | |
| 136 | 148 | // A full-buffer source is no crop. | |
| 137 | 149 | cropped.Source = [4]float32{0, 0, 100, 100} | |
| 138 | 150 | c[2] = cropped | |
| 139 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" { | |
| 151 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" { | |
| 140 | 152 | t.Fatalf("full source reason %q", reason) | |
| 141 | 153 | } | |
| 142 | 154 | } | |
| @@ -212,12 +224,12 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) { | |||
| 212 | 224 | func TestOverlayPopupAndBorder(t *testing.T) { | |
| 213 | 225 | s, c := overlayScene() | |
| 214 | 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 | 228 | t.Fatalf("popup: %q", reason) | |
| 217 | 229 | } | |
| 218 | 230 | s, _ = overlayScene() | |
| 219 | 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 | 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 | 25 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 26 | 26 | } | |
| 27 | 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 | 29 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 30 | 30 | renders := make(chan struct{}, 8) | |
| 31 | 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 | 133 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 134 | 134 | } | |
| 135 | 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 | 137 | r.EXPECT().UseTarget(mock.Anything).Return().Maybe() | |
| 138 | 138 | renders := make(chan struct{}, 8) | |
| 139 | 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 | 32 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 33 | 33 | } | |
| 34 | 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 | 36 | r.EXPECT().UseTarget(mock.Anything).Return().Maybe() | |
| 37 | 37 | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 38 | 38 | return renders() | |
| @@ -266,7 +266,7 @@ func TestProtectedShowImagesHDRKeepsHDRForKMSErrors(t *testing.T) { | |||
| 266 | 266 | r.EXPECT().SetHDR(float64(203)).Return().Once() | |
| 267 | 267 | r.EXPECT().UseTarget(mock.Anything).Return().Maybe() | |
| 268 | 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 | 270 | k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil).Maybe() | |
| 271 | 271 | k.EXPECT().createBlob(mock.Anything).Return(321, nil).Maybe() | |
| 272 | 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 | 31 | clear(o.showsScratch) | |
| 32 | 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 | 36 | 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 | 85 | // scanoutCandidate returns the window content the output can scan out | |
| 86 | 86 | // directly, or a reason why it cannot. w, h are the output's physical size. | |
| 87 | 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 | 93 | if s.Transform != 0 { | |
| 90 | 94 | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | |
| 91 | 95 | return ports.SurfaceContent{}, "output_transform" | |
| @@ -107,6 +111,9 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 107 | 111 | if full == nil { | |
| 108 | 112 | return ports.SurfaceContent{}, "no_fullscreen" | |
| 109 | 113 | } | |
| 114 | + | if kept != nil && kept(full.ID) { | |
| 115 | + | return ports.SurfaceContent{}, "leaving" | |
| 116 | + | } | |
| 110 | 117 | if len(s.DropHints) > 0 { | |
| 111 | 118 | // Drag hints are drawn over the window. | |
| 112 | 119 | return ports.SurfaceContent{}, "drop_hint" | |
| @@ -115,6 +122,11 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 115 | 122 | // Its veil is composed over it: the plane would show it bright. | |
| 116 | 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 | 130 | // Core leaves the layers a fullscreen window hides out of the scene: | |
| 119 | 131 | // one above the windows here is drawn. | |
| 120 | 132 | for _, l := range s.Layers { | |
| @@ -200,7 +212,7 @@ func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) { | |||
| 200 | 212 | func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent, mode colorMode) { | |
| 201 | 213 | reason := "disabled" | |
| 202 | 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 | 216 | if reason == "" { | |
| 205 | 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 | 267 | var w ports.SceneWindow | |
| 256 | 268 | var c ports.SurfaceContent | |
| 257 | 269 | 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 | 271 | if reason == "" && mode != colorBypass { | |
| 260 | 272 | if ok, known := o.overlay.verdict(c.DMABuf.Format, o.cursorShown()); known && !ok { | |
| 261 | 273 | reason = "color_refused" | |
M internal/adapters/drm/scanout_test.go +7 -4
| @@ -27,6 +27,9 @@ func TestScanoutCandidate(t *testing.T) { | |||
| 27 | 27 | {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"}, | |
| 28 | 28 | {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"}, | |
| 29 | 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 | 33 | {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"}, | |
| 31 | 34 | {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"}, | |
| 32 | 35 | {"float above", func(s *ports.Scene) { | |
| @@ -73,7 +76,7 @@ func TestScanoutCandidate(t *testing.T) { | |||
| 73 | 76 | if tc.cont != nil { | |
| 74 | 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 | 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 | 105 | root := ports.SurfaceContent{ID: 1, Seq: 7, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, SHM: &ports.SHMBuffer{}, | |
| 103 | 106 | Geometry: ports.Rect{W: 100, H: 50}, | |
| 104 | 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 | 109 | if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) { | |
| 107 | 110 | t.Fatalf("candidate %+v, reason %q", c, reason) | |
| 108 | 111 | } | |
| @@ -111,12 +114,12 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) { | |||
| 111 | 114 | // one; alone it is not a dmabuf. | |
| 112 | 115 | solidRoot := root | |
| 113 | 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 | 118 | if reason != "" || c.DMABuf != game || c.Solid != nil || c.ID != 1 || c.Seq != 7 { | |
| 116 | 119 | t.Fatalf("solid root with dmabuf child: candidate %+v, reason %q", c, reason) | |
| 117 | 120 | } | |
| 118 | 121 | 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 | 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 | 31 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 32 | 32 | } | |
| 33 | 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 | 35 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 36 | 36 | fence, signal := protectionFence(t, false) | |
| 37 | 37 | var renderedOnce atomic.Bool | |
M internal/adapters/drm/security_run_test.go +1 -1
| @@ -32,7 +32,7 @@ func TestSecurityRunWakePendingResumeAndOff(t *testing.T) { | |||
| 32 | 32 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 33 | 33 | } | |
| 34 | 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 | 36 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 37 | 37 | black := make(chan struct{}, 8) | |
| 38 | 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 | 28 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 29 | 29 | } | |
| 30 | 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 | 32 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 33 | 33 | r.EXPECT().Render(ports.Scene{Background: "#000000"}, map[ports.WindowID]ports.SurfaceContent(nil)).Return(nil, nil).Times(3) | |
| 34 | 34 | r.EXPECT().Close().Return().Once() | |
| @@ -94,8 +94,8 @@ func TestSecurityStartupClearWaitUsesRunContext(t *testing.T) { | |||
| 94 | 94 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 95 | 95 | } | |
| 96 | 96 | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |
| 97 | - | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 98 | - | r.EXPECT().ExportTargets(0, []uint64(nil)).Return(nil, nil).Once() | |
| 97 | + | r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 98 | + | r.EXPECT().ExportTargets(0, []uint64(nil), false).Return(nil, nil).Once() | |
| 99 | 99 | r.EXPECT().UseTarget(0).Return().Once() | |
| 100 | 100 | fence, _ := protectionFence(t, false) | |
| 101 | 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 | 52 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 53 | 53 | } | |
| 54 | 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 | 56 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 57 | 57 | var started atomic.Bool | |
| 58 | 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 | 92 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 93 | 93 | } | |
| 94 | 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 | 96 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 97 | 97 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 98 | 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 | 15 | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 16 | 16 | "github.com/bnema/neferwl/internal/adapters/clock" | |
| 17 | 17 | "github.com/bnema/neferwl/internal/adapters/presented" | |
| 18 | + | "github.com/bnema/neferwl/internal/adapters/surfaces" | |
| 18 | 19 | "github.com/bnema/neferwl/internal/adapters/syncfile" | |
| 19 | 20 | "github.com/bnema/neferwl/internal/ports" | |
| 20 | 21 | "github.com/bnema/zerowrap" | |
| @@ -66,10 +67,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 66 | 67 | var confirmed *ports.OutputHDR | |
| 67 | 68 | if opts.HDR { | |
| 68 | 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 | 71 | opts.Log.Warn().Err(err).Str("output", opts.Name).Msg("virtual HDR unavailable; falling back to SDR") | |
| 71 | 72 | r.SetHDR(0) | |
| 72 | - | _, _ = r.ExportTargets(0, nil) | |
| 73 | + | _, _ = r.ExportTargets(0, nil, false) | |
| 73 | 74 | } else { | |
| 74 | 75 | for _, b := range bufs { | |
| 75 | 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 | 93 | var scene ports.Scene | |
| 92 | 94 | haveScene, dirty := false, false | |
| 93 | 95 | frame := 0 | |
| @@ -199,16 +201,18 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 199 | 201 | if opts.Security != nil && (s.Security != security || s.Security != opts.Security.Snapshot()) { | |
| 200 | 202 | 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 | 208 | 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 | 212 | update := func(c ports.SurfaceContent) { | |
| 205 | 213 | seen[c.ID] = max(seen[c.ID], c.Seq) | |
| 206 | 214 | dirty = dirty || capture.Shows(scene, c.ID) | |
| 207 | - | if c.Empty() { | |
| 208 | - | delete(surfaces, c.ID) | |
| 209 | - | } else { | |
| 210 | - | surfaces[c.ID] = c | |
| 211 | - | } | |
| 215 | + | table.Update(c, scene) | |
| 212 | 216 | } | |
| 213 | 217 | clk := opts.Clock | |
| 214 | 218 | if clk == nil { | |
| @@ -469,7 +473,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 469 | 473 | failRequests("security epoch changed") | |
| 470 | 474 | continue | |
| 471 | 475 | } | |
| 472 | - | pipeline.SubmitHidden(scene, surfaces, hidden) | |
| 476 | + | pipeline.SubmitHidden(scene, drawn, hidden) | |
| 473 | 477 | clear(hidden) | |
| 474 | 478 | // A slow child may outlive Wayland's stale-report timeout even | |
| 475 | 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 | 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 | 498 | if pdone != nil { | |
| 495 | 499 | // The plain render reads client buffers even when its requests | |
| 496 | 500 | // 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 | 521 | failRequests("security epoch changed") | |
| 518 | 522 | continue | |
| 519 | 523 | } | |
| 520 | - | xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), surfaces) | |
| 524 | + | xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn) | |
| 521 | 525 | if xdone != nil { | |
| 522 | 526 | // The excluded-frame composition reads client buffers even when the displayed | |
| 523 | 527 | // 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 | 549 | failRequests("security epoch changed") | |
| 546 | 550 | continue | |
| 547 | 551 | } | |
| 548 | - | done, err := r.Render(scene, surfaces) | |
| 552 | + | done, err := r.Render(scene, drawn) | |
| 549 | 553 | if err != nil { | |
| 550 | 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 | 579 | pipeline.EndGate(nil) | |
| 576 | 580 | requests = capture.Waiting(requests, scene) | |
| 577 | 581 | frame++ | |
| 578 | - | opts.report(&reports, flipInfo(scene, surfaces), seen) | |
| 582 | + | opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen) | |
| 579 | 583 | if opts.ScreenshotDir != "" && !opts.protected() { | |
| 580 | 584 | shot := r.Pixels() | |
| 581 | 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 | 671 | // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC | |
| 666 | 672 | // time (software clock, refresh unknown), so presentation feedback and | |
| 667 | 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 | 675 | var ts unix.Timespec | |
| 670 | 676 | _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) | |
| 671 | 677 | shows := map[ports.WindowID]uint64{} | |
| 672 | 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 | 682 | 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 | 84 | if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 { | |
| 85 | 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 | 89 | contents <- ports.SurfaceContent{ID: 1} | |
| 88 | 90 | waitFrames(t, f, 2) | |
| 89 | 91 | if _, c := f.snapshot(); len(c) < 2 { | |
| 90 | 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 | 101 | t.Error("content not deleted") | |
| 93 | 102 | } | |
| 94 | 103 | cancel() | |
| 95 | 104 | if err := <-done; err != nil { | |
| 96 | 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 | 108 | file, err := os.Open(filepath.Join(dir, name)) | |
| 100 | 109 | if err != nil { | |
| 101 | 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 | 152 | // Content of a window the output does not show draws no frame. | |
| 112 | 153 | func TestRunSkipsContentNotShown(t *testing.T) { | |
| 113 | 154 | scenes := make(chan ports.Scene, 1) | |
| @@ -308,7 +349,7 @@ func TestHeadlessRawHDRScreenshot(t *testing.T) { | |||
| 308 | 349 | } | |
| 309 | 350 | r := portsmocks.NewMockRenderer(t) | |
| 310 | 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 | 353 | rendered := make(chan struct{}, 4) | |
| 313 | 354 | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 314 | 355 | rendered <- struct{}{} | |
| @@ -381,16 +422,16 @@ func TestHeadlessHDRFormatsConfirmed(t *testing.T) { | |||
| 381 | 422 | if err != nil { | |
| 382 | 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 | 426 | t.Cleanup(func() { | |
| 386 | 427 | if _, err := f.Stat(); err == nil { | |
| 387 | 428 | t.Error("exported fd not closed") | |
| 388 | 429 | } | |
| 389 | 430 | }) | |
| 390 | 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 | 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 | 436 | r.EXPECT().Close().Return().Once() | |
| 396 | 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 | 268 | r := portsmocks.NewMockRenderer(t) | |
| 269 | 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 | 271 | rendered := make(chan ports.Scene, 4) | |
| 272 | 272 | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 273 | 273 | 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 | 529 | hdrShot := readPixels(t, r).RGBAAt(3, 3) | |
| 530 | 530 | leased := captureWait(t, r) // HDR capture held across the toggle | |
| 531 | 531 | r.SetHDR(0) | |
| 532 | - | if _, err := r.ExportTargets(0, nil); err != nil { | |
| 532 | + | if _, err := r.ExportTargets(0, nil, false); err != nil { | |
| 533 | 533 | t.Fatal(err) | |
| 534 | 534 | } | |
| 535 | 535 | scene.Seq++ | |
| @@ -556,7 +556,7 @@ func TestCaptureSurvivesHDRToggle(t *testing.T) { | |||
| 556 | 556 | } | |
| 557 | 557 | // Back to HDR: the capture pass and slots are reused. | |
| 558 | 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 | 560 | t.Skipf("no HDR target: %v", err) | |
| 561 | 561 | } | |
| 562 | 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 | 326 | 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 | 345 | // dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA | |
| 330 | 346 | // leaves the destination at (1-alpha) of its previous value. | |
| 331 | 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 | 3 | import ( | |
| 4 | 4 | "image" | |
| 5 | 5 | "math" | |
| 6 | + | "slices" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 9 | ) | |
| @@ -10,8 +11,10 @@ import ( | |||
| 10 | 11 | // Damage: an output target keeps what it holds (scene Seq, each window's | |
| 11 | 12 | // content Seq). When the scene is the same, only the region changed since | |
| 12 | 13 | // then is redrawn: every quad is clipped to it on the CPU and the pass | |
| 13 | - | // keeps the rest of the image. A changed scene, a target never drawn or | |
| 14 | - | // a history too short redraws everything. | |
| 14 | + | // keeps the rest of the image. A scene with a new Seq redraws only the | |
| 15 | + | // windows and separators that differ from the scene the target holds | |
| 16 | + | // (sceneDelta). A scene that differs in anything else, a target never | |
| 17 | + | // drawn or a history too short redraws everything. | |
| 15 | 18 | ||
| 16 | 19 | // damageRegion collects the region of a target to redraw this frame and | |
| 17 | 20 | // what the target will hold after it: the windows drawn, their content | |
| @@ -22,6 +25,8 @@ type damageRegion struct { | |||
| 22 | 25 | seen map[ports.WindowID]bool | |
| 23 | 26 | bound image.Rectangle | |
| 24 | 27 | 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 | 32 | // heldWindow is a window a target holds: its content Seq and rect. | |
| @@ -32,11 +37,34 @@ type heldWindow struct { | |||
| 32 | 37 | ||
| 33 | 38 | func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion { | |
| 34 | 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. | |
| 36 | - | if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq { | |
| 40 | + | d.hold(tg, s) | |
| 41 | + | return d | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // hold decides what the target keeps. Seq 0 is a scene core did not | |
| 45 | + | // number (setup, tests): redraw all. The same Seq keeps everything; a new | |
| 46 | + | // one keeps all but what sceneDelta finds changed. | |
| 47 | + | func (d *damageRegion) hold(tg *target, s ports.Scene) { | |
| 48 | + | if !tg.valid || s.Seq == 0 { | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | if tg.sceneSeq == s.Seq { | |
| 37 | 52 | 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 | 70 | // 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 | 78 | r.damageDrawn = make(map[ports.WindowID]heldWindow) | |
| 51 | 79 | } | |
| 52 | 80 | clear(r.damageDrawn) | |
| 53 | - | r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds} | |
| 54 | - | if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq { | |
| 55 | - | r.damage.held = tg | |
| 56 | - | } | |
| 81 | + | r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds, rects: r.damage.rects[:0]} | |
| 82 | + | r.damage.hold(tg, s) | |
| 57 | 83 | return &r.damage | |
| 58 | 84 | } | |
| 59 | 85 | ||
| @@ -86,13 +112,12 @@ func (d *damageRegion) window(id ports.WindowID, c ports.SurfaceContent, rect im | |||
| 86 | 112 | d.add(rect) | |
| 87 | 113 | return | |
| 88 | 114 | } | |
| 89 | - | rects, known := c.DamageSince(held) | |
| 115 | + | var known bool | |
| 116 | + | d.rects, known = c.AppendDamageSince(d.rects[:0], held) | |
| 90 | 117 | if !known { | |
| 91 | 118 | d.add(rect) | |
| 92 | - | return | |
| 93 | 119 | } | |
| 94 | - | // Mapped by content, called next: nothing more here. | |
| 95 | - | _ = rects | |
| 120 | + | // Otherwise mapped by content, called next: nothing more here. | |
| 96 | 121 | } | |
| 97 | 122 | ||
| 98 | 123 | // 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 | 132 | if !ok || held == c.Seq || len(c.Children) > 0 { | |
| 108 | 133 | return | |
| 109 | 134 | } | |
| 110 | - | rects, known := c.DamageSince(held) | |
| 135 | + | var known bool | |
| 136 | + | d.rects, known = c.AppendDamageSince(d.rects[:0], held) | |
| 111 | 137 | if !known || c.Width <= 0 || c.Height <= 0 { | |
| 112 | 138 | return | |
| 113 | 139 | } | |
| 140 | + | rects := d.rects | |
| 114 | 141 | if c.Transform != 0 { | |
| 115 | 142 | d.add(dst) | |
| 116 | 143 | return | |
| @@ -162,7 +189,123 @@ func (d *damageRegion) clip(ds []draw) []draw { | |||
| 162 | 189 | } | |
| 163 | 190 | ||
| 164 | 191 | // 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 | 195 | func (tg *target) hold(s ports.Scene, d *damageRegion) { | |
| 167 | - | tg.valid, tg.sceneSeq, tg.windows = true, s.Seq, d.drawn | |
| 196 | + | tg.heldWindows = append(tg.heldWindows[:0], s.Windows...) | |
| 197 | + | tg.heldSeparators = append(tg.heldSeparators[:0], s.Separators...) | |
| 198 | + | tg.heldLayers = append(tg.heldLayers[:0], s.Layers...) | |
| 199 | + | tg.heldHints = append(tg.heldHints[:0], s.DropHints...) | |
| 200 | + | tg.heldIndicators = append(tg.heldIndicators[:0], s.CaptureIndicators...) | |
| 201 | + | tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn | |
| 202 | + | tg.scene.Windows, tg.scene.Separators, tg.scene.Layers = tg.heldWindows, tg.heldSeparators, tg.heldLayers | |
| 203 | + | tg.scene.DropHints, tg.scene.CaptureIndicators = tg.heldHints, tg.heldIndicators | |
| 204 | + | // Only whether they are set matters (any capture redraws everything): | |
| 205 | + | // sentinels, so no pointer into the caller's capture state is kept. | |
| 206 | + | tg.scene.Capture, tg.scene.CaptureScene = nil, nil | |
| 207 | + | if s.Capture != nil { | |
| 208 | + | tg.scene.Capture = &heldCapture | |
| 209 | + | } | |
| 210 | + | if s.CaptureScene != nil { | |
| 211 | + | tg.scene.CaptureScene = &heldCaptureScene | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | var ( | |
| 216 | + | heldCapture ports.SceneCapture | |
| 217 | + | heldCaptureScene ports.Scene | |
| 218 | + | ) | |
| 219 | + | ||
| 220 | + | // sceneDelta is the region (physical, through phys) where cur differs from | |
| 221 | + | // old, two scenes of one output; ok is false when the whole output must be | |
| 222 | + | // redrawn: anything but the windows and separators differs, the windows | |
| 223 | + | // are not the same list, or the region covers more than limit pixels. | |
| 224 | + | // Each window that differs (any field) adds its old and new rects, each | |
| 225 | + | // separator present in only one scene adds its own. A new Scene field | |
| 226 | + | // belongs here too (see Scene.SameAs, TestSceneSameAsCoversEveryField); a | |
| 227 | + | // new SceneWindow field is checked by TestSceneWindowFieldCount. | |
| 228 | + | func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, limit int) (image.Rectangle, bool) { | |
| 229 | + | if old.Security != cur.Security || old.Output != cur.Output || | |
| 230 | + | old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight || | |
| 231 | + | old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off || | |
| 232 | + | old.Background != cur.Background || old.Border != cur.Border || | |
| 233 | + | old.WorkspaceClip != cur.WorkspaceClip || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind || | |
| 234 | + | old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil || | |
| 235 | + | !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) || | |
| 236 | + | !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) || | |
| 237 | + | len(old.Windows) != len(cur.Windows) { | |
| 238 | + | return image.Rectangle{}, false | |
| 239 | + | } | |
| 240 | + | // windows() paints the tile lines and the Dim veil under the first | |
| 241 | + | // window that opens the floats: when that moves, the paint order of the | |
| 242 | + | // whole output changed, not only the rects of the windows that differ. | |
| 243 | + | if floatsStart(old) != floatsStart(cur) { | |
| 244 | + | return image.Rectangle{}, false | |
| 245 | + | } | |
| 246 | + | var area image.Rectangle | |
| 247 | + | for i := range old.Windows { | |
| 248 | + | a, b := old.Windows[i], cur.Windows[i] | |
| 249 | + | if a.ID != b.ID { | |
| 250 | + | return image.Rectangle{}, false | |
| 251 | + | } | |
| 252 | + | if a != b { | |
| 253 | + | area = area.Union(phys(a.Rect)).Union(phys(b.Rect)) | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | area = area.Union(separatorDelta(old.Separators, cur.Separators, phys)) | |
| 257 | + | if area.Dx()*area.Dy() > limit { | |
| 258 | + | return image.Rectangle{}, false | |
| 259 | + | } | |
| 260 | + | return area, true | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // separatorDelta is the region where the separator lists differ: a line in | |
| 264 | + | // one list more often than in the other. Core builds the lists in layout | |
| 265 | + | // order (one pass over the placements, sides in a fixed order), so between | |
| 266 | + | // two frames of the same windows they are index-aligned: equal lists cost | |
| 267 | + | // one comparison, and with lists of the same length only the pairs that | |
| 268 | + | // differ by index are counted in the other list (a focus change lights a | |
| 269 | + | // few lines, not all). Lists of different lengths (a line scrolled on or | |
| 270 | + | // off) count every line. | |
| 271 | + | func separatorDelta(old, cur []ports.Separator, phys func(ports.Rect) image.Rectangle) image.Rectangle { | |
| 272 | + | var area image.Rectangle | |
| 273 | + | if slices.Equal(old, cur) { | |
| 274 | + | return area | |
| 275 | + | } | |
| 276 | + | if len(old) == len(cur) { | |
| 277 | + | for i := range old { | |
| 278 | + | if old[i] == cur[i] { | |
| 279 | + | continue | |
| 280 | + | } | |
| 281 | + | if countSeparator(old, old[i]) != countSeparator(cur, old[i]) { | |
| 282 | + | area = area.Union(phys(old[i].Rect)) | |
| 283 | + | } | |
| 284 | + | if countSeparator(old, cur[i]) != countSeparator(cur, cur[i]) { | |
| 285 | + | area = area.Union(phys(cur[i].Rect)) | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | return area | |
| 289 | + | } | |
| 290 | + | for _, sep := range old { | |
| 291 | + | if countSeparator(old, sep) != countSeparator(cur, sep) { | |
| 292 | + | area = area.Union(phys(sep.Rect)) | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | for _, sep := range cur { | |
| 296 | + | if countSeparator(old, sep) != countSeparator(cur, sep) { | |
| 297 | + | area = area.Union(phys(sep.Rect)) | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | return area | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func countSeparator(list []ports.Separator, sep ports.Separator) int { | |
| 304 | + | n := 0 | |
| 305 | + | for _, o := range list { | |
| 306 | + | if o == sep { | |
| 307 | + | n++ | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | return n | |
| 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 | 50 | return r | |
| 51 | 51 | } | |
| 52 | 52 | damaged, full := newR(), newR() | |
| 53 | - | bufsD, err := damaged.ExportTargets(2, nil) | |
| 53 | + | bufsD, err := damaged.ExportTargets(2, nil, false) | |
| 54 | 54 | if err != nil { | |
| 55 | 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 | 15 | // udmabuf turns memfd pages into a real dmabuf: a linear buffer any GPU | |
| 16 | 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 | 18 | t.Helper() | |
| 19 | 19 | dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0) | |
| 20 | 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 | 27 | if len(r.hdrMods) == 0 { | |
| 28 | 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 | 31 | if err != nil { | |
| 32 | 32 | t.Skipf("no HDR target: %v", err) | |
| 33 | 33 | } | |
| @@ -116,7 +116,7 @@ func TestHDRPixels(t *testing.T) { | |||
| 116 | 116 | defer plain.Close() | |
| 117 | 117 | plain.SetHDR(203) | |
| 118 | 118 | plain.SetVirtualOutput(true) | |
| 119 | - | bufs, err := plain.ExportTargets(1, nil) | |
| 119 | + | bufs, err := plain.ExportTargets(1, nil, false) | |
| 120 | 120 | if err != nil { | |
| 121 | 121 | t.Skipf("no HDR target: %v", err) | |
| 122 | 122 | } | |
| @@ -149,7 +149,7 @@ func TestHDRPixelsReadsRenderedTarget(t *testing.T) { | |||
| 149 | 149 | t.Fatal(err) | |
| 150 | 150 | } | |
| 151 | 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 | 153 | if err != nil { | |
| 154 | 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 | 10 | "unsafe" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/neferwl/internal/ports" | |
| 13 | + | "github.com/bnema/zerowrap" | |
| 13 | 14 | ||
| 14 | 15 | vk "github.com/bnema/purego-vulkan/vulkan" | |
| 15 | 16 | ) | |
| @@ -35,6 +36,11 @@ type Renderer struct { | |||
| 35 | 36 | hdrNits float64 | |
| 36 | 37 | hdr hdrPass | |
| 37 | 38 | 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 | 44 | // hdrReadback makes exported HDR targets transfer sources, for GPU tests | |
| 39 | 45 | // and headless --screenshot-raw (SetHDRReadback); never DRM. | |
| 40 | 46 | hdrReadback bool | |
| @@ -340,6 +346,10 @@ func (r *Renderer) TakeRedrawn() int { | |||
| 340 | 346 | 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 | 353 | // QueuePriority is the global priority the queue got: realtime, high or | |
| 344 | 354 | // default. | |
| 345 | 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 | 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 | 397 | px := make([]byte, w*h*4) | |
| 398 | 398 | for i := 0; i < len(px); i += 4 { | |
| 399 | 399 | 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 | 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 | 406 | t.Helper() | |
| 407 | 407 | fd, err := unix.MemfdCreate("shm-test", unix.MFD_CLOEXEC) | |
| 408 | 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 | 22 | zoom float64 | |
| 23 | 23 | // pulse is the focus effect of the window being placed | |
| 24 | 24 | // (ports.SceneWindow.FocusEffect); 0 otherwise. | |
| 25 | - | pulse float32 | |
| 25 | + | pulse float32 | |
| 26 | + | // alpha is the opacity of the window being drawn, 1 - its Fade | |
| 27 | + | // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by | |
| 28 | + | // it (premultiplied); 1 otherwise. | |
| 29 | + | alpha float32 | |
| 26 | 30 | bounds image.Rectangle | |
| 27 | 31 | draws []draw | |
| 28 | 32 | } | |
| @@ -33,7 +37,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 33 | 37 | // The walk stays in scene-physical space (the target's size, swapped for | |
| 34 | 38 | // a rotated output); orient maps the draws to the target afterwards. | |
| 35 | 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 | 41 | r.scratchCovers = r.scratchCovers[:0] | |
| 38 | 42 | if w.scale <= 0 { | |
| 39 | 43 | w.scale = 1 | |
| @@ -129,21 +133,25 @@ func (w *sceneWalk) physRectF(x, y, width, height float64) image.Rectangle { | |||
| 129 | 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 | 137 | func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) { | |
| 134 | 138 | rect = rect.Intersect(w.bounds) | |
| 135 | 139 | if rect.Empty() { | |
| 136 | 140 | 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 | 149 | // dim paints a translucent black quad over rect (physical), clipped to the | |
| 142 | - | // output. Damage clipping later limits it to the region being repainted, | |
| 143 | - | // just like other fills. | |
| 150 | + | // output, at alpha scaled by the walk's alpha. Damage clipping later limits | |
| 151 | + | // it to the region being repainted, just like other fills. | |
| 144 | 152 | func (w *sceneWalk) dim(rect image.Rectangle, alpha float64) { | |
| 145 | 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 | 196 | // windows draws ordered placements with borders. Tile lines go after | |
| 165 | 197 | // the column group, before the first float above it; an overview preview | |
| 166 | 198 | // 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 | 204 | tileLines := false | |
| 173 | 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 | 207 | continue | |
| 176 | 208 | } | |
| 177 | - | if !win.Below && win.Floating && win.Preview == 0 && !tileLines { | |
| 209 | + | if opensFloats(win) && !tileLines { | |
| 178 | 210 | w.separators(0) | |
| 179 | 211 | tileLines = true | |
| 180 | 212 | if w.s.Dim > 0 && !w.s.DimBehind { | |
| 181 | 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 | 222 | // Content sits inside the border; core sized the client to match. | |
| 185 | 223 | b, inset := 0, ports.Sides(0) | |
| 186 | 224 | if !win.Fullscreen { | |
| @@ -193,7 +231,11 @@ func (w *sceneWalk) windows() { | |||
| 193 | 231 | // Until its first buffer, a window shows the background: no flash. | |
| 194 | 232 | w.fill(body, parseColor(w.s.Background)) | |
| 195 | 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 | 239 | w.zoom = win.Preview | |
| 198 | 240 | } | |
| 199 | 241 | w.pulse = float32(max(0, min(win.FocusEffect, 1))) | |
| @@ -207,6 +249,7 @@ func (w *sceneWalk) windows() { | |||
| 207 | 249 | // A stashed window peeking in: dimmed with its border. | |
| 208 | 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 | 254 | if !tileLines { | |
| 212 | 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 subsurfaces | |
| 313 | + | // opaqueChildren gives the physical rects shown opaque by the subsurfaces | |
| 271 | 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 is | |
| 273 | - | // left out, so the surface below it still shows: a dmabuf that fails to | |
| 274 | - | // import (opaqueChildren pre-imports it) or an unreadable shm buffer. | |
| 315 | + | // they hide the surfaces below them. A child that is not wholly opaque but | |
| 316 | + | // has an opaque region (Firefox's page, whose region stops a pixel short of | |
| 317 | + | // its edges) hides the surfaces below only inside that rect. A child whose | |
| 318 | + | // buffer will not draw is left out, so the surface below it still shows: a | |
| 319 | + | // dmabuf that fails to import (opaqueChildren pre-imports it) or an | |
| 320 | + | // unreadable shm buffer. | |
| 275 | 321 | func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64, clip image.Rectangle) []image.Rectangle { | |
| 276 | 322 | covers := w.r.scratchCovers[:0] | |
| 277 | 323 | for i := range content.Children { | |
| 278 | 324 | ch := &content.Children[i] | |
| 279 | - | if ch.Below || !ch.Opaque { | |
| 325 | + | if ch.Below || !ch.Opaque && ch.OpaqueRect.W <= 0 { | |
| 280 | 326 | 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 | 329 | if !ok || !w.drawable(&ch.SurfaceContent) { | |
| 284 | 330 | continue | |
| 285 | 331 | } | |
| 332 | + | if !ch.Opaque { | |
| 333 | + | if dst = opaqueCover(&ch.SurfaceContent, full, dst); dst.Empty() { | |
| 334 | + | continue | |
| 335 | + | } | |
| 336 | + | } | |
| 286 | 337 | covers = append(covers, dst) | |
| 287 | 338 | } | |
| 288 | 339 | w.r.scratchCovers = covers | |
| 289 | 340 | 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 | 363 | // drawable reports whether the child's buffer will draw. GPU buffer | |
| 293 | 364 | // imports are cached, so pre-importing here costs nothing. | |
| 294 | 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 | 411 | // surface draws one surface buffer with its origin at (x, y) logical, | |
| 341 | 412 | // clipped to clip (logical), unless an opaque surface above it (covers, | |
| 342 | 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 | 415 | func (w *sceneWalk) surface(content *ports.SurfaceContent, x, y float64, clip image.Rectangle, key shmKey, seq uint64, covers []image.Rectangle, root bool) { | |
| 344 | 416 | full, dst, ok := w.surfaceRects(content, x, y, clip) | |
| 345 | 417 | if !ok { | |
| @@ -353,7 +425,74 @@ func (w *sceneWalk) surface(content *ports.SurfaceContent, x, y float64, clip im | |||
| 353 | 425 | if root { | |
| 354 | 426 | w.dmg.content(key.win, content, full, dst) | |
| 355 | 427 | } | |
| 428 | + | n := len(w.draws) | |
| 356 | 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 | 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 | 504 | return | |
| 366 | 505 | } | |
| 367 | 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 | 512 | w.draws = append(w.draws, dr) | |
| 370 | 513 | } | |
| 371 | 514 | return | |
| @@ -378,6 +521,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon | |||
| 378 | 521 | } | |
| 379 | 522 | dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque) | |
| 380 | 523 | r.setContentColor(&dr, content) | |
| 524 | + | dr.pc.color[3] *= w.alpha | |
| 381 | 525 | dr.pc.mapy[2] = w.pulse | |
| 382 | 526 | if im.yuv { | |
| 383 | 527 | dr.pc.misc[1] |= flagYUV | |
| @@ -399,22 +543,32 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon | |||
| 399 | 543 | return | |
| 400 | 544 | } | |
| 401 | 545 | 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 | 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. | |
| 408 | - | var damage func(uint64) ([]ports.Rect, bool) | |
| 409 | - | if root { | |
| 410 | - | damage = content.DamageSince | |
| 411 | - | } | |
| 412 | - | c, err := r.shmCopyFor(key, st, pixels, b.Offset, b.Stride, damage) | |
| 413 | - | if err != nil { | |
| 414 | - | return | |
| 547 | + | // The copy of this content, if any, needs no pool: a kept (closing) | |
| 548 | + | // window whose pool file is closed still draws. | |
| 549 | + | c := r.shmCached(key, st) | |
| 550 | + | if c != nil { | |
| 551 | + | // As a drawn pool, for Trim; nothing is mapped for it. | |
| 552 | + | if m := r.pools[b.Pool]; m != nil { | |
| 553 | + | m.last = r.frame | |
| 554 | + | } | |
| 555 | + | } else { | |
| 556 | + | pixels, err := r.shmPixels(b, b.Offset+(content.Height-1)*b.Stride+content.Width*4) | |
| 557 | + | if err != nil { | |
| 558 | + | return | |
| 559 | + | } | |
| 560 | + | // Damage history is the root surface's: children copy in full. | |
| 561 | + | var damage func(uint64) ([]ports.Rect, bool) | |
| 562 | + | if root { | |
| 563 | + | damage = content.DamageSince | |
| 564 | + | } | |
| 565 | + | if c, err = r.shmCopyFor(key, st, pixels, b.Offset, b.Stride, damage); err != nil { | |
| 566 | + | return | |
| 567 | + | } | |
| 415 | 568 | } | |
| 416 | 569 | dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque) | |
| 417 | 570 | r.setContentColor(&dr, content) | |
| 571 | + | dr.pc.color[3] *= w.alpha | |
| 418 | 572 | dr.pc.mapy[2] = w.pulse | |
| 419 | 573 | dr.set = c.set | |
| 420 | 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 | 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 | 17 | func TestRenderSteadyStateAllocations(t *testing.T) { | |
| 14 | 18 | for _, tc := range []struct { | |
| 15 | 19 | name string | |
| @@ -41,6 +45,93 @@ func TestRenderSteadyStateAllocations(t *testing.T) { | |||
| 41 | 45 | } else { | |
| 42 | 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 | 178 | last uint64 // frame that drew it | |
| 179 | 179 | } | |
| 180 | 180 | ||
| 181 | - | // shmCopyFor returns the GPU buffer holding a surface's content st, copying | |
| 182 | - | // the client pixels (stride bytes per row from offset) into one when no | |
| 183 | - | // buffer holds them yet. A buffer holding an older content of the same | |
| 184 | - | // size gets only what changed since (damage, in buffer pixels; false: | |
| 185 | - | // everything). | |
| 186 | - | func (r *Renderer) shmCopyFor(key shmKey, st shmState, pixels []byte, offset, stride int, damage func(since uint64) ([]ports.Rect, bool)) (*shmCopy, error) { | |
| 181 | + | // shmCached returns the GPU buffer already holding a surface's content st, | |
| 182 | + | // or nil. It needs no client pixels: a kept window whose pool is closed | |
| 183 | + | // still draws from it. | |
| 184 | + | func (r *Renderer) shmCached(key shmKey, st shmState) *shmCopy { | |
| 187 | 185 | s := r.shm[key] | |
| 188 | 186 | if s == nil { | |
| 189 | - | s = &shmSurface{} | |
| 190 | - | r.shm[key] = s | |
| 187 | + | return nil | |
| 191 | 188 | } | |
| 192 | 189 | s.last = r.frame | |
| 193 | 190 | for _, c := range s.bufs { | |
| 194 | 191 | if c != nil && c.valid && c.holds.w == st.w && c.holds.h == st.h && c.holds.seq == st.seq { | |
| 195 | 192 | c.gpu.last = r.frame | |
| 196 | - | return c, nil | |
| 193 | + | 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 | 211 | // The older buffer takes the new content. | |
| 200 | 212 | i := 0 | |
| 201 | 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 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "fmt" | |
| 6 | + | "maps" | |
| 6 | 7 | "os" | |
| 7 | 8 | "slices" | |
| 8 | 9 | "unsafe" | |
| @@ -29,11 +30,20 @@ type target struct { | |||
| 29 | 30 | // exported images are shared with the display: ownership goes to the | |
| 30 | 31 | // foreign queue family after each frame, and KMS reads them. | |
| 31 | 32 | exported bool | |
| 32 | - | // What the image holds (damage.go): the scene Seq and each window's | |
| 33 | - | // content Seq; valid once a frame was drawn whole. | |
| 34 | - | valid bool | |
| 35 | - | sceneSeq uint64 | |
| 36 | - | windows map[ports.WindowID]heldWindow | |
| 33 | + | // What the image holds (damage.go): the scene, its Seq and each | |
| 34 | + | // window's content Seq; valid once a frame was drawn whole. Scenes are | |
| 35 | + | // immutable for core, but output adapters reuse their slices in place | |
| 36 | + | // (DRM overlay frames, capture), so the compared slices of scene point | |
| 37 | + | // into the target's own buffers, copied by hold. | |
| 38 | + | valid bool | |
| 39 | + | sceneSeq uint64 | |
| 40 | + | scene ports.Scene | |
| 41 | + | heldWindows []ports.SceneWindow | |
| 42 | + | heldSeparators []ports.Separator | |
| 43 | + | heldLayers []ports.SceneLayer | |
| 44 | + | heldHints []ports.Rect | |
| 45 | + | heldIndicators []ports.CaptureIndicator | |
| 46 | + | windows map[ports.WindowID]heldWindow | |
| 37 | 47 | } | |
| 38 | 48 | ||
| 39 | 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 | 55 | // ExportTargets allocates n exported images of the output size, replacing | |
| 46 | 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 | 60 | r.dropTargets() | |
| 49 | 61 | if n > 0 && r.hdrNits > 0 && r.hdrReadback && r.physical != 0 { | |
| 50 | 62 | // 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 | 72 | if !r.dd.HasGetMemoryFdKHR() || !r.dd.HasGetImageDrmFormatModifierPropertiesEXT() || len(r.dmabuf.Formats) == 0 { | |
| 61 | 73 | return nil, errors.New("device cannot export dmabufs") | |
| 62 | 74 | } | |
| 63 | - | mods := r.exportModifiers(modifiers) | |
| 75 | + | mods := r.exportModifiers(modifiers, singlePlane) | |
| 64 | 76 | if len(mods) == 0 { | |
| 65 | 77 | if r.hdrNits == 0 { | |
| 66 | 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 | 121 | // exportModifiers intersects the selected signal format modifiers with | |
| 110 | 122 | // those supported by the display. HDR never assumes an unspecified list, | |
| 111 | - | // except for a virtual output (no display) or a test readback. | |
| 112 | - | func (r *Renderer) exportModifiers(display []uint64) []uint64 { | |
| 123 | + | // except for a virtual output (no display) or a test readback. singlePlane | |
| 124 | + | // drops the modifiers with more than one memory plane. | |
| 125 | + | func (r *Renderer) exportModifiers(display []uint64, singlePlane bool) []uint64 { | |
| 113 | 126 | var out []uint64 | |
| 114 | 127 | available := r.renderMods | |
| 115 | 128 | if r.hdrNits > 0 { | |
| 116 | 129 | available = r.hdrMods | |
| 117 | 130 | } | |
| 118 | 131 | for _, m := range available { | |
| 132 | + | if singlePlane && r.modPlanes[m] > 1 { | |
| 133 | + | continue | |
| 134 | + | } | |
| 119 | 135 | if (len(display) == 0 && (r.hdrNits == 0 || r.hdrReadback || r.virtual)) || slices.Contains(display, m) { | |
| 120 | 136 | out = append(out, m) | |
| 121 | 137 | } | |
| @@ -123,14 +139,16 @@ func (r *Renderer) exportModifiers(display []uint64) []uint64 { | |||
| 123 | 139 | 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 | 143 | func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) { | |
| 128 | 144 | r.renderMods = r.probeModifiers(physical, vk.FormatB8g8r8a8Unorm) | |
| 129 | 145 | r.hdrMods = r.probeModifiers(physical, vk.FormatA2r10g10b10UnormPack32) | |
| 130 | 146 | } | |
| 131 | 147 | ||
| 132 | - | // probeModifiers lists the single-plane, exportable modifiers of format that | |
| 133 | - | // support the target usage. HDR targets need transfer-src only when | |
| 148 | + | // probeModifiers lists the exportable modifiers of format (one to four memory | |
| 149 | + | // planes: DCC adds metadata planes; DCC retile modifiers are left out, see | |
| 150 | + | // retilesDCC) that support the target usage, and | |
| 151 | + | // records their plane counts in modPlanes. HDR targets need transfer-src only when | |
| 134 | 152 | // hdrReadback is set (GPU tests, headless --screenshot-raw; never DRM). | |
| 135 | 153 | func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 { | |
| 136 | 154 | list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT} | |
| @@ -148,17 +166,55 @@ func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) | |||
| 148 | 166 | } else if r.hdrReadback { | |
| 149 | 167 | need |= formatFeatureTransferSrc | |
| 150 | 168 | } | |
| 151 | - | var result []uint64 | |
| 152 | 169 | // SDR targets are composed and read back; HDR targets are color | |
| 153 | 170 | // attachments, and transfer sources only with hdrReadback. | |
| 154 | - | for _, m := range mods[:list.DrmFormatModifierCount] { | |
| 155 | - | if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, format, m.DrmFormatModifier) { | |
| 156 | - | result = append(result, m.DrmFormatModifier) | |
| 157 | - | } | |
| 171 | + | result, planes := filterModifiers(mods[:list.DrmFormatModifierCount], need, func(m uint64) bool { return r.exportable(physical, format, m) }) | |
| 172 | + | if r.modPlanes == nil { | |
| 173 | + | r.modPlanes = make(map[uint64]uint32) | |
| 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 | 178 | 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 | 218 | func (r *Renderer) exportable(physical vk.PhysicalDevice, format vk.Format, modifier uint64) bool { | |
| 163 | 219 | mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive} | |
| 164 | 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 | 282 | if err := checked("vkGetImageDrmFormatModifierPropertiesEXT", d.GetImageDrmFormatModifierPropertiesEXT(r.device, t.image, &props)); err != nil { | |
| 227 | 283 | return nil, ports.DMABuf{}, err | |
| 228 | 284 | } | |
| 229 | - | var layout vk.SubresourceLayout | |
| 230 | - | sub := vk.ImageSubresource{AspectMask: vk.ImageAspectMemoryPlane0BitEXT} | |
| 231 | - | d.GetImageSubresourceLayout(r.device, t.image, &sub, &layout) | |
| 285 | + | n := min(max(r.modPlanes[props.DrmFormatModifier], 1), maxModifierPlanes) | |
| 286 | + | var layouts [maxModifierPlanes]vk.SubresourceLayout | |
| 287 | + | for i := range n { | |
| 288 | + | sub := vk.ImageSubresource{AspectMask: vk.ImageAspectMemoryPlane0BitEXT << i} | |
| 289 | + | d.GetImageSubresourceLayout(r.device, t.image, &sub, &layouts[i]) | |
| 290 | + | } | |
| 232 | 291 | t.view, err = r.imageView(t.image, format) | |
| 233 | 292 | if err != nil { | |
| 234 | 293 | return nil, ports.DMABuf{}, err | |
| @@ -238,11 +297,29 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) { | |||
| 238 | 297 | if err := checked("vkGetMemoryFdKHR", d.GetMemoryFdKHR(r.device, &get, &fd)); err != nil { | |
| 239 | 298 | return nil, ports.DMABuf{}, err | |
| 240 | 299 | } | |
| 241 | - | f := os.NewFile(uintptr(fd), "neferwl-target") | |
| 242 | - | if f == nil { | |
| 300 | + | // Every plane owns its file: the planes share the one memory object, | |
| 301 | + | // so planes 1.. are duplicates of the exported fd. | |
| 302 | + | planes := make([]ports.DMABufPlane, 0, n) | |
| 303 | + | closePlanes := func() { | |
| 304 | + | for _, p := range planes { | |
| 305 | + | _ = p.File.Close() | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | first := os.NewFile(uintptr(fd), "neferwl-target") | |
| 309 | + | if first == nil { | |
| 243 | 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 | 323 | ok = true | |
| 247 | 324 | 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 | 3 | import ( | |
| 4 | 4 | "image/color" | |
| 5 | 5 | "math" | |
| 6 | + | "slices" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -11,20 +12,24 @@ import ( | |||
| 11 | 12 | ||
| 12 | 13 | // Frames drawn into exported targets reach their dmabufs: another renderer | |
| 13 | 14 | // 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 | 18 | func TestExportTargets(t *testing.T) { | |
| 16 | 19 | r, err := New(64, 32) | |
| 17 | 20 | if err != nil { | |
| 18 | 21 | t.Skipf("Vulkan unavailable: %v", err) | |
| 19 | 22 | } | |
| 20 | 23 | defer r.Close() | |
| 21 | - | bufs, err := r.ExportTargets(2, nil) | |
| 24 | + | bufs, err := r.ExportTargets(2, nil, true) | |
| 22 | 25 | if err != nil { | |
| 23 | 26 | t.Skipf("no exportable targets: %v", err) | |
| 24 | 27 | } | |
| 25 | 28 | defer func() { | |
| 26 | 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 | 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 | 88 | if len(r.hdrMods) == 0 { | |
| 84 | 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 | 92 | if err != nil { | |
| 88 | 93 | t.Skipf("no HDR-exportable target: %v", err) | |
| 89 | 94 | } | |
| @@ -140,12 +145,12 @@ func TestVirtualHDRExportsWithoutDisplayList(t *testing.T) { | |||
| 140 | 145 | t.Skip("no exportable HDR GPU") | |
| 141 | 146 | } | |
| 142 | 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 | 149 | bufs[0].Planes[0].File.Close() | |
| 145 | 150 | t.Fatal("HDR export without a display list succeeded on a non-virtual output") | |
| 146 | 151 | } | |
| 147 | 152 | r.SetVirtualOutput(true) | |
| 148 | - | bufs, err := r.ExportTargets(1, nil) | |
| 153 | + | bufs, err := r.ExportTargets(1, nil, false) | |
| 149 | 154 | if err != nil { | |
| 150 | 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 | 56 | t.Fatalf("popup escaped clip: %v", got) | |
| 57 | 57 | } | |
| 58 | 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 | 61 | scene.Seq++ | |
| 60 | - | scene.Windows[0].OverLayers = true | |
| 62 | + | scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true, OverLayers: true}} | |
| 61 | 63 | if err := render(r, scene, contents); err != nil { | |
| 62 | 64 | t.Fatal(err) | |
| 63 | 65 | } | |
| 64 | 66 | if got := readPixels(t, r).RGBAAt(10, 10); got != red { | |
| 65 | 67 | t.Fatalf("output-wide popup clipped: %v", got) | |
| 66 | 68 | } | |
| 67 | - | scene.Windows[0].OverLayers = false | |
| 69 | + | scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true}} | |
| 68 | 70 | // Switching back to inherited geometry repaints the margins. | |
| 69 | 71 | scene.Seq++ | |
| 70 | 72 | scene.WorkspaceClip = ports.Rect{} | |
M internal/adapters/wayland/surface.go +24 -3
| @@ -393,7 +393,7 @@ func (s *surface) applyCommit(u *update) { | |||
| 393 | 393 | s.bufferScale = u.scale | |
| 394 | 394 | } | |
| 395 | 395 | s.transform = u.transform | |
| 396 | - | oldOpaque := s.content.Opaque | |
| 396 | + | oldOpaque, oldOpaqueRect := s.content.Opaque, s.content.OpaqueRect | |
| 397 | 397 | if u.opaqueSet { | |
| 398 | 398 | s.opaque = u.opaque | |
| 399 | 399 | } | |
| @@ -455,6 +455,7 @@ func (s *surface) applyCommit(u *update) { | |||
| 455 | 455 | c.LogicalW, c.LogicalH = s.logicalSize(c.Width, c.Height) | |
| 456 | 456 | s.formatOpaque = c.Opaque | |
| 457 | 457 | 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 | 459 | c.Fade = u.fade | |
| 459 | 460 | resized := !s.has || c.Width != s.content.Width || c.Height != s.content.Height || c.LogicalW != s.content.LogicalW || c.LogicalH != s.content.LogicalH | |
| 460 | 461 | s.commitDamage(u, true, resized, c.Width, c.Height) | |
| @@ -484,13 +485,14 @@ func (s *surface) applyCommit(u *update) { | |||
| 484 | 485 | s.content.Source, _ = s.source(s.content.Width, s.content.Height) | |
| 485 | 486 | s.content.LogicalW, s.content.LogicalH = s.logicalSize(s.content.Width, s.content.Height) | |
| 486 | 487 | 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 | 489 | s.content.Fade = u.fade | |
| 488 | 490 | } | |
| 489 | 491 | } | |
| 490 | 492 | if s.current == nil { | |
| 491 | 493 | 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 | 496 | s.version++ | |
| 495 | 497 | s.root().treeDirty = true | |
| 496 | 498 | } | |
| @@ -519,7 +521,7 @@ func (s *surface) applyCommit(u *update) { | |||
| 519 | 521 | } | |
| 520 | 522 | reshaped := s.has && (s.content.LogicalW != oldW || s.content.LogicalH != oldH || s.content.Source != oldSource || s.content.Transform != oldTransform) | |
| 521 | 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 | 525 | drawn := fresh || damaged || moved || geometry || hinted || reshaped || opacity || s.sub.parent != nil | |
| 524 | 526 | if drawn { | |
| 525 | 527 | if moved || geometry || reshaped || opacity { | |
| @@ -623,6 +625,25 @@ func (s *surface) opaqueCovers(w, h int) bool { | |||
| 623 | 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 | 647 | // maxCoverRects bounds the region covers checks: beyond it, not covered. | |
| 627 | 648 | const maxCoverRects = 16 | |
| 628 | 649 | ||
M internal/adapters/wayland/transform_test.go +42 -2
| @@ -161,13 +161,16 @@ func TestOpaqueRegionContent(t *testing.T) { | |||
| 161 | 161 | if prev.Opaque { | |
| 162 | 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 | 167 | // Two halves cover the whole surface, even through a larger rect. | |
| 165 | 168 | // Opacity changes blending everywhere: the commit damages it all. | |
| 166 | 169 | whole := tc.region(t, ports.Rect{W: 15, H: 20}, ports.Rect{X: 15, W: 100, H: 20}) | |
| 167 | 170 | requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetOpaqueRegion, whole) | |
| 168 | 171 | tc.commit(t) | |
| 169 | 172 | got := tc.next(t) | |
| 170 | - | if !got.Opaque { | |
| 173 | + | if !got.Opaque || got.OpaqueRect != (ports.Rect{}) { | |
| 171 | 174 | t.Fatalf("covering region: %+v", got) | |
| 172 | 175 | } | |
| 173 | 176 | if rects, ok := got.DamageSince(prev.Seq); ok { | |
| @@ -186,9 +189,46 @@ func TestOpaqueRegionContent(t *testing.T) { | |||
| 186 | 189 | } | |
| 187 | 190 | requestProtocol(t, tc.c, tc.surf, wayland.SurfaceRequestSetOpaqueRegion, uint32(0)) | |
| 188 | 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 | 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 | 234 | // 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 | 294 | if err != nil { | |
| 295 | 295 | return nil, err | |
| 296 | 296 | } | |
| 297 | + | r.SetLogger(renderLog) | |
| 297 | 298 | renderLog.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue") | |
| 298 | 299 | return r, nil | |
| 299 | 300 | } | |
M internal/app/app_test.go +54 -6
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "os/exec" | |
| 10 | 10 | "path/filepath" | |
| 11 | 11 | "strings" | |
| 12 | + | "sync" | |
| 12 | 13 | "testing" | |
| 13 | 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 | 112 | if _, err := exec.LookPath("weston-simple-shm"); err != nil { | |
| 106 | 113 | t.Skip("weston-simple-shm not installed") | |
| 107 | 114 | } | |
| @@ -109,7 +116,8 @@ func TestHeadlessSpawnClose(t *testing.T) { | |||
| 109 | 116 | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 110 | 117 | cfg := config.Defaults() | |
| 111 | 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 | 121 | ctx, cancel := context.WithCancel(context.Background()) | |
| 114 | 122 | defer cancel() | |
| 115 | 123 | inputReady := make(chan chan<- ports.InputEvent, 1) | |
| @@ -144,6 +152,12 @@ func TestHeadlessSpawnClose(t *testing.T) { | |||
| 144 | 152 | case set := <-scenes: | |
| 145 | 153 | s := set[0] | |
| 146 | 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 | 161 | // A lone column fills the 1920 output (default zero gaps). | |
| 148 | 162 | if count == 1 && s.Windows[0].Rect.W != 1920 { | |
| 149 | 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 | 238 | if _, err := exec.LookPath("foot"); err != nil { | |
| 219 | 239 | t.Skip("foot unavailable") | |
| 220 | 240 | } | |
| @@ -225,17 +245,45 @@ func TestHeadlessPointerClickFocus(t *testing.T) { | |||
| 225 | 245 | cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"} | |
| 226 | 246 | // Pulse frames would fill the scene tap before the click. | |
| 227 | 247 | 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 | 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 | 274 | if err != nil { | |
| 231 | 275 | if strings.Contains(strings.ToLower(err.Error()), "vulkan") { | |
| 232 | 276 | t.Skipf("Vulkan unavailable: %v", err) | |
| 233 | 277 | } | |
| 234 | 278 | t.Fatal(err) | |
| 235 | 279 | } | |
| 236 | - | two, focused := false, false | |
| 280 | + | stopCollector() | |
| 237 | 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 | 287 | if len(s.Windows) == 2 { | |
| 240 | 288 | two = true | |
| 241 | 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 | 26 | defer r.Close() | |
| 27 | 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 | 29 | if err != nil { | |
| 30 | 30 | t.Skipf("no exportable target: %v", err) | |
| 31 | 31 | } | |
| @@ -35,7 +35,7 @@ func TestVirtualRendererExportsHDRTargets(t *testing.T) { | |||
| 35 | 35 | } | |
| 36 | 36 | } | |
| 37 | 37 | r.SetHDR(203) | |
| 38 | - | bufs, err := r.ExportTargets(1, nil) | |
| 38 | + | bufs, err := r.ExportTargets(1, nil, false) | |
| 39 | 39 | if err != nil { | |
| 40 | 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 | 3 | import ( | |
| 4 | 4 | "math/rand" | |
| 5 | + | "slices" | |
| 5 | 6 | "testing" | |
| 7 | + | "time" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 10 | ) | |
| @@ -13,52 +15,154 @@ func TestBorderSceneInvariants(t *testing.T) { | |||
| 13 | 15 | for _, overflow := range []string{"fixed", "scroll"} { | |
| 14 | 16 | for _, gap := range []int{0, 6} { | |
| 15 | 17 | t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) { | |
| 16 | - | rng := rand.New(rand.NewSource(20260928)) | |
| 17 | 18 | r := startMulti(t, func(c *ports.Config) { | |
| 19 | + | c.Animations.On = false | |
| 18 | 20 | c.Border.Width = 2 | |
| 19 | 21 | c.Layout.MaxColumns = 3 | |
| 20 | 22 | c.Layout.Overflow = overflow | |
| 21 | 23 | c.Layout.Gaps = gap | |
| 22 | 24 | }, left, right) | |
| 23 | - | var ids []ports.WindowID | |
| 24 | - | next := ports.WindowID(1) | |
| 25 | - | // A single keypress may publish a scene even when the action is | |
| 26 | - | // inapplicable; check every resulting scene independently. | |
| 27 | - | keys := []struct { | |
| 28 | - | sym string | |
| 29 | - | mods ports.Mods | |
| 30 | - | }{ | |
| 31 | - | {"Left", ports.ModAlt | ports.ModShift}, {"Right", ports.ModAlt | ports.ModShift}, | |
| 32 | - | {"Left", ports.ModAlt}, {"Right", ports.ModAlt}, | |
| 33 | - | {"Left", ports.ModAlt | ports.ModCtrl}, {"Right", ports.ModAlt | ports.ModCtrl}, | |
| 34 | - | {"Left", ports.ModAlt | ports.ModCtrl | ports.ModShift}, {"Right", ports.ModAlt | ports.ModCtrl | ports.ModShift}, | |
| 35 | - | {"bracketleft", ports.ModAlt}, {"bracketright", ports.ModAlt}, | |
| 36 | - | {"f", ports.ModAlt}, {"f", ports.ModAlt | ports.ModShift}, | |
| 37 | - | {"s", ports.ModAlt}, {"S", ports.ModAlt | ports.ModShift}, {"Up", ports.ModAlt}, {"Down", ports.ModAlt}, | |
| 38 | - | } | |
| 39 | - | for step := range 180 { | |
| 40 | - | var scenes []ports.Scene | |
| 41 | - | switch { | |
| 42 | - | case len(ids) < 2 || rng.Intn(5) == 0 && len(ids) < 12: | |
| 43 | - | scenes = r.mapWindow(t, next) | |
| 44 | - | ids = append(ids, next) | |
| 45 | - | next++ | |
| 46 | - | case rng.Intn(9) == 0: | |
| 47 | - | i := rng.Intn(len(ids)) | |
| 48 | - | r.client <- ports.WindowUnmapped{ID: ids[i]} | |
| 49 | - | scenes = receive(t, r.scenes) | |
| 50 | - | ids = append(ids[:i], ids[i+1:]...) | |
| 51 | - | default: | |
| 52 | - | k := keys[rng.Intn(len(keys))] | |
| 53 | - | scenes = r.key(t, k.sym, k.mods) | |
| 25 | + | borderScenario(t, r, gap, nil) | |
| 26 | + | }) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // With animations on, the scene right after an action shows the old layout | |
| 32 | + | // moving: only the weak invariants hold there. Once every spring settled | |
| 33 | + | // (the test moves the clock and sends the page flips) the full ones do, | |
| 34 | + | // whatever the actions that ran mid-transition. Insets and lit lines come | |
| 35 | + | // from the settled topology by design: separators follow the shown layout. | |
| 36 | + | func TestBorderSceneInvariantsAnimated(t *testing.T) { | |
| 37 | + | for _, overflow := range []string{"fixed", "scroll"} { | |
| 38 | + | for _, gap := range []int{0, 6} { | |
| 39 | + | t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) { | |
| 40 | + | r := startSwipeOn(t, func(c *ports.Config) { | |
| 41 | + | c.Animations.On = true | |
| 42 | + | c.Border.Width = 2 | |
| 43 | + | c.Layout.MaxColumns = 3 | |
| 44 | + | c.Layout.Overflow = overflow | |
| 45 | + | c.Layout.Gaps = gap | |
| 46 | + | }, left, right) | |
| 47 | + | moved := 0 | |
| 48 | + | borderScenario(t, r.multiRig, gap, func(set []ports.Scene) []ports.Scene { | |
| 49 | + | settled, ok := r.settleAll(t, left.Name, right.Name) | |
| 50 | + | if !ok { | |
| 51 | + | return set | |
| 52 | + | } | |
| 53 | + | if !sameRects(set, settled) { | |
| 54 | + | moved++ | |
| 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 | 166 | func checkBorderScenes(t *testing.T, step, gap int, scenes []ports.Scene) { | |
| 63 | 167 | t.Helper() | |
| 64 | 168 | focused := 0 | |
M internal/core/capindicator_test.go +7 -1
| @@ -12,10 +12,11 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | // indicatorClock is a clock the test moves; its timers never fire by | |
| 14 | 14 | // 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 | 16 | type indicatorClock struct { | |
| 17 | 17 | now time.Time | |
| 18 | 18 | timers []time.Duration | |
| 19 | + | resets []time.Duration | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | func indicatorCore(t *testing.T) (*Core, *indicatorClock) { | |
| @@ -28,6 +29,10 @@ func indicatorCore(t *testing.T) (*Core, *indicatorClock) { | |||
| 28 | 29 | timer := portsmocks.NewMockTimer(t) | |
| 29 | 30 | timer.EXPECT().C().Return(make(chan time.Time)).Maybe() | |
| 30 | 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 | 36 | return timer | |
| 32 | 37 | }).Maybe() | |
| 33 | 38 | var cfg ports.Config | |
| @@ -193,6 +198,7 @@ func TestCaptureIndicatorHiddenWorkspacePill(t *testing.T) { | |||
| 193 | 198 | timer := portsmocks.NewMockTimer(t) | |
| 194 | 199 | timer.EXPECT().C().Return(make(chan time.Time)).Maybe() | |
| 195 | 200 | timer.EXPECT().Stop().Return(true).Maybe() | |
| 201 | + | timer.EXPECT().Reset(mock.Anything).Return(false).Maybe() | |
| 196 | 202 | clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe() | |
| 197 | 203 | c.ch.Clock = clock | |
| 198 | 204 | c.configures.cw.reset() | |
M internal/core/capture_workspace_test.go +2 -2
| @@ -193,7 +193,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) { | |||
| 193 | 193 | c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}} | |
| 194 | 194 | c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true} | |
| 195 | 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 | 197 | ctx := context.Background() | |
| 198 | 198 | if err := c.publish(ctx); err != nil { | |
| 199 | 199 | t.Fatal(err) | |
| @@ -207,7 +207,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) { | |||
| 207 | 207 | if v, ok := c.configures.sent[1]; !ok || !v.Captured || v.Visible || v.Activated || v.Width <= 0 { | |
| 208 | 208 | t.Fatalf("window during the slide %+v (sent %v)", v, ok) | |
| 209 | 209 | } | |
| 210 | - | ws.motion = nil | |
| 210 | + | ws.motion = motion{} | |
| 211 | 211 | if err := c.publish(ctx); err != nil { | |
| 212 | 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 | 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 | 147 | // prune forgets the windows no next call saw since the last prune: they | |
| 134 | 148 | // left the layout. | |
| 135 | 149 | func (s *configures) prune() { | |
M internal/core/core.go +158 -55
| @@ -67,6 +67,11 @@ type binding struct { | |||
| 67 | 67 | key string | |
| 68 | 68 | } | |
| 69 | 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 | 75 | security ports.SecurityState | |
| 71 | 76 | lockSurfaces []ports.LockSurfacePlacement | |
| 72 | 77 | lockFocus WindowID | |
| @@ -126,10 +131,15 @@ type Core struct { | |||
| 126 | 131 | // (ports.UserActivity), sent at most once per ActivityInterval. | |
| 127 | 132 | activity time.Time | |
| 128 | 133 | // swipe is the touchpad swipe in progress. frameC fires when no page | |
| 129 | - | // flip came in time to move a running slide (frameStop stops it). | |
| 130 | - | swipe *swipeGesture | |
| 131 | - | frameC <-chan time.Time | |
| 132 | - | frameStop func() bool | |
| 134 | + | // flip came in time to move a running slide; nil when not armed. It is | |
| 135 | + | // the channel of frameTimer, made once and reset per frame: an | |
| 136 | + | // animation frame allocates no timer. | |
| 137 | + | swipe *swipeGesture | |
| 138 | + | frameC <-chan time.Time | |
| 139 | + | frameTimer ports.Timer | |
| 140 | + | // shots is the snapshot of what the screens show before an action | |
| 141 | + | // (transition.go), reused by every action. | |
| 142 | + | shots []viewShot | |
| 133 | 143 | // motionTime is the time of the last pointer motion; pointerAt is the | |
| 134 | 144 | // last position sent in the pointer's window. | |
| 135 | 145 | motionTime time.Duration | |
| @@ -355,14 +365,26 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||
| 355 | 365 | ||
| 356 | 366 | // newTimer makes a timer on clock, or the system timer when clock is nil. | |
| 357 | 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 | 375 | if clock != nil { | |
| 359 | - | t := clock.NewTimer(d) | |
| 360 | - | return t.C(), t.Stop | |
| 376 | + | return clock.NewTimer(d) | |
| 361 | 377 | } | |
| 362 | - | t := time.NewTimer(d) | |
| 363 | - | return t.C, t.Stop | |
| 378 | + | return sysTimer{time.NewTimer(d)} | |
| 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 | 388 | func (c *Core) now() time.Time { | |
| 367 | 389 | if c.ch.Clock != nil { | |
| 368 | 390 | return c.ch.Clock.Now() | |
| @@ -405,21 +427,20 @@ func onScreen(p Placement, o Rect) bool { | |||
| 405 | 427 | ||
| 406 | 428 | // floatDim is the veil opacity of a layout: dim only when a float is | |
| 407 | 429 | // 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 | 434 | func floatDim(layout []Placement, o Rect, dim float64) float64 { | |
| 410 | - | shown := false | |
| 411 | 435 | for _, p := range layout { | |
| 412 | 436 | if p.Floating && !p.Below && p.Preview == 0 && onScreen(p, o) { | |
| 413 | 437 | if p.Fullscreen { | |
| 414 | 438 | return 0 | |
| 415 | 439 | } | |
| 416 | - | shown = true | |
| 440 | + | return dim | |
| 417 | 441 | } | |
| 418 | 442 | } | |
| 419 | - | if !shown { | |
| 420 | - | return 0 | |
| 421 | - | } | |
| 422 | - | return dim | |
| 443 | + | return 0 | |
| 423 | 444 | } | |
| 424 | 445 | ||
| 425 | 446 | // 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 | 522 | if c.inputEpochChanged() { | |
| 496 | 523 | return errSecurityChanged | |
| 497 | 524 | } | |
| @@ -505,6 +532,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 505 | 532 | sc.mon.Current().settleLeft() | |
| 506 | 533 | } | |
| 507 | 534 | } | |
| 535 | + | c.refreshShown() | |
| 508 | 536 | c.captureExpire() | |
| 509 | 537 | capture, err := c.captureEvaluate(ctx) | |
| 510 | 538 | if err != nil { | |
| @@ -530,17 +558,20 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 530 | 558 | focus := c.keyboardFocus() | |
| 531 | 559 | window, _ := c.cur().mon.Focused() | |
| 532 | 560 | c.pulseFocus(window, focus == window) | |
| 533 | - | var pulse float64 | |
| 534 | - | if c.pulse.id != 0 { | |
| 535 | - | pulse = c.advancePulse(c.now()) | |
| 536 | - | } | |
| 561 | + | // The pulse runs on the focused output: a flip of another output reuses | |
| 562 | + | // its value so the focused scene keeps its Seq. | |
| 563 | + | if c.pulse.id == 0 { | |
| 564 | + | c.pulse.value = 0 | |
| 565 | + | } else if only == nil || only == c.cur() { | |
| 566 | + | c.pulse.value = c.advancePulse(c.now()) | |
| 567 | + | } | |
| 568 | + | pulse := c.pulse.value | |
| 537 | 569 | // drawable: the scene can show a pulse on the focused window. | |
| 538 | 570 | drawable := false | |
| 539 | 571 | scenes := make([]ports.Scene, 0, len(c.screens)) | |
| 540 | 572 | // Before the first output (and after the last is unplugged) the | |
| 541 | 573 | // placeholder's scene has no output name: no renderer draws it. | |
| 542 | 574 | for i, sc := range c.screens { | |
| 543 | - | c.seq++ | |
| 544 | 575 | o := sc.mon.Output() | |
| 545 | 576 | // frame is the viewport of the workspace on screen: the whole output | |
| 546 | 577 | // unless it has a size override (never in the overview). | |
| @@ -549,13 +580,21 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 549 | 580 | if frame != (Rect{W: o.W, H: o.H}) { | |
| 550 | 581 | 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)} | |
| 553 | - | layout := sc.mon.Layout() | |
| 583 | + | // layout is what is drawn; settled (same indexes) is where the | |
| 584 | + | // windows are going, and alone sizes the configures. | |
| 585 | + | layout, settled := sc.shown, sc.settledLayout | |
| 586 | + | scene := ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)} | |
| 554 | 587 | var real map[WindowID]Placement | |
| 555 | 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 | 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 | 598 | real[p.ID] = p | |
| 560 | 599 | } | |
| 561 | 600 | } | |
| @@ -569,21 +608,42 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 569 | 608 | if d := c.drag; d != nil && d.target.screen == sc && d.target.kind != dropNone { | |
| 570 | 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 | 611 | if sc.mon.ov.open { | |
| 575 | 612 | // Frame the selection and separate numbered and named row groups. | |
| 576 | 613 | _, scene.Separators = sc.mon.overviewRows() | |
| 577 | 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 | 619 | // A window alone on screen needs no pulse to show it has the focus. | |
| 580 | 620 | 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 | 623 | // Only the focused output has an activated window. | |
| 583 | 624 | focused := p.Focused && i == c.focusScreen | |
| 584 | - | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview} | |
| 585 | - | if p.Peek { | |
| 586 | - | sw.Dim = c.cfg.Stash.Dim | |
| 625 | + | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade} | |
| 626 | + | if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) { | |
| 627 | + | // A scale motion: the content follows the drawn size. A | |
| 628 | + | // card in flight drawn at its size still needs Zoom 1: | |
| 629 | + | // without it the renderer would shrink it by Preview. | |
| 630 | + | sw.Zoom = p.Zoom | |
| 631 | + | } | |
| 632 | + | // A peek's veil is the configured one plus its animated offset. | |
| 633 | + | sw.Dim = max(0, min(p.Dim+c.peekDim(p), 1)) | |
| 634 | + | if p.Leaving { | |
| 635 | + | // A window fading out after it closed or hid: drawn, but | |
| 636 | + | // hidden to everything else, its configures included. | |
| 637 | + | sw.Hidden = false | |
| 638 | + | scene.Windows = append(scene.Windows, sw) | |
| 639 | + | // A window the workspace still holds (a hidden stash | |
| 640 | + | // window) keeps its last configure: prune would forget it, | |
| 641 | + | // and its next one would start from nothing. A closed one | |
| 642 | + | // was forgotten at its unmap and stays so. | |
| 643 | + | if w, _ := sc.mon.find(p.ID); w != nil { | |
| 644 | + | c.configures.keep(p.ID) | |
| 645 | + | } | |
| 646 | + | continue | |
| 587 | 647 | } | |
| 588 | 648 | if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open { | |
| 589 | 649 | drawable = true | |
| @@ -595,7 +655,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 595 | 655 | t := configureTarget{output: sc.name(), area: frame, focused: focused, captured: capture != nil && capture.window == p.ID} | |
| 596 | 656 | if !p.Hidden && p.Preview == 0 { | |
| 597 | 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 | 659 | } else if t.captured && p.Hidden { | |
| 600 | 660 | // A captured hidden window is sized like its capture. | |
| 601 | 661 | t.client = capture.windowSz | |
| @@ -605,8 +665,8 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 605 | 665 | t.client = c.clientRect(rp) | |
| 606 | 666 | } | |
| 607 | 667 | } | |
| 608 | - | cp, t := c.captureConfigure(sc, p, t) | |
| 609 | - | if v, send := c.configures.nextWithCapture(p, t, cp); send { | |
| 668 | + | cp, t := c.captureConfigure(sc, ps, t) | |
| 669 | + | if v, send := c.configures.nextWithCapture(ps, t, cp); send { | |
| 610 | 670 | if err := c.command(ctx, v); err != nil { | |
| 611 | 671 | return err | |
| 612 | 672 | } | |
| @@ -615,7 +675,23 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 615 | 675 | } | |
| 616 | 676 | scene.Windows = append(scene.Windows, c.scenePopups(sc)...) | |
| 617 | 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 | 695 | switch sc { | |
| 620 | 696 | case capture.hiddenScr: | |
| 621 | 697 | scene.CaptureScene = c.captureScene(scene.Seq) | |
| @@ -623,6 +699,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 623 | 699 | scene.CaptureScene = c.captureWindowScene(capture, scene.Seq) | |
| 624 | 700 | } | |
| 625 | 701 | } | |
| 702 | + | sc.last = scene | |
| 626 | 703 | scenes = append(scenes, scene) | |
| 627 | 704 | } | |
| 628 | 705 | c.configures.prune() | |
| @@ -651,7 +728,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 651 | 728 | return errSecurityChanged | |
| 652 | 729 | } | |
| 653 | 730 | if c.syncSecurity() { | |
| 654 | - | return c.publish(ctx) | |
| 731 | + | return c.publishFrame(ctx, only) | |
| 655 | 732 | } | |
| 656 | 733 | latest(c.ch.Scenes, scenes) | |
| 657 | 734 | c.publishState() | |
| @@ -668,20 +745,41 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 668 | 745 | return nil | |
| 669 | 746 | } | |
| 670 | 747 | ||
| 671 | - | // step moves the running slides to now and publishes the frame. | |
| 672 | - | func (c *Core) step(ctx context.Context) error { | |
| 673 | - | c.stopFrame() | |
| 674 | - | c.animate(c.now()) | |
| 675 | - | return c.slid(ctx, false) | |
| 748 | + | // step moves the running slides of only (nil: every screen) to now and | |
| 749 | + | // publishes the frame. | |
| 750 | + | func (c *Core) step(ctx context.Context, only *screen) error { | |
| 751 | + | // The fallback timer guards every animating output: a flip of one keeps | |
| 752 | + | // it running while another still animates, or that one could starve. | |
| 753 | + | if only == nil || !c.animatingOther(only) { | |
| 754 | + | c.stopFrame() | |
| 755 | + | } | |
| 756 | + | c.animate(c.now(), only) | |
| 757 | + | return c.slidFrame(ctx, false, only) | |
| 758 | + | } | |
| 759 | + | ||
| 760 | + | // animatingOther reports whether a spring or the pulse runs on a screen | |
| 761 | + | // other than only. | |
| 762 | + | func (c *Core) animatingOther(only *screen) bool { | |
| 763 | + | for _, sc := range c.screens { | |
| 764 | + | if sc != only && (sc.springing() || c.pulsing(sc)) { | |
| 765 | + | return true | |
| 766 | + | } | |
| 767 | + | } | |
| 768 | + | return false | |
| 676 | 769 | } | |
| 677 | 770 | ||
| 678 | 771 | // slid publishes a moved view; shown reports another workspace came on | |
| 679 | 772 | // screen. | |
| 680 | 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 | 779 | if err := c.workspaceVisible(ctx, shown); err != nil { | |
| 682 | 780 | return err | |
| 683 | 781 | } | |
| 684 | - | if err := c.publish(ctx); err != nil { | |
| 782 | + | if err := c.publishFrame(ctx, only); err != nil { | |
| 685 | 783 | return err | |
| 686 | 784 | } | |
| 687 | 785 | return c.rehit(ctx) | |
| @@ -788,7 +886,7 @@ func (c *Core) shownClient(id WindowID) (full, visible Rect, ok bool) { | |||
| 788 | 886 | if s == nil { | |
| 789 | 887 | return Rect{}, Rect{}, false | |
| 790 | 888 | } | |
| 791 | - | for _, p := range s.mon.Layout() { | |
| 889 | + | for _, p := range s.shownLayout() { | |
| 792 | 890 | if p.ID != id || p.Hidden || p.Preview > 0 { | |
| 793 | 891 | continue | |
| 794 | 892 | } | |
| @@ -938,7 +1036,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 938 | 1036 | } | |
| 939 | 1037 | var id WindowID | |
| 940 | 1038 | var sx, sy float64 | |
| 941 | - | for _, p := range sc.mon.Layout() { | |
| 1039 | + | for _, p := range sc.shownLayout() { | |
| 942 | 1040 | r := c.clientRect(p) | |
| 943 | 1041 | // A peek is clickable wherever it shows, border included: it may | |
| 944 | 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 | 1127 | case ports.InputRegionChanged: | |
| 1030 | 1128 | c.windows.setRegion(v) | |
| 1031 | 1129 | case ports.WindowMapped: | |
| 1032 | - | c.windows.mapped(v, c.now()) | |
| 1033 | - | c.placement.place(c, v) | |
| 1130 | + | c.mapWindow(v) | |
| 1034 | 1131 | case ports.WindowResized: | |
| 1035 | - | if _, w := c.screenOf(v.ID); w != nil { | |
| 1036 | - | w.ResizeFloating(v.ID, v.Width, v.Height) | |
| 1037 | - | } | |
| 1132 | + | c.resizeFloating(v) | |
| 1038 | 1133 | case ports.PopupRequest: | |
| 1039 | 1134 | if err := c.placePopup(ctx, v); err != nil { | |
| 1040 | 1135 | return nil | |
| @@ -1076,9 +1171,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1076 | 1171 | if c.drag != nil && c.drag.id == v.ID { | |
| 1077 | 1172 | c.abortDrag() | |
| 1078 | 1173 | } | |
| 1079 | - | if s, _ := c.screenOf(v.ID); s != nil { | |
| 1080 | - | s.mon.RemoveWindow(v.ID) | |
| 1081 | - | } | |
| 1174 | + | c.unmapWindow(v) | |
| 1082 | 1175 | c.releaseSlots() | |
| 1083 | 1176 | if c.pointer == v.ID { | |
| 1084 | 1177 | c.pointer = 0 | |
| @@ -1109,6 +1202,13 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1109 | 1202 | } | |
| 1110 | 1203 | if s, _ := c.screenOf(v.ID); s != nil { | |
| 1111 | 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 | 1213 | case ports.WorkspaceActivate: | |
| 1114 | 1214 | before := c.cur().mon.Current() | |
| @@ -1145,7 +1245,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1145 | 1245 | c.ch.Frames = nil | |
| 1146 | 1246 | 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 | 1249 | return nil | |
| 1150 | 1250 | } | |
| 1151 | 1251 | continue | |
| @@ -1153,8 +1253,8 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1153 | 1253 | if c.securityCheckpoint(ctx) != nil { | |
| 1154 | 1254 | return nil | |
| 1155 | 1255 | } | |
| 1156 | - | c.frameC, c.frameStop = nil, nil | |
| 1157 | - | if c.step(ctx) != nil { | |
| 1256 | + | c.frameC = nil | |
| 1257 | + | if c.step(ctx, nil) != nil { | |
| 1158 | 1258 | return nil | |
| 1159 | 1259 | } | |
| 1160 | 1260 | continue | |
| @@ -1217,6 +1317,9 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1217 | 1317 | } | |
| 1218 | 1318 | continue | |
| 1219 | 1319 | } | |
| 1320 | + | if !c.animOn() { | |
| 1321 | + | c.stopAnimations() | |
| 1322 | + | } | |
| 1220 | 1323 | // A rotation can shrink the layout under a still pointer. | |
| 1221 | 1324 | c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY) | |
| 1222 | 1325 | if c.workspaceVisible(ctx, false) != nil { | |
M internal/core/core_test.go +176 -49
| @@ -5,13 +5,11 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "reflect" | |
| 7 | 7 | "slices" | |
| 8 | - | "sync/atomic" | |
| 9 | 8 | "testing" | |
| 10 | 9 | "time" | |
| 11 | 10 | ||
| 12 | 11 | "github.com/bnema/neferwl/internal/adapters/config" | |
| 13 | 12 | "github.com/bnema/neferwl/internal/core" | |
| 14 | - | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 15 | 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 | 97 | cfg := altCmdDefaults() | |
| 51 | 98 | cfg.Border.Width = 0 | |
| 52 | 99 | cfg.Layout.Gaps = 8 | |
| @@ -58,7 +105,9 @@ func TestOwner(t *testing.T) { | |||
| 58 | 105 | spawn := make(chan ports.SpawnRequest, 8) | |
| 59 | 106 | scenes := make(chan []ports.Scene, 1) | |
| 60 | 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 | 111 | if err != nil { | |
| 63 | 112 | t.Fatal(err) | |
| 64 | 113 | } | |
| @@ -72,7 +121,7 @@ func TestOwner(t *testing.T) { | |||
| 72 | 121 | t.Fatal(s) | |
| 73 | 122 | } | |
| 74 | 123 | client <- ports.WindowMapped{ID: 1} | |
| 75 | - | s = scene(t, scenes) | |
| 124 | + | s = l.one(scene(t, scenes)) | |
| 76 | 125 | // A single column fills the usable width. | |
| 77 | 126 | if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 { | |
| 78 | 127 | t.Fatal(s) | |
| @@ -117,7 +166,7 @@ func TestOwner(t *testing.T) { | |||
| 117 | 166 | } | |
| 118 | 167 | cfg.Layout.Gaps = 4 | |
| 119 | 168 | reload <- ports.ConfigChanged{Config: cfg} | |
| 120 | - | s = scene(t, scenes) | |
| 169 | + | s = l.one(scene(t, scenes)) | |
| 121 | 170 | if s.Windows[0].Rect.W != 92 { | |
| 122 | 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 | 338 | cfg := config.Defaults() | |
| 288 | 339 | cfg.Border.Width = 2 | |
| 289 | 340 | client := make(chan ports.ClientEvent, 8) | |
| @@ -291,7 +342,9 @@ func TestBorderInset(t *testing.T) { | |||
| 291 | 342 | output := make(chan ports.OutputEvent, 8) | |
| 292 | 343 | commands := make(chan ports.ClientCommand, 64) | |
| 293 | 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 | 348 | if err != nil { | |
| 296 | 349 | t.Fatal(err) | |
| 297 | 350 | } | |
| @@ -302,14 +355,14 @@ func TestBorderInset(t *testing.T) { | |||
| 302 | 355 | scene(t, scenes) | |
| 303 | 356 | // Alone, the window is borderless and gets the full rect. | |
| 304 | 357 | client <- ports.WindowMapped{ID: 1} | |
| 305 | - | r := scene(t, scenes).Windows[0].Rect | |
| 358 | + | r := l.one(scene(t, scenes)).Windows[0].Rect | |
| 306 | 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 | 360 | t.Fatalf("%v for outer %v", v, r) | |
| 308 | 361 | } | |
| 309 | 362 | // With a second column, the left window owns the shared separator: its | |
| 310 | 363 | // client is 2px narrower on the right; the right window keeps its rect. | |
| 311 | 364 | client <- ports.WindowMapped{ID: 2} | |
| 312 | - | s := scene(t, scenes) | |
| 365 | + | s := l.one(scene(t, scenes)) | |
| 313 | 366 | r = s.Windows[1].Rect | |
| 314 | 367 | if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 { | |
| 315 | 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 | 464 | cfg := config.Defaults() | |
| 465 | + | cfg.Animations.On = animated | |
| 388 | 466 | client := make(chan ports.ClientEvent, 8) | |
| 389 | 467 | input := make(chan ports.InputEvent, 8) | |
| 390 | 468 | output := make(chan ports.OutputEvent, 8) | |
| 391 | 469 | commands := make(chan ports.ClientCommand, 64) | |
| 392 | 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 | 479 | if err != nil { | |
| 395 | 480 | t.Fatal(err) | |
| 396 | 481 | } | |
| @@ -406,7 +491,7 @@ func TestWorkspaceSwitch(t *testing.T) { | |||
| 406 | 491 | } | |
| 407 | 492 | // AZERTY: the 2 key prints eacute; the default bind is on its physical key (code 3). | |
| 408 | 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 | 495 | if len(s.Windows) != 1 || !s.Windows[0].Hidden { | |
| 411 | 496 | t.Fatal(s) | |
| 412 | 497 | } | |
| @@ -425,20 +510,32 @@ func TestWorkspaceSwitch(t *testing.T) { | |||
| 425 | 510 | } | |
| 426 | 511 | // A new window opens on workspace 2, alone and borderless. | |
| 427 | 512 | client <- ports.WindowMapped{ID: 2} | |
| 428 | - | s = scene(t, scenes) | |
| 513 | + | s = settledScene(t, sc, frames, scenes, scene(t, scenes), false) | |
| 429 | 514 | if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || s.Windows[1].Inset != 0 { | |
| 430 | 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 | 524 | cfg := config.Defaults() | |
| 525 | + | cfg.Animations.On = animated | |
| 436 | 526 | client := make(chan ports.ClientEvent, 8) | |
| 437 | 527 | input := make(chan ports.InputEvent, 8) | |
| 438 | 528 | output := make(chan ports.OutputEvent, 8) | |
| 439 | 529 | commands := make(chan ports.ClientCommand, 64) | |
| 440 | 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 | 539 | if err != nil { | |
| 443 | 540 | t.Fatal(err) | |
| 444 | 541 | } | |
| @@ -458,7 +555,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) { | |||
| 458 | 555 | } | |
| 459 | 556 | // Cmd+2 hides window 1; the pointer must leave it. | |
| 460 | 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 | 559 | var cleared bool | |
| 463 | 560 | for len(commands) > 0 { | |
| 464 | 561 | if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 { | |
| @@ -472,7 +569,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) { | |||
| 472 | 569 | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 473 | 570 | input <- ports.PointerButton{Button: 0x110} | |
| 474 | 571 | client <- ports.WindowMapped{ID: 2} | |
| 475 | - | s := scene(t, scenes) | |
| 572 | + | s := settledScene(t, sc, frames, scenes, scene(t, scenes), false) | |
| 476 | 573 | if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden { | |
| 477 | 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 | 615 | cfg := config.Defaults() | |
| 517 | 616 | cfg.Border.Width = 0 | |
| 518 | 617 | cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}} | |
| @@ -524,7 +623,9 @@ func TestOutputScale(t *testing.T) { | |||
| 524 | 623 | reload := make(chan ports.ConfigChanged, 1) | |
| 525 | 624 | commands := make(chan ports.ClientCommand, 64) | |
| 526 | 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 | 629 | if err != nil { | |
| 529 | 630 | t.Fatal(err) | |
| 530 | 631 | } | |
| @@ -541,7 +642,7 @@ func TestOutputScale(t *testing.T) { | |||
| 541 | 642 | t.Fatal(v) | |
| 542 | 643 | } | |
| 543 | 644 | client <- ports.WindowMapped{ID: 1} | |
| 544 | - | scene(t, scenes) | |
| 645 | + | l.one(scene(t, scenes)) | |
| 545 | 646 | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2", Visible: true}) { | |
| 546 | 647 | t.Fatal(v) | |
| 547 | 648 | } | |
| @@ -550,7 +651,13 @@ func TestOutputScale(t *testing.T) { | |||
| 550 | 651 | } | |
| 551 | 652 | // The pointer arrives in global logical pixels. | |
| 552 | 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 | 661 | t.Fatal(v) | |
| 555 | 662 | } | |
| 556 | 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 | 748 | cfg := config.Defaults() | |
| 640 | 749 | cfg.Border.Width = 0 | |
| 641 | 750 | cfg.Layout.Gaps = 0 | |
| @@ -645,7 +754,9 @@ func TestPointerConstraint(t *testing.T) { | |||
| 645 | 754 | commands := make(chan ports.ClientCommand, 64) | |
| 646 | 755 | scenes := make(chan []ports.Scene, 1) | |
| 647 | 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 | 760 | if err != nil { | |
| 650 | 761 | t.Fatal(err) | |
| 651 | 762 | } | |
| @@ -659,7 +770,7 @@ func TestPointerConstraint(t *testing.T) { | |||
| 659 | 770 | input <- ports.PointerMotion{X: 150, Y: 40} | |
| 660 | 771 | scene(t, scenes) // the pointer moves the focus to B | |
| 661 | 772 | client <- ports.WindowMapped{ID: 1} | |
| 662 | - | r := receive(t, scenes)[1].Windows[0].Rect | |
| 773 | + | r := l.land(receive(t, scenes))[1].Windows[0].Rect | |
| 663 | 774 | // The confine region is window-local and clipped to the window. | |
| 664 | 775 | client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 10, Y: 10, W: 1000, H: 20}}} | |
| 665 | 776 | scene(t, scenes) | |
| @@ -843,7 +954,9 @@ func TestDialogOverFullscreenHit(t *testing.T) { | |||
| 843 | 954 | // A window that asks for fullscreen as it maps (Wine at a remembered | |
| 844 | 955 | // monitor size) stays in its column: the windows it opens next are seen. | |
| 845 | 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 | 960 | cfg := config.Defaults() | |
| 848 | 961 | cfg.Layout.Overflow = "fixed" | |
| 849 | 962 | cfg.Focus.Animation = ports.FocusAnimationOff | |
| @@ -851,11 +964,15 @@ func TestFullscreenAtMapIgnored(t *testing.T) { | |||
| 851 | 964 | output := make(chan ports.OutputEvent, 8) | |
| 852 | 965 | commands := make(chan ports.ClientCommand, 64) | |
| 853 | 966 | scenes := make(chan []ports.Scene, 1) | |
| 854 | - | // Core reads the time on its goroutine. | |
| 855 | - | var now atomic.Int64 | |
| 856 | - | clock := portsmocks.NewMockClock(t) | |
| 857 | - | clock.EXPECT().Now().RunAndReturn(func() time.Time { return time.Unix(now.Load(), 0) }) | |
| 858 | - | c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock}) | |
| 967 | + | // Core reads the time on its goroutine: the clock is the test's. | |
| 968 | + | ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes} | |
| 969 | + | l := newLanding(t, animated, &cfg, &ch, "OUT-1") | |
| 970 | + | clk := l.clk | |
| 971 | + | if clk == nil { | |
| 972 | + | clk = newStepClock(t) | |
| 973 | + | ch.Clock = clk.clock | |
| 974 | + | } | |
| 975 | + | c, err := core.New(cfg, ch) | |
| 859 | 976 | if err != nil { | |
| 860 | 977 | t.Fatal(err) | |
| 861 | 978 | } | |
| @@ -879,7 +996,10 @@ func TestFullscreenAtMapIgnored(t *testing.T) { | |||
| 879 | 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 | 1003 | // Only the focused window may cover the screen (ADR 011). | |
| 884 | 1004 | client <- ports.WindowUnmapped{ID: 3} | |
| 885 | 1005 | scene(t, scenes) | |
| @@ -924,16 +1044,21 @@ func TestFixedFullscreenArrivalWaits(t *testing.T) { | |||
| 924 | 1044 | } | |
| 925 | 1045 | ||
| 926 | 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 | 1050 | cfg := config.Defaults() | |
| 929 | 1051 | cfg.Focus.Animation = ports.FocusAnimationOff | |
| 930 | 1052 | client := make(chan ports.ClientEvent, 8) | |
| 931 | 1053 | output := make(chan ports.OutputEvent, 8) | |
| 932 | 1054 | commands := make(chan ports.ClientCommand, 64) | |
| 933 | 1055 | scenes := make(chan []ports.Scene, 1) | |
| 934 | - | clock := portsmocks.NewMockClock(t) | |
| 935 | - | clock.EXPECT().Now().Return(time.Unix(0, 0)) | |
| 936 | - | c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock}) | |
| 1056 | + | ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes} | |
| 1057 | + | l := newLanding(t, animated, &cfg, &ch, "OUT-1") | |
| 1058 | + | if l.clk == nil { | |
| 1059 | + | ch.Clock = newStepClock(t).clock | |
| 1060 | + | } | |
| 1061 | + | c, err := core.New(cfg, ch) | |
| 937 | 1062 | if err != nil { | |
| 938 | 1063 | t.Fatal(err) | |
| 939 | 1064 | } | |
| @@ -943,7 +1068,7 @@ func TestExternalFullscreenAtMapApplies(t *testing.T) { | |||
| 943 | 1068 | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 944 | 1069 | scene(t, scenes) | |
| 945 | 1070 | client <- ports.WindowMapped{ID: 1} | |
| 946 | - | scene(t, scenes) | |
| 1071 | + | l.one(scene(t, scenes)) | |
| 947 | 1072 | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true} | |
| 948 | 1073 | s := scene(t, scenes) | |
| 949 | 1074 | if len(s.Windows) != 1 || !s.Windows[0].Fullscreen { | |
| @@ -1022,18 +1147,20 @@ func TestExternalFullscreenInOverview(t *testing.T) { | |||
| 1022 | 1147 | // focus-window-down from a fullscreen tile at the bottom of its column | |
| 1023 | 1148 | // goes to the next workspace and fullscreen stays. | |
| 1024 | 1149 | func TestFocusWindowDownKeepsFullscreen(t *testing.T) { | |
| 1025 | - | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | |
| 1026 | - | r.mapWindow(t, 1) | |
| 1027 | - | r.mapWindow(t, 2) | |
| 1028 | - | r.key(t, "f", ports.ModAlt|ports.ModShift) | |
| 1029 | - | set := r.key(t, "Down", ports.ModAlt) | |
| 1030 | - | if got, _ := windowsOf(set, "DP-1"); len(got) != 0 { | |
| 1031 | - | t.Fatal("still on the first workspace:", got) | |
| 1032 | - | } | |
| 1033 | - | set = r.key(t, "Up", ports.ModAlt) | |
| 1034 | - | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 { | |
| 1035 | - | t.Fatal("fullscreen lost:", got, focused) | |
| 1036 | - | } | |
| 1150 | + | both(t, func(t *testing.T, animated bool) { | |
| 1151 | + | r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | |
| 1152 | + | r.mapLanded(t, 1) | |
| 1153 | + | r.mapLanded(t, 2) | |
| 1154 | + | r.keyLanded(t, "f", ports.ModAlt|ports.ModShift) | |
| 1155 | + | set := r.keyLanded(t, "Down", ports.ModAlt) | |
| 1156 | + | if got, _ := windowsOf(set, "DP-1"); len(got) != 0 { | |
| 1157 | + | t.Fatal("still on the first workspace:", got) | |
| 1158 | + | } | |
| 1159 | + | set = r.keyLanded(t, "Up", ports.ModAlt) | |
| 1160 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 { | |
| 1161 | + | t.Fatal("fullscreen lost:", got, focused) | |
| 1162 | + | } | |
| 1163 | + | }) | |
| 1037 | 1164 | } | |
| 1038 | 1165 | ||
| 1039 | 1166 | // 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 | 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 | 238 | func (c *Core) endDrag(ctx context.Context) error { | |
| 232 | 239 | d := c.drag | |
| 233 | 240 | c.drag = nil | |
| 234 | 241 | if !d.float { | |
| 235 | 242 | if t := c.dropAt(d.id, c.cursorX, c.cursorY); t.kind != dropNone { | |
| 243 | + | now := c.now() | |
| 244 | + | shots := c.snapshot(now) | |
| 236 | 245 | c.drop(d.id, t) | |
| 246 | + | c.transition(shots, now) | |
| 237 | 247 | // Slots and guests follow the windows, as after a bind. | |
| 238 | 248 | if err := c.workspaceVisible(ctx, false); err != nil { | |
| 239 | 249 | return err | |
M internal/core/drag_test.go +216 -10
| @@ -21,11 +21,37 @@ type dragRig struct { | |||
| 21 | 21 | scenes chan []ports.Scene | |
| 22 | 22 | last []ports.Scene | |
| 23 | 23 | 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 | 34 | // startDrag runs core with Super as Cmd, no border, 10 px gaps, and | |
| 27 | 35 | // the given outputs. edit may change the config. | |
| 28 | 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 | 55 | t.Helper() | |
| 30 | 56 | cfg := config.Defaults() | |
| 31 | 57 | cfg.Border.Width = 0 | |
| @@ -45,7 +71,18 @@ func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn | |||
| 45 | 71 | output: make(chan ports.OutputEvent), commands: make(chan ports.ClientCommand, 4096), | |
| 46 | 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 | 86 | if err != nil { | |
| 50 | 87 | t.Fatal(err) | |
| 51 | 88 | } | |
| @@ -55,6 +92,7 @@ func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn | |||
| 55 | 92 | t.Cleanup(func() { cancel(); <-done }) | |
| 56 | 93 | for _, o := range outs { | |
| 57 | 94 | r.output <- ports.OutputAdded{Info: o} | |
| 95 | + | r.outputs = append(r.outputs, o.Name) | |
| 58 | 96 | r.settle(t) | |
| 59 | 97 | } | |
| 60 | 98 | return r | |
| @@ -79,9 +117,16 @@ func send[T any](ch chan T, v T) { | |||
| 79 | 117 | const barrier ports.WindowID = 1 << 30 | |
| 80 | 118 | ||
| 81 | 119 | // 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 | 123 | func (r *dragRig) settle(t *testing.T) []ports.Scene { | |
| 84 | 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 | 130 | send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier})) | |
| 86 | 131 | send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier})) | |
| 87 | 132 | select { | |
| @@ -255,7 +300,12 @@ func TestDragEscapeRestoresFloat(t *testing.T) { | |||
| 255 | 300 | } | |
| 256 | 301 | ||
| 257 | 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 | 309 | for id := ports.WindowID(1); id <= 3; id++ { | |
| 260 | 310 | r.client <- ports.WindowMapped{ID: id} | |
| 261 | 311 | r.settle(t) | |
| @@ -279,6 +329,8 @@ func TestDragTileZonesAndHints(t *testing.T) { | |||
| 279 | 329 | if got := columnsOf(set, "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1}, {2}, {3}}, slices.Equal) { | |
| 280 | 330 | t.Fatal(got) | |
| 281 | 331 | } | |
| 332 | + | // The drop itself must run a spring: the guard counts from here. | |
| 333 | + | r.lands = 0 | |
| 282 | 334 | r.in(ports.PointerButton{Button: 0x110}) | |
| 283 | 335 | set = r.settle(t) | |
| 284 | 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 | 350 | if hints := dragScene(r.settle(t), "A").DropHints; len(hints) != 4 { | |
| 299 | 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 | 355 | r.in(ports.PointerButton{Button: 0x110}) | |
| 302 | 356 | set = r.settle(t) | |
| 303 | 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 | 364 | r.in(ports.PointerButton{Button: 0x110, Pressed: true}) | |
| 311 | 365 | r.moveTo(float64(b.X+b.W/2), float64(b.Y+b.H/2), 6) | |
| 312 | 366 | r.settle(t) | |
| 367 | + | // The drop itself must run a spring: the guard counts from here. | |
| 368 | + | r.lands = 0 | |
| 313 | 369 | r.in(ports.PointerButton{Button: 0x110}) | |
| 314 | 370 | set = r.settle(t) | |
| 315 | 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 | 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 | 413 | for id := ports.WindowID(1); id <= 3; id++ { | |
| 338 | 414 | r.client <- ports.WindowMapped{ID: id} | |
| 339 | 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 | 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 | 554 | for id := ports.WindowID(1); id <= 2; id++ { | |
| 373 | 555 | r.client <- ports.WindowMapped{ID: id} | |
| 374 | 556 | r.settle(t) | |
| @@ -385,6 +567,8 @@ func TestDragFixedNoGapZone(t *testing.T) { | |||
| 385 | 567 | // Top band of 2 stacks 1 above it. | |
| 386 | 568 | r.moveTo(float64(two.X+two.W/2), float64(two.Y+3), 3) | |
| 387 | 569 | r.settle(t) | |
| 570 | + | // The drop itself must run a spring: the guard counts from here. | |
| 571 | + | r.lands = 0 | |
| 388 | 572 | r.in(ports.PointerButton{Button: 0x110}) | |
| 389 | 573 | if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{1, 2}}, slices.Equal) { | |
| 390 | 574 | t.Fatal(got) | |
| @@ -392,8 +576,12 @@ func TestDragFixedNoGapZone(t *testing.T) { | |||
| 392 | 576 | } | |
| 393 | 577 | ||
| 394 | 578 | func TestDragAcrossOutputs(t *testing.T) { | |
| 395 | - | b := ports.OutputInfo{Name: "B", Width: 800, Height: 600} | |
| 396 | - | r := startDragRig(t, nil, dragOut, b) | |
| 579 | + | both(t, func(t *testing.T, animated bool) { | |
| 580 | + | dragAcrossOutputs(t, startDragMode(t, animated, nil, dragOut, ports.OutputInfo{Name: "B", Width: 800, Height: 600})) | |
| 581 | + | }) | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | func dragAcrossOutputs(t *testing.T, r *dragRig) { | |
| 397 | 585 | r.client <- ports.WindowMapped{ID: 1} | |
| 398 | 586 | r.client <- ports.WindowMapped{ID: 2} | |
| 399 | 587 | r.settle(t) | |
| @@ -414,6 +602,7 @@ func TestDragAcrossOutputs(t *testing.T) { | |||
| 414 | 602 | if len(dragScene(set, "B").DropHints) == 0 || len(dragScene(set, "A").DropHints) != 0 { | |
| 415 | 603 | t.Fatalf("hints A %+v B %+v", dragScene(set, "A").DropHints, dragScene(set, "B").DropHints) | |
| 416 | 604 | } | |
| 605 | + | r.lands = 0 | |
| 417 | 606 | r.in(ports.PointerButton{Button: 0x110}) | |
| 418 | 607 | set = r.settle(t) | |
| 419 | 608 | if _, ok := windowIn(set, "B", 1); !ok { | |
| @@ -484,7 +673,10 @@ func TestLockClearsDrag(t *testing.T) { | |||
| 484 | 673 | } | |
| 485 | 674 | ||
| 486 | 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 | 680 | for id := ports.WindowID(1); id <= 4; id++ { | |
| 489 | 681 | r.client <- ports.WindowMapped{ID: id} | |
| 490 | 682 | r.settle(t) | |
| @@ -526,7 +718,12 @@ func TestDragEdgeScroll(t *testing.T) { | |||
| 526 | 718 | } | |
| 527 | 719 | ||
| 528 | 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 | 727 | for id := ports.WindowID(1); id <= 3; id++ { | |
| 531 | 728 | r.client <- ports.WindowMapped{ID: id} | |
| 532 | 729 | r.settle(t) | |
| @@ -542,6 +739,8 @@ func TestDragFullFixedStacksInOutlinedColumn(t *testing.T) { | |||
| 542 | 739 | r.press(t, 0x110, ports.ModSuper) | |
| 543 | 740 | r.moveTo(float64(one.X+one.W/2), float64(one.Y+3), 2) | |
| 544 | 741 | r.settle(t) | |
| 742 | + | // The drop itself must run a spring: the guard counts from here. | |
| 743 | + | r.lands = 0 | |
| 545 | 744 | r.in(ports.PointerButton{Button: 0x110}) | |
| 546 | 745 | set = r.settle(t) | |
| 547 | 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 | 752 | r.in(ports.PointerButton{Button: 0x110, Pressed: true}) | |
| 554 | 753 | r.moveTo(float64(two.X+two.W/2), float64(two.Y+two.H/2), 4) | |
| 555 | 754 | r.settle(t) | |
| 755 | + | // The drop itself must run a spring: the guard counts from here. | |
| 756 | + | r.lands = 0 | |
| 556 | 757 | r.in(ports.PointerButton{Button: 0x110}) | |
| 557 | 758 | if got := columnsOf(r.settle(t), "A"); !slices.EqualFunc(got, [][]ports.WindowID{{4}, {2, 1}, {3}}, slices.Equal) { | |
| 558 | 759 | t.Fatal(got) | |
| @@ -623,7 +824,12 @@ func TestClientDragUsesLastPress(t *testing.T) { | |||
| 623 | 824 | } | |
| 624 | 825 | ||
| 625 | 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 | 833 | for id := ports.WindowID(1); id <= 4; id++ { | |
| 628 | 834 | r.client <- ports.WindowMapped{ID: id} | |
| 629 | 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 | 23 | func TestFreeFloatKeepsPlaceAcrossMonitors(t *testing.T) { | |
| 24 | - | r := startMulti(t, func(c *ports.Config) { c.Layout.Gaps = 0 }, left, right) | |
| 25 | - | r.mapWindow(t, 1) | |
| 26 | - | r.mapWindow(t, 2) | |
| 27 | - | r.key(t, "space", ports.ModAlt|ports.ModShift) | |
| 28 | - | // Nudge left twice: 100 px left of centre on a 200 px output stops at | |
| 29 | - | // the edge. | |
| 30 | - | r.key(t, "Left", ports.ModAlt|ports.ModShift) | |
| 31 | - | set := r.key(t, "Left", ports.ModAlt|ports.ModShift) | |
| 32 | - | before, ok := windowIn(set, "DP-1", 2) | |
| 33 | - | if !ok || !before.Floating || before.Rect.X != 0 { | |
| 34 | - | t.Fatalf("%+v", before) | |
| 35 | - | } | |
| 36 | - | // The workspace moves to the 400x200 output: the centre keeps its | |
| 37 | - | // fractions of the usable area (1/4, 1/2), the size stays. | |
| 38 | - | set = r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |
| 39 | - | after, ok := windowIn(set, "DP-2", 2) | |
| 40 | - | if !ok || after.Rect.W != before.Rect.W || after.Rect.X+after.Rect.W/2 != 100 || after.Rect.Y+after.Rect.H/2 != 100 { | |
| 41 | - | t.Fatalf("%+v", after) | |
| 42 | - | } | |
| 24 | + | both(t, func(t *testing.T, animated bool) { | |
| 25 | + | r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Gaps = 0 }, left, right) | |
| 26 | + | r.mapLanded(t, 1) | |
| 27 | + | r.mapLanded(t, 2) | |
| 28 | + | r.keyLanded(t, "space", ports.ModAlt|ports.ModShift) | |
| 29 | + | // Nudge left twice: 100 px left of centre on a 200 px output stops at | |
| 30 | + | // the edge. | |
| 31 | + | r.keyLanded(t, "Left", ports.ModAlt|ports.ModShift) | |
| 32 | + | set := r.keyLanded(t, "Left", ports.ModAlt|ports.ModShift) | |
| 33 | + | before, ok := windowIn(set, "DP-1", 2) | |
| 34 | + | if !ok || !before.Floating || before.Rect.X != 0 { | |
| 35 | + | t.Fatalf("%+v", before) | |
| 36 | + | } | |
| 37 | + | // The workspace moves to the 400x200 output: the centre keeps its | |
| 38 | + | // fractions of the usable area (1/4, 1/2), the size stays. | |
| 39 | + | set = r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |
| 40 | + | after, ok := windowIn(set, "DP-2", 2) | |
| 41 | + | if !ok || after.Rect.W != before.Rect.W || after.Rect.X+after.Rect.W/2 != 100 || after.Rect.Y+after.Rect.H/2 != 100 { | |
| 42 | + | t.Fatalf("%+v", after) | |
| 43 | + | } | |
| 44 | + | }) | |
| 43 | 45 | } | |
M internal/core/gesture.go +115 -22
| @@ -36,7 +36,9 @@ const ( | |||
| 36 | 36 | swipeWorkspaces | |
| 37 | 37 | swipeDiscrete | |
| 38 | 38 | // 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 | 42 | swipeOverview | |
| 41 | 43 | // swipeDropped ignores the rest of a swipe whose workspace changed. | |
| 42 | 44 | swipeDropped | |
| @@ -166,12 +168,14 @@ func (c *Core) decide(g *swipeGesture) { | |||
| 166 | 168 | case g.fingers == 4: | |
| 167 | 169 | // Four fingers sideways do nothing. | |
| 168 | 170 | g.mode = swipeDropped | |
| 169 | - | case m.ov.open: | |
| 171 | + | case m.ov.open || !c.animOn(): | |
| 170 | 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 | 175 | g.mode, g.snap = swipeDiscrete, newStepSwipe() | |
| 172 | 176 | case g.horizontal && w.slidable(): | |
| 173 | 177 | g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints() | |
| 174 | - | w.motion = nil | |
| 178 | + | w.motion = motion{} | |
| 175 | 179 | g.snap = newSnapSwipe(float64(w.ViewX)+w.shift, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W))) | |
| 176 | 180 | case !g.horizontal && m.shown == nil: | |
| 177 | 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 | 185 | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | |
| 182 | 186 | g.list = slices.Clone(m.Workspaces) | |
| 183 | - | m.switchMotion, m.switchList = nil, nil | |
| 187 | + | m.switchMotion, m.switchList = motion{}, nil | |
| 184 | 188 | g.snap = newSnapSwipe(float64(m.Active)+m.switchOff, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand) | |
| 185 | 189 | default: | |
| 186 | 190 | g.mode, g.snap = swipeDiscrete, newStepSwipe() | |
| @@ -216,7 +220,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 216 | 220 | c.keyboard.takeBack() | |
| 217 | 221 | } | |
| 218 | 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 | 228 | case swipeWorkspaces: | |
| 221 | 229 | if g.listChanged(m) { | |
| 222 | 230 | m.stopSwitch() | |
| @@ -240,16 +248,28 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 240 | 248 | c.keyboard.takeBack() | |
| 241 | 249 | } | |
| 242 | 250 | 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 | 256 | case swipeOverview: | |
| 245 | 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 | 265 | return false | |
| 248 | 266 | } | |
| 249 | 267 | before := m.Current() | |
| 268 | + | shots := c.snapshot(now) | |
| 250 | 269 | m.ToggleOverview() | |
| 251 | 270 | c.focusScreen = c.screenIndex(g.screen.name()) | |
| 252 | 271 | c.keyboard.takeBack() | |
| 272 | + | c.transition(shots, now) | |
| 253 | 273 | return m.Current() != before | |
| 254 | 274 | case swipeDiscrete: | |
| 255 | 275 | step := g.snap.step(e.Cancelled, e.Time) | |
| @@ -271,17 +291,36 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 271 | 291 | return false | |
| 272 | 292 | } | |
| 273 | 293 | before := c.cur().mon.Current() | |
| 294 | + | shots := c.snapshot(now) | |
| 274 | 295 | c.keyboard.takeBack() | |
| 275 | 296 | if mon := c.cur().mon; mon.ov.open { | |
| 276 | 297 | mon.overviewFocus(a) | |
| 277 | 298 | } else { | |
| 278 | 299 | c.applyAction(a) | |
| 279 | 300 | } | |
| 301 | + | c.transition(shots, now) | |
| 280 | 302 | return c.cur().mon.Current() != before | |
| 281 | 303 | } | |
| 282 | 304 | 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 | 324 | // swipedWorkspace is the workspace on the swipe's screen, or nil without | |
| 286 | 325 | // a swipe. | |
| 287 | 326 | func (c *Core) swipedWorkspace() *Workspace { | |
| @@ -317,11 +356,49 @@ func (c *Core) hasScreen(s *screen) bool { | |||
| 317 | 356 | return false | |
| 318 | 357 | } | |
| 319 | 358 | ||
| 320 | - | // animate moves the running springs to now; settled ones stop. | |
| 321 | - | func (c *Core) animate(now time.Time) { | |
| 359 | + | // animOn reports whether transitions run (animations = on). | |
| 360 | + | func (c *Core) animOn() bool { return c.cfg.Animations.On } | |
| 361 | + | ||
| 362 | + | // spring starts s at now, stretched by animations.speed (Slowdown). Every motion | |
| 363 | + | // core creates goes through it, except a retarget, which keeps the slowdown | |
| 364 | + | // of the motion it replaces. | |
| 365 | + | func (c *Core) spring(s spring, now time.Time) motion { | |
| 366 | + | return newMotion(s, now, c.cfg.Animations.Slowdown) | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | // stopAnimations settles every running spring where it is going and stops | |
| 370 | + | // the frame timer: a session lock or a switch to animations = off. A swipe | |
| 371 | + | // in progress keeps its fingers' view: only running springs stop. Per-window | |
| 372 | + | // rect motions stop too. | |
| 373 | + | func (c *Core) stopAnimations() { | |
| 322 | 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 | 400 | m := sc.mon | |
| 324 | - | if m.switchMotion != nil { | |
| 401 | + | if m.switchMotion.on { | |
| 325 | 402 | v, done := m.switchMotion.at(now) | |
| 326 | 403 | m.switchOff = v | |
| 327 | 404 | if done { | |
| @@ -329,7 +406,7 @@ func (c *Core) animate(now time.Time) { | |||
| 329 | 406 | } | |
| 330 | 407 | } | |
| 331 | 408 | for w := range m.all() { | |
| 332 | - | if w.motion != nil { | |
| 409 | + | if w.motion.on { | |
| 333 | 410 | v, done := w.motion.at(now) | |
| 334 | 411 | w.shift = v | |
| 335 | 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 | 428 | // sliding reports whether a spring runs on the named output. | |
| 344 | 429 | func (c *Core) sliding(output string) bool { | |
| 345 | 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 | 434 | // animating reports whether a spring or the focus pulse runs on any output. | |
| @@ -352,7 +437,7 @@ func (c *Core) animating() bool { | |||
| 352 | 437 | return true | |
| 353 | 438 | } | |
| 354 | 439 | for _, sc := range c.screens { | |
| 355 | - | if sc.mon.springing() { | |
| 440 | + | if sc.springing() { | |
| 356 | 441 | return true | |
| 357 | 442 | } | |
| 358 | 443 | } | |
| @@ -360,11 +445,11 @@ func (c *Core) animating() bool { | |||
| 360 | 445 | } | |
| 361 | 446 | ||
| 362 | 447 | func (m *Monitor) springing() bool { | |
| 363 | - | if m.switchMotion != nil { | |
| 448 | + | if m.switchMotion.on { | |
| 364 | 449 | return true | |
| 365 | 450 | } | |
| 366 | 451 | for w := range m.all() { | |
| 367 | - | if w.motion != nil { | |
| 452 | + | if w.motion.on { | |
| 368 | 453 | return true | |
| 369 | 454 | } | |
| 370 | 455 | } | |
| @@ -376,22 +461,30 @@ func (m *Monitor) springing() bool { | |||
| 376 | 461 | func (c *Core) frameFallback() time.Duration { | |
| 377 | 462 | refresh := 60000 | |
| 378 | 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 | 465 | refresh = min(refresh, r) | |
| 381 | 466 | } | |
| 382 | 467 | } | |
| 383 | 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 | 474 | func (c *Core) armFrame() { | |
| 388 | - | c.stopFrame() | |
| 389 | - | c.frameC, c.frameStop = newTimer(c.ch.Clock, c.frameFallback()) | |
| 475 | + | d := c.frameFallback() | |
| 476 | + | if c.frameTimer == nil { | |
| 477 | + | c.frameTimer = newPortTimer(c.ch.Clock, d) | |
| 478 | + | } else { | |
| 479 | + | c.frameTimer.Stop() | |
| 480 | + | c.frameTimer.Reset(d) | |
| 481 | + | } | |
| 482 | + | c.frameC = c.frameTimer.C() | |
| 390 | 483 | } | |
| 391 | 484 | ||
| 392 | 485 | func (c *Core) stopFrame() { | |
| 393 | - | if c.frameStop != nil { | |
| 394 | - | c.frameStop() | |
| 486 | + | if c.frameC != nil { | |
| 487 | + | c.frameTimer.Stop() | |
| 395 | 488 | } | |
| 396 | - | c.frameC, c.frameStop = nil, nil | |
| 489 | + | 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 | 125 | // A click focuses the window and its output. | |
| 126 | 126 | s, w := c.screenOf(id) | |
| 127 | 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 | 130 | w.Click(id) | |
| 129 | 131 | c.focusScreen = c.screenIndex(s.name()) | |
| 132 | + | c.transition(before, now) | |
| 130 | 133 | if err := c.publish(ctx); err != nil { | |
| 131 | 134 | return err | |
| 132 | 135 | } | |
| @@ -140,7 +143,12 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 140 | 143 | c.scrollStop(v) | |
| 141 | 144 | // In the overview, scrolling moves the selection. | |
| 142 | 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 | 150 | if c.cur().mon.overviewScroll(v) { | |
| 151 | + | c.transition(shots, now) | |
| 144 | 152 | if err := c.workspaceVisible(ctx, true); err != nil { | |
| 145 | 153 | return err | |
| 146 | 154 | } | |
| @@ -189,8 +197,14 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 189 | 197 | // run binds, and are not forwarded. | |
| 190 | 198 | // A launcher or a menu holding the keyboard gets them first. | |
| 191 | 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 | 205 | if key.Pressed && mon.overviewKey(key) { | |
| 193 | 206 | c.pressed[heldKey(key)] = true | |
| 207 | + | c.transition(shots, now) | |
| 194 | 208 | if err := c.workspaceVisible(ctx, true); err != nil { | |
| 195 | 209 | return err | |
| 196 | 210 | } | |
| @@ -260,6 +274,8 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 260 | 274 | } | |
| 261 | 275 | before := c.cur().mon.Current() | |
| 262 | 276 | swiped := c.swipedWorkspace() | |
| 277 | + | now := c.now() | |
| 278 | + | shots := c.snapshot(now) | |
| 263 | 279 | c.keyboard.takeBack() // a bind acts on the windows | |
| 264 | 280 | effect := c.applyAction(action) | |
| 265 | 281 | if effect.Quit { | |
| @@ -280,6 +296,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 280 | 296 | if c.swipedWorkspace() != swiped { | |
| 281 | 297 | c.dropSwipe() | |
| 282 | 298 | } | |
| 299 | + | c.transition(shots, now) | |
| 283 | 300 | if err := c.workspaceVisible(ctx, c.cur().mon.Current() != before); err != nil { | |
| 284 | 301 | return err | |
| 285 | 302 | } | |
M internal/core/layout.go +112 -23
| @@ -122,6 +122,18 @@ type Placement struct { | |||
| 122 | 122 | // Inset reserves room for drawn lines: all sides of a float; for | |
| 123 | 123 | // tiles without gaps, only the right and bottom shared sides. | |
| 124 | 124 | 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 | 139 | // Overflow says what happens past MaxColumns columns. | |
| @@ -154,7 +166,7 @@ type Workspace struct { | |||
| 154 | 166 | // shift slides the columns on screen past ViewX, in logical pixels, | |
| 155 | 167 | // while a swipe follows the fingers or its spring (motion) lands. | |
| 156 | 168 | shift float64 | |
| 157 | - | motion *motion | |
| 169 | + | motion motion | |
| 158 | 170 | // Output is the effective viewport in monitor coordinates: the whole | |
| 159 | 171 | // monitor unless the workspace has a size override, then a centered | |
| 160 | 172 | // rectangle no larger than the monitor. Fullscreen fills it. | |
| @@ -204,6 +216,14 @@ type Workspace struct { | |||
| 204 | 216 | homePos int | |
| 205 | 217 | // termAt is when core last spawned a terminal for this workspace. | |
| 206 | 218 | 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 | 229 | // origPlace remembers where a stashed window was: its column, its row in a | |
| @@ -242,6 +262,35 @@ func (w *Workspace) empty() bool { | |||
| 242 | 262 | 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 | 294 | // windows lists every window: columns first, then the stash, then native | |
| 246 | 295 | // floats. | |
| 247 | 296 | func (w *Workspace) windows() []WindowID { | |
| @@ -1349,15 +1398,22 @@ func (w *Workspace) scroll() { | |||
| 1349 | 1398 | ||
| 1350 | 1399 | // columnRects returns each column's area on screen, before stacking windows. | |
| 1351 | 1400 | func (w *Workspace) columnRects() []Rect { | |
| 1352 | - | return w.columnRectsFor(false) | |
| 1401 | + | return w.columnRectsInto(nil, false) | |
| 1353 | 1402 | } | |
| 1354 | 1403 | ||
| 1355 | 1404 | // columnRectsFor shares the fixed layout geometry with the overview's hidden | |
| 1356 | 1405 | // columns card, without changing FullWidth or the clients' saved buffers. | |
| 1357 | 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 | 1414 | g := w.gap() | |
| 1359 | 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 | 1417 | view := w.ViewX + w.shiftPixels() | |
| 1362 | 1418 | x := w.Usable.X + g | |
| 1363 | 1419 | for i := range w.Columns { | |
| @@ -1371,7 +1427,8 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect { | |||
| 1371 | 1427 | } | |
| 1372 | 1428 | k := max(w.MaxColumns, 1) | |
| 1373 | 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 | 1433 | if w.Overflow != OverflowFixed || len(w.Columns) <= k { | |
| 1377 | 1434 | return rects | |
| @@ -1388,7 +1445,7 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect { | |||
| 1388 | 1445 | // expandedRects places expanded column e over k-1 cells in its place. The | |
| 1389 | 1446 | // columns before it stack in a strip on its left, those after it on its | |
| 1390 | 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 | 1449 | g := w.gap() | |
| 1393 | 1450 | cell := max((w.Usable.W-g*(k+1))/k, 0) | |
| 1394 | 1451 | wide := (k-1)*cell + (k-2)*g | |
| @@ -1399,15 +1456,14 @@ func (w *Workspace) expandedRects(e, k, y, h int) []Rect { | |||
| 1399 | 1456 | leftW = max((rest-g)/2, 0) | |
| 1400 | 1457 | rightW = max(rest-g-leftW, 0) | |
| 1401 | 1458 | } | |
| 1402 | - | rects := make([]Rect, 0, len(w.Columns)) | |
| 1403 | 1459 | x := w.Usable.X + g | |
| 1404 | 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 | 1462 | x += leftW + g | |
| 1407 | 1463 | } | |
| 1408 | 1464 | rects = append(rects, Rect{X: x, Y: y, W: wide, H: h}) | |
| 1409 | 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 | 1468 | // Gaps wider than a narrow output would push columns past its edge. | |
| 1413 | 1469 | right := w.Usable.X + w.Usable.W | |
| @@ -1422,7 +1478,15 @@ func (w *Workspace) expandedRects(e, k, y, h int) []Rect { | |||
| 1422 | 1478 | // the rounding remainder. Rows past the bottom (gaps taller than r) are | |
| 1423 | 1479 | // clamped to it, like windows stacked in a column. | |
| 1424 | 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 | 1490 | avail := max(r.H-(n-1)*gap, 0) | |
| 1427 | 1491 | height := avail / n | |
| 1428 | 1492 | yy, bottom := r.Y, r.Y+r.H | |
| @@ -1436,17 +1500,25 @@ func stackRects(r Rect, n, gap int) []Rect { | |||
| 1436 | 1500 | rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh} | |
| 1437 | 1501 | yy += hh + gap | |
| 1438 | 1502 | } | |
| 1439 | - | return rows | |
| 1503 | + | return dst | |
| 1440 | 1504 | } | |
| 1441 | 1505 | ||
| 1442 | 1506 | // rowRects splits column rect r into the rows of c: by c.Shares when they | |
| 1443 | 1507 | // are valid, else equally (stackRects). | |
| 1444 | 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 | 1515 | n := len(c.Windows) | |
| 1446 | 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 | 1522 | avail := max(r.H-(n-1)*gap, 0) | |
| 1451 | 1523 | yy, bottom, used := r.Y, r.Y+r.H, 0 | |
| 1452 | 1524 | for i, share := range c.Shares { | |
| @@ -1460,7 +1532,7 @@ func rowRects(r Rect, c Column, gap int) []Rect { | |||
| 1460 | 1532 | rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh} | |
| 1461 | 1533 | yy += hh + gap | |
| 1462 | 1534 | } | |
| 1463 | - | return rows | |
| 1535 | + | return dst | |
| 1464 | 1536 | } | |
| 1465 | 1537 | ||
| 1466 | 1538 | // 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 | 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 { | |
| 1493 | - | var result []Placement | |
| 1564 | + | // Layout places every window of the workspace in a fresh slice the caller | |
| 1565 | + | // owns. The per-frame paths use layoutInto instead. | |
| 1566 | + | func (w *Workspace) Layout() []Placement { return w.appendLayout(nil) } | |
| 1567 | + | ||
| 1568 | + | // layoutInto is Layout built in dst[:0]: the result aliases dst's storage | |
| 1569 | + | // (a reused buffer's previous result is overwritten), so only a caller that | |
| 1570 | + | // is done with the previous one may pass it. It never returns the | |
| 1571 | + | // workspace's own scratch. | |
| 1572 | + | func (w *Workspace) layoutInto(dst []Placement) []Placement { return w.appendLayout(dst[:0]) } | |
| 1573 | + | ||
| 1574 | + | // appendLayout appends the workspace's placements to dst. | |
| 1575 | + | func (w *Workspace) appendLayout(dst []Placement) []Placement { | |
| 1494 | 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 | 1582 | focusedID, _ := w.Focused() | |
| 1497 | 1583 | cover := w.cover() | |
| 1584 | + | tiles := w.tileBuf[:0] | |
| 1498 | 1585 | for i, c := range w.Columns { | |
| 1499 | 1586 | col := cols[i] | |
| 1500 | 1587 | n := len(c.Windows) | |
| @@ -1505,7 +1592,8 @@ func (w *Workspace) Layout() []Placement { | |||
| 1505 | 1592 | if fullColumn { | |
| 1506 | 1593 | 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 | 1597 | for j, id := range c.Windows { | |
| 1510 | 1598 | r := rows[j] | |
| 1511 | 1599 | full := w.fullscreen == id && id != 0 | |
| @@ -1523,14 +1611,15 @@ func (w *Workspace) Layout() []Placement { | |||
| 1523 | 1611 | if hidden { | |
| 1524 | 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 | 1620 | // The column/stash group sits between demoted covering floats and | |
| 1531 | 1621 | // floats above it. Keep the order within each group stable. | |
| 1532 | - | tiles := result | |
| 1533 | - | result = nil | |
| 1622 | + | result := dst | |
| 1534 | 1623 | const ( | |
| 1535 | 1624 | belowTiles = iota | |
| 1536 | 1625 | coveringFloats | |
| @@ -1570,9 +1659,9 @@ func (w *Workspace) Layout() []Placement { | |||
| 1570 | 1659 | // The stash the user showed over a covering window tops it, under | |
| 1571 | 1660 | // the dialogs it may open meanwhile. | |
| 1572 | 1661 | appendFloats(coveringFloats) | |
| 1573 | - | result = append(result, w.stashLayout(focusedID, cover)...) | |
| 1662 | + | result = w.appendStash(result, focusedID, cover) | |
| 1574 | 1663 | } else { | |
| 1575 | - | result = append(result, w.stashLayout(focusedID, cover)...) | |
| 1664 | + | result = w.appendStash(result, focusedID, cover) | |
| 1576 | 1665 | appendFloats(coveringFloats) | |
| 1577 | 1666 | } | |
| 1578 | 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 | 24 | shown *Workspace // hidden workspace on screen, nil for Workspaces[Active] | |
| 25 | 25 | back *Workspace // where a named workspace toggle returns to | |
| 26 | 26 | 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 | 31 | nextID *uint64 // shared by the monitors of one Core; owner goroutine only | |
| 28 | 32 | named []NamedWorkspace | |
| 29 | 33 | // followMove shows the target workspace after a move to it. | |
| @@ -34,7 +38,7 @@ type Monitor struct { | |||
| 34 | 38 | // at Active+switchOff while a swipe follows the fingers or its spring | |
| 35 | 39 | // (switchMotion) lands. | |
| 36 | 40 | switchOff float64 | |
| 37 | - | switchMotion *motion | |
| 41 | + | switchMotion motion | |
| 38 | 42 | // switchList is the numbered list a landing slide measures from (the | |
| 39 | 43 | // one its swipe began on); switchOff is then from the current | |
| 40 | 44 | // workspace's place in it. | |
| @@ -157,6 +161,7 @@ func (m *Monitor) Current() *Workspace { | |||
| 157 | 161 | ||
| 158 | 162 | func (m *Monitor) newWorkspace() *Workspace { | |
| 159 | 163 | w := m.template | |
| 164 | + | w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil | |
| 160 | 165 | w.presets = append([]Width(nil), m.template.presets...) | |
| 161 | 166 | w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, "" | |
| 162 | 167 | w.overviewAfter = nil | |
| @@ -408,20 +413,24 @@ func (m *Monitor) Focused() (WindowID, bool) { | |||
| 408 | 413 | } | |
| 409 | 414 | ||
| 410 | 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 | 423 | if m.ov.open { | |
| 413 | - | return m.overviewLayout() | |
| 424 | + | return m.overviewLayout(dst[:0]) | |
| 414 | 425 | } | |
| 415 | - | var result []Placement | |
| 426 | + | result := dst[:0] | |
| 416 | 427 | cur := m.Current() | |
| 417 | 428 | for w := range m.all() { | |
| 418 | 429 | if w == cur { | |
| 419 | - | result = append(result, w.Layout()...) | |
| 430 | + | result = w.appendLayout(result) | |
| 420 | 431 | continue | |
| 421 | 432 | } | |
| 422 | - | for _, id := range w.windows() { | |
| 423 | - | result = append(result, Placement{ID: id, Hidden: true}) | |
| 424 | - | } | |
| 433 | + | result = w.appendHidden(result, true) | |
| 425 | 434 | } | |
| 426 | 435 | return m.slideLayout(result) | |
| 427 | 436 | } | |
M internal/core/motion.go +112 -7
| @@ -70,18 +70,57 @@ type spring struct { | |||
| 70 | 70 | DampingRatio float64 | |
| 71 | 71 | Stiffness float64 | |
| 72 | 72 | 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. | |
| 76 | - | // Both are critically damped: fast, with no overshoot. | |
| 82 | + | // pixelSnap is the Snap of a spring in logical pixels: offsets are rounded | |
| 83 | + | // to whole logical pixels before the renderer scales them (rectMotion.apply, | |
| 84 | + | // Workspace.shiftPixels), so a spring that stays under half a pixel from | |
| 85 | + | // its target draws the settled rect from then on, at any output scale. | |
| 86 | + | const pixelSnap = 0.5 | |
| 87 | + | ||
| 88 | + | // viewSpring moves a scrolled view, or a rect offset, in logical pixels; | |
| 89 | + | // workspaceSpring switches workspaces, in workspaces (the caller sets its | |
| 90 | + | // Snap from the output height). Both are critically damped: fast, with no | |
| 91 | + | // overshoot. | |
| 77 | 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 | 104 | func workspaceSpring(from, velocity float64) spring { | |
| 82 | 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 | 124 | // f32Epsilon compares damping terms: float64's epsilon is too small for | |
| 86 | 125 | // them (libadwaita, niri). | |
| 87 | 126 | const f32Epsilon = 1.1920929e-07 | |
| @@ -150,16 +189,53 @@ func (s spring) duration() time.Duration { | |||
| 150 | 189 | ||
| 151 | 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 | 215 | // 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 | 219 | type motion struct { | |
| 156 | 220 | spring spring | |
| 157 | 221 | start time.Time | |
| 158 | 222 | 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 { | |
| 162 | - | return &motion{spring: s, start: now, end: s.duration()} | |
| 228 | + | // newMotion starts s at now, slowed down slow times (slow <= 0 is 1). The | |
| 229 | + | // spring's Velocity is in units per second of real time. | |
| 230 | + | func newMotion(s spring, now time.Time, slow float64) motion { | |
| 231 | + | if slow <= 0 { | |
| 232 | + | slow = 1 | |
| 233 | + | } | |
| 234 | + | v := s.Velocity | |
| 235 | + | s.Velocity *= slow | |
| 236 | + | // The slowdown is capped by the config, so this cannot overflow. | |
| 237 | + | end := time.Duration(float64(s.duration()) * slow) | |
| 238 | + | return motion{spring: s, start: now, end: end, slow: slow, v: v, on: true} | |
| 163 | 239 | } | |
| 164 | 240 | ||
| 165 | 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 | 246 | t := now.Sub(m.start) | |
| 171 | 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 | 255 | return m.spring.To, true | |
| 173 | 256 | } | |
| 174 | - | return m.spring.valueAt(max(t, 0)), false | |
| 257 | + | m.v = vel / m.slow | |
| 258 | + | return pos, false | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // sampleAt is the position and speed m has at now, in units per second, | |
| 262 | + | // without moving m: m is a copy, so at's bookkeeping (start, v) stays out. | |
| 263 | + | func (m motion) sampleAt(now time.Time) (pos, vel float64) { | |
| 264 | + | pos, _ = m.at(now) | |
| 265 | + | return pos, m.v | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // scale multiplies the motion's distance and speed by k: the spring is | |
| 269 | + | // linear in its start offset and speed, so the progress is unchanged. | |
| 270 | + | func (m *motion) scale(k float64) { | |
| 271 | + | if !m.on { | |
| 272 | + | return | |
| 273 | + | } | |
| 274 | + | m.spring.From *= k | |
| 275 | + | m.spring.Velocity *= k | |
| 276 | + | m.v *= k | |
| 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 | 282 | // rubberBand resists movement past the edges: the further out, the less | |
| 178 | 283 | // it moves, never more than limit. | |
| 179 | 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 | 91 | func TestMotionEndsOnTarget(t *testing.T) { | |
| 92 | 92 | start := time.Unix(10, 0) | |
| 93 | - | m := newMotion(workspaceSpring(0.4, 0), start) | |
| 93 | + | m := newMotion(workspaceSpring(0.4, 0), start, 1) | |
| 94 | 94 | if v, done := m.at(start); done || v != 0.4 { | |
| 95 | 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 | 518 | // overviewLayout places the current workspace and its overview neighbors, | |
| 519 | 519 | // each with its full-width stash pile and covering-float stack. Other rows | |
| 520 | 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 | 522 | cur := m.Current() | |
| 523 | 523 | rows, _ := m.overviewRows() | |
| 524 | - | var result []Placement | |
| 525 | 524 | for w := range m.all() { | |
| 526 | 525 | ry, shown := rows[w] | |
| 527 | 526 | if shown { | |
| @@ -535,11 +534,8 @@ func (m *Monitor) overviewLayout() []Placement { | |||
| 535 | 534 | } | |
| 536 | 535 | } | |
| 537 | 536 | if !shown { | |
| 538 | - | for _, id := range w.windows() { | |
| 539 | - | if w.floatIndex(id) < 0 { | |
| 540 | - | result = append(result, Placement{ID: id, Hidden: true}) | |
| 541 | - | } | |
| 542 | - | } | |
| 537 | + | // Columns and stash only: the floats were listed above. | |
| 538 | + | result = w.appendHidden(result, false) | |
| 543 | 539 | } | |
| 544 | 540 | } | |
| 545 | 541 | return result | |
| @@ -664,7 +660,9 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement { | |||
| 664 | 660 | // overviewZoom is the scale that fits every column of w in the usable | |
| 665 | 661 | // width, within the overview bounds. | |
| 666 | 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 | 666 | if span <= 0 { | |
| 669 | 667 | return overviewMaxZoom | |
| 670 | 668 | } | |
| @@ -705,13 +703,23 @@ func (w *Workspace) rowHeight() int { | |||
| 705 | 703 | // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses | |
| 706 | 704 | // an unscrolled row. span is the row's width; sel is the focused column. | |
| 707 | 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 | 715 | if w.pinned() { | |
| 709 | 716 | c := w.cover() | |
| 710 | 717 | // The row shows the columns a pinned fullscreen window hides, | |
| 711 | 718 | // laid out as without it; its own tile stays marked fullscreen. | |
| 712 | 719 | v := *w | |
| 713 | 720 | 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 | 723 | for i := range tiles { | |
| 716 | 724 | tiles[i].Fullscreen = tiles[i].ID == c | |
| 717 | 725 | } | |
| @@ -721,7 +729,8 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) { | |||
| 721 | 729 | h := max(w.Usable.H-2*g, 0) | |
| 722 | 730 | var rects []Rect | |
| 723 | 731 | if w.Overflow == OverflowFixed { | |
| 724 | - | rects = w.columnRects() | |
| 732 | + | w.colBuf = w.columnRectsInto(w.colBuf, false) | |
| 733 | + | rects = w.colBuf | |
| 725 | 734 | span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile. | |
| 726 | 735 | } | |
| 727 | 736 | 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 | 753 | 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 | 757 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus}) | |
| 748 | 758 | } | |
| 749 | 759 | if w.Overflow != OverflowFixed { | |
| @@ -810,22 +820,26 @@ func overviewOutline(layout []Placement, b int) []ports.Separator { | |||
| 810 | 820 | return nil | |
| 811 | 821 | } | |
| 812 | 822 | ||
| 813 | - | // overviewAt is the preview under the output-local point, if any. Front | |
| 814 | - | // cards take priority over peeks, including peeks appended after a front | |
| 815 | - | // tile in another row's layout. | |
| 816 | - | func (m *Monitor) overviewAt(x, y float64) WindowID { | |
| 817 | - | layout := m.Layout() | |
| 823 | + | // overviewClickFade is the fade from which a card takes no click. | |
| 824 | + | const overviewClickFade = 0.5 | |
| 825 | + | ||
| 826 | + | // overviewIn is the preview under the output-local point in layout, if | |
| 827 | + | // any. Front cards take priority over peeks, including peeks appended after | |
| 828 | + | // a front tile in another row's layout. The click path passes the shown | |
| 829 | + | // layout, so a card is hit where it is drawn while it moves. A card that is | |
| 830 | + | // nearly invisible (still fading in) is not clickable. | |
| 831 | + | func overviewIn(layout []Placement, x, y float64) WindowID { | |
| 818 | 832 | for i := len(layout) - 1; i >= 0; i-- { | |
| 819 | 833 | p := layout[i] | |
| 820 | 834 | 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 | 836 | return p.ID | |
| 823 | 837 | } | |
| 824 | 838 | } | |
| 825 | 839 | for i := len(layout) - 1; i >= 0; i-- { | |
| 826 | 840 | p := layout[i] | |
| 827 | 841 | 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 | 843 | return p.ID | |
| 830 | 844 | } | |
| 831 | 845 | } | |
| @@ -843,11 +857,16 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||
| 843 | 857 | if !sc.mon.ov.open { | |
| 844 | 858 | 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 | 863 | if id == 0 { | |
| 848 | 864 | return false, nil | |
| 849 | 865 | } | |
| 866 | + | now := c.now() | |
| 867 | + | shots := c.snapshot(now) | |
| 850 | 868 | sc.mon.OverviewPick(id) | |
| 869 | + | c.transition(shots, now) | |
| 851 | 870 | c.focusScreen = i | |
| 852 | 871 | c.keyboard.takeBack() | |
| 853 | 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 | 51 | func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) { | |
| 52 | - | r := startMulti(t, nil, left, right) | |
| 53 | - | r.mapWindow(t, 1) | |
| 54 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 55 | - | r.mapWindow(t, 2) | |
| 56 | - | r.mapWindow(t, 3) | |
| 57 | - | set := r.key(t, "o", ports.ModAlt) | |
| 52 | + | both(t, func(t *testing.T, animated bool) { | |
| 53 | + | overviewClickPicksOnPointerOutput(t, startLanding(t, animated, nil, left, right)) | |
| 54 | + | }) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func overviewClickPicksOnPointerOutput(t *testing.T, r *landRig) { | |
| 58 | + | r.mapLanded(t, 1) | |
| 59 | + | r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 60 | + | r.mapLanded(t, 2) | |
| 61 | + | r.mapLanded(t, 3) | |
| 62 | + | // Settled: a card still fading in takes no click. | |
| 63 | + | set := r.keyLanded(t, "o", ports.ModAlt) | |
| 58 | 64 | p, ok := scenePreview(outputScene(t, set, "DP-2"), 2) | |
| 59 | 65 | if !ok { | |
| 60 | 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 | 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 | 97 | r.mapWindow(t, 1) | |
| 87 | 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 | 102 | for _, w := range set[0].Windows { | |
| 90 | 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 | 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 | 304 | if p.Hidden { | |
| 305 | 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 | 308 | if id != 6 { | |
| 309 | 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 | 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 | 66 | if id != 9 { | |
| 67 | 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 | 257 | r := rects[i] | |
| 258 | 258 | r.X -= w.Usable.X | |
| 259 | 259 | 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 | 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 | 288 | r.X = (w.Usable.W - r.W) / 2 | |
| 288 | 289 | r.Y = (w.Usable.H - r.H) / 2 | |
| 289 | 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 | 293 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()}) | |
| 292 | 294 | } | |
| 293 | 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 | 115 | if frontColumn { | |
| 116 | 116 | 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 | 119 | t.Fatalf("overlap hit %d want %d (front %+v back %+v)", got, front.ID, front.Rect, back.Rect) | |
| 120 | 120 | } | |
| 121 | 121 | // The up/right exposed corner belongs to the peeking card. | |
| 122 | 122 | 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 | 124 | t.Fatalf("peek hit %d want %d", got, back.ID) | |
| 125 | 125 | } | |
| 126 | 126 | m.OverviewPick(back.ID) | |
M internal/core/overview_test.go +4 -4
| @@ -116,7 +116,7 @@ func TestOverviewPick(t *testing.T) { | |||
| 116 | 116 | m := overviewMonitor() | |
| 117 | 117 | m.ToggleOverview() | |
| 118 | 118 | 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 | 120 | if id != 4 { | |
| 121 | 121 | t.Fatalf("hit %d", id) | |
| 122 | 122 | } | |
| @@ -173,7 +173,7 @@ func TestOverviewFullscreenFloatPickAndCancel(t *testing.T) { | |||
| 173 | 173 | m := gameMonitor(OverflowFixed) | |
| 174 | 174 | m.ToggleOverview() | |
| 175 | 175 | 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 | 177 | t.Fatalf("hit %d", id) | |
| 178 | 178 | } | |
| 179 | 179 | m.OverviewMove(0, 1) | |
| @@ -412,7 +412,7 @@ func TestOverviewPilePick(t *testing.T) { | |||
| 412 | 412 | m := pileMonitor() | |
| 413 | 413 | m.ToggleOverview() | |
| 414 | 414 | 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 | 416 | if id != 7 { | |
| 417 | 417 | t.Fatalf("hit %d", id) | |
| 418 | 418 | } | |
| @@ -486,7 +486,7 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) { | |||
| 486 | 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 | 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 | 28 | timer := portsmocks.NewMockTimer(t) | |
| 29 | 29 | timer.EXPECT().C().Return(make(chan time.Time)).Maybe() | |
| 30 | 30 | timer.EXPECT().Stop().Return(true).Maybe() | |
| 31 | + | timer.EXPECT().Reset(mock.Anything).Return(false).Maybe() | |
| 31 | 32 | return timer | |
| 32 | 33 | }).Maybe() | |
| 33 | 34 | return clock | |
M internal/core/popup.go +2 -2
| @@ -144,7 +144,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||
| 144 | 144 | return sc, l.Rect, true | |
| 145 | 145 | } | |
| 146 | 146 | for _, sc := range c.screens { | |
| 147 | - | for _, pl := range sc.mon.Layout() { | |
| 147 | + | for _, pl := range sc.shownLayout() { | |
| 148 | 148 | if pl.ID == id { | |
| 149 | 149 | // Hidden or scrolled off: not on screen, like its popups. | |
| 150 | 150 | // A preview takes no input and shows no popups. | |
| @@ -161,7 +161,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||
| 161 | 161 | // peeking reports whether id is a stashed window peeking in on screen. | |
| 162 | 162 | func (c *Core) peeking(id WindowID) bool { | |
| 163 | 163 | for _, sc := range c.screens { | |
| 164 | - | for _, pl := range sc.mon.Layout() { | |
| 164 | + | for _, pl := range sc.shownLayout() { | |
| 165 | 165 | if pl.ID == id { | |
| 166 | 166 | 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 | 32 | // are the previous pulse, for the cooldown. drawable is whether the last | |
| 33 | 33 | // scene could show a pulse on target. | |
| 34 | 34 | type focusPulse struct { | |
| 35 | - | target WindowID | |
| 36 | - | since time.Time | |
| 37 | - | id WindowID | |
| 38 | - | start time.Time | |
| 39 | - | last WindowID | |
| 40 | - | lastAt time.Time | |
| 41 | - | drawable bool | |
| 35 | + | target WindowID | |
| 36 | + | since time.Time | |
| 37 | + | id WindowID | |
| 38 | + | start time.Time | |
| 39 | + | last WindowID | |
| 40 | + | lastAt time.Time | |
| 41 | + | drawable bool | |
| 42 | + | // value is the effect of the last sample, kept for scenes published | |
| 43 | + | // between the focused output's own flips. | |
| 44 | + | value float64 | |
| 42 | 45 | timerC <-chan time.Time | |
| 43 | 46 | 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 | 53 | // pulseFocus follows the window focus. held is false while something else | |
| 49 | 54 | // (a popup grab, a layer, a lock surface) has the keyboard: the running pulse | |
| @@ -99,7 +104,7 @@ func (c *Core) pulseTick() bool { | |||
| 99 | 104 | // A swipe or its landing slide still shows the neighbours: decide on | |
| 100 | 105 | // the settled layout, or a window left alone would start a pulse cut | |
| 101 | 106 | // short. | |
| 102 | - | if !c.cur().mon.settled() { | |
| 107 | + | if !c.cur().settled() { | |
| 103 | 108 | p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseRecheck) | |
| 104 | 109 | return false | |
| 105 | 110 | } | |
| @@ -132,13 +137,14 @@ func (c *Core) advancePulse(now time.Time) float64 { | |||
| 132 | 137 | 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 | 142 | func (m *Monitor) settled() bool { | |
| 137 | - | if m.switchMotion != nil || m.switchOff != 0 { | |
| 143 | + | if m.switchMotion.on || m.switchOff != 0 { | |
| 138 | 144 | return false | |
| 139 | 145 | } | |
| 140 | 146 | for w := range m.all() { | |
| 141 | - | if w.motion != nil || w.shift != 0 { | |
| 147 | + | if w.motion.on || w.shift != 0 { | |
| 142 | 148 | return false | |
| 143 | 149 | } | |
| 144 | 150 | } | |
M internal/core/pulse_test.go +49 -7
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | func pulseCore(t *testing.T) (*Core, *indicatorClock) { | |
| 13 | 13 | t.Helper() | |
| 14 | 14 | c, ic := indicatorCore(t) | |
| 15 | + | c.cfg.Animations.On = true | |
| 15 | 16 | c.cfg.Focus.Animation = ports.FocusAnimationPulse | |
| 16 | 17 | c.cfg.Focus.Strength = 0.05 | |
| 17 | 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 waits | |
| 188 | - | // for the layout to settle, then decides on it. | |
| 188 | + | // A swipe, its landing slide or a transition still shows the neighbours: the | |
| 189 | + | // pulse waits for the layout to settle, then decides on it. | |
| 189 | 190 | func TestFocusPulseWaitsForSlide(t *testing.T) { | |
| 190 | 191 | for _, tc := range []struct { | |
| 191 | 192 | name string | |
| 192 | - | slide func(*Monitor, time.Time) | |
| 193 | + | slide func(*testing.T, *Core, time.Time) | |
| 193 | 194 | 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 drag, alone", func(m *Monitor, _ time.Time) { m.switchOff = 0.3 }, true}, | |
| 197 | - | {"column drag, beside another", func(m *Monitor, _ time.Time) { m.Current().shift = 40 }, false}, | |
| 196 | + | {"workspace landing, alone", func(_ *testing.T, c *Core, now time.Time) { | |
| 197 | + | c.cur().mon.switchMotion = newMotion(workspaceSpring(0.5, 0), now, 1) | |
| 198 | + | }, true}, | |
| 199 | + | {"workspace drag, alone", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.switchOff = 0.3 }, true}, | |
| 200 | + | {"column drag, beside another", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.Current().shift = 40 }, false}, | |
| 201 | + | {"rect motion, beside another", func(_ *testing.T, c *Core, now time.Time) { | |
| 202 | + | sc := c.cur() | |
| 203 | + | sc.rects = map[WindowID]rectMotion{1: {x: c.spring(viewSpring(40, 0), now), dx: 40}} | |
| 204 | + | sc.rectsWS = sc.mon.Current() | |
| 205 | + | }, false}, | |
| 206 | + | {"camera transition of a key, beside another", func(t *testing.T, c *Core, now time.Time) { | |
| 207 | + | m := c.cur().mon | |
| 208 | + | for id := WindowID(3); id <= 4; id++ { | |
| 209 | + | m.AddWindow(id) | |
| 210 | + | } | |
| 211 | + | indicatorScene(t, c) | |
| 212 | + | before := c.snapshot(now) | |
| 213 | + | for range 3 { | |
| 214 | + | c.applyAction(ActionFocusColumnLeft) | |
| 215 | + | } | |
| 216 | + | c.transition(before, now) | |
| 217 | + | if !m.Current().motion.on || m.settled() { | |
| 218 | + | t.Fatal("no camera transition after the focus moved the view") | |
| 219 | + | } | |
| 220 | + | }, false}, | |
| 198 | 221 | } { | |
| 199 | 222 | t.Run(tc.name, func(t *testing.T) { | |
| 200 | 223 | c, ic := pulseCore(t) | |
| 201 | 224 | m := c.cur().mon | |
| 202 | 225 | m.Current().FocusID(1) | |
| 203 | 226 | indicatorScene(t, c) | |
| 204 | - | tc.slide(m, ic.now) | |
| 227 | + | tc.slide(t, c, ic.now) | |
| 228 | + | indicatorScene(t, c) | |
| 205 | 229 | ic.now = ic.now.Add(pulseSettle) | |
| 206 | 230 | if c.pulseTick() || c.pulse.timerC == nil { | |
| 207 | 231 | t.Fatal("pulse decided during a slide") | |
| 208 | 232 | } | |
| 209 | 233 | m.stopSwitch() | |
| 210 | 234 | m.Current().stopSlide() | |
| 235 | + | c.cur().stopRects() | |
| 211 | 236 | if tc.alone { | |
| 212 | 237 | m.Current().ToggleFullWidth() | |
| 213 | 238 | } | |
| @@ -259,3 +284,20 @@ func TestFocusPulseOff(t *testing.T) { | |||
| 259 | 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 | 28 | // CaptureIndicators): immutable, shared by the next one while unchanged. | |
| 29 | 29 | capScene *ports.SceneCapture | |
| 30 | 30 | 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 | 84 | func (s *screen) name() string { return s.info.Name } | |
| @@ -293,6 +344,10 @@ func (c *Core) removeScreen(name string) { | |||
| 293 | 344 | if len(c.screens) == 1 { | |
| 294 | 345 | c.screens[0].info = ports.OutputInfo{} | |
| 295 | 346 | 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 | 351 | return | |
| 297 | 352 | } | |
| 298 | 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 | 129 | func TestLayoutPerOutput(t *testing.T) { | |
| 130 | - | r := startMulti(t, func(c *ports.Config) { | |
| 131 | - | c.Layout.MaxColumns = 2 | |
| 132 | - | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", MaxColumns: 4, Overflow: "fixed"}} | |
| 133 | - | }, left, right) | |
| 134 | - | r.mapWindow(t, 1) | |
| 135 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 130 | + | both(t, func(t *testing.T, animated bool) { | |
| 131 | + | r := startLanding(t, animated, func(c *ports.Config) { | |
| 132 | + | c.Layout.MaxColumns = 2 | |
| 133 | + | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", MaxColumns: 4, Overflow: "fixed"}} | |
| 134 | + | }, left, right) | |
| 135 | + | layoutPerOutput(t, r) | |
| 136 | + | }) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func layoutPerOutput(t *testing.T, r *landRig) { | |
| 140 | + | r.mapLanded(t, 1) | |
| 141 | + | r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 136 | 142 | var set []ports.Scene | |
| 137 | 143 | for id := ports.WindowID(2); id <= 6; id++ { | |
| 138 | - | set = r.mapWindow(t, id) | |
| 144 | + | set = r.mapLanded(t, id) | |
| 139 | 145 | } | |
| 140 | 146 | // DP-2: four columns of 100; the fifth window splits the last one | |
| 141 | 147 | // (fixed) instead of scrolling. | |
| @@ -166,26 +172,32 @@ func widths(set []ports.Scene, output string) []int { | |||
| 166 | 172 | } | |
| 167 | 173 | ||
| 168 | 174 | func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) { | |
| 169 | - | r := startMulti(t, func(c *ports.Config) { | |
| 170 | - | c.Layout.MaxColumns = 1 | |
| 171 | - | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", LayoutRules: ports.LayoutRules{MaxColumns: 2}}} | |
| 172 | - | }, right) | |
| 173 | - | r.mapWindow(t, 1) | |
| 174 | - | if got := widths(r.mapWindow(t, 2), "DP-2"); !slices.Equal(got, []int{200, 200}) { | |
| 175 | + | both(t, func(t *testing.T, animated bool) { | |
| 176 | + | r := startLanding(t, animated, func(c *ports.Config) { | |
| 177 | + | c.Layout.MaxColumns = 1 | |
| 178 | + | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", LayoutRules: ports.LayoutRules{MaxColumns: 2}}} | |
| 179 | + | }, right) | |
| 180 | + | layoutPerOutputFollowsMonitorAndReload(t, r) | |
| 181 | + | }) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func layoutPerOutputFollowsMonitorAndReload(t *testing.T, r *landRig) { | |
| 185 | + | r.mapLanded(t, 1) | |
| 186 | + | if got := widths(r.mapLanded(t, 2), "DP-2"); !slices.Equal(got, []int{200, 200}) { | |
| 175 | 187 | t.Fatal(got) | |
| 176 | 188 | } | |
| 177 | 189 | // Another monitor on DP-2: the key rule no longer applies. | |
| 178 | 190 | other := right | |
| 179 | 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 | 193 | t.Fatal(got) | |
| 182 | 194 | } | |
| 183 | 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 | 197 | cfg := r.cfg | |
| 186 | 198 | cfg.Layout.Outputs = nil | |
| 187 | 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 | 201 | t.Fatal(got) | |
| 190 | 202 | } | |
| 191 | 203 | } | |
| @@ -301,10 +313,15 @@ func TestFocusColumnCrossesOutputs(t *testing.T) { | |||
| 301 | 313 | } | |
| 302 | 314 | ||
| 303 | 315 | func TestPointerCrossesOutputs(t *testing.T) { | |
| 304 | - | r := startMulti(t, nil, left, right) | |
| 305 | - | r.mapWindow(t, 1) | |
| 306 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 307 | - | r.mapWindow(t, 2) | |
| 316 | + | both(t, func(t *testing.T, animated bool) { | |
| 317 | + | pointerCrossesOutputs(t, startLanding(t, animated, nil, left, right)) | |
| 318 | + | }) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func pointerCrossesOutputs(t *testing.T, r *landRig) { | |
| 322 | + | r.mapLanded(t, 1) | |
| 323 | + | r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 324 | + | r.mapLanded(t, 2) | |
| 308 | 325 | for len(r.commands) > 0 { | |
| 309 | 326 | <-r.commands | |
| 310 | 327 | } | |
| @@ -400,33 +417,35 @@ func TestMonitorActionsFollowGeometry(t *testing.T) { | |||
| 400 | 417 | } | |
| 401 | 418 | ||
| 402 | 419 | func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) { | |
| 403 | - | r := startMulti(t, nil, left, right) | |
| 404 | - | r.mapWindow(t, 1) | |
| 405 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 406 | - | r.mapWindow(t, 2) | |
| 407 | - | // Unplug DP-2 while it is focused: its workspace joins DP-1 below its own. | |
| 408 | - | r.output <- ports.OutputRemoved{Name: "DP-2"} | |
| 409 | - | set := receive(t, r.scenes) | |
| 410 | - | if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 { | |
| 411 | - | t.Fatal(shown(set)) | |
| 412 | - | } | |
| 413 | - | // Cmd+2 shows the guest. | |
| 414 | - | r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true} | |
| 415 | - | set = receive(t, r.scenes) | |
| 416 | - | r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt} | |
| 417 | - | if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 { | |
| 418 | - | t.Fatal(got) | |
| 419 | - | } | |
| 420 | - | // Replugged while on screen: the guest stays until the user leaves it. | |
| 421 | - | set = r.plug(t, right) | |
| 422 | - | if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 { | |
| 423 | - | t.Fatal(got) | |
| 424 | - | } | |
| 425 | - | r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true} | |
| 426 | - | set = receive(t, r.scenes) | |
| 427 | - | if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 { | |
| 428 | - | t.Fatal(got) | |
| 429 | - | } | |
| 420 | + | both(t, func(t *testing.T, animated bool) { | |
| 421 | + | r := startLanding(t, animated, nil, left, right) | |
| 422 | + | r.mapLanded(t, 1) | |
| 423 | + | r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 424 | + | r.mapLanded(t, 2) | |
| 425 | + | // Unplug DP-2 while it is focused: its workspace joins DP-1 below its own. | |
| 426 | + | r.output <- ports.OutputRemoved{Name: "DP-2"} | |
| 427 | + | set := r.land(receive(t, r.scenes)) | |
| 428 | + | if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 { | |
| 429 | + | t.Fatal(shown(set)) | |
| 430 | + | } | |
| 431 | + | // Cmd+2 shows the guest. | |
| 432 | + | r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true} | |
| 433 | + | set = r.land(receive(t, r.scenes)) | |
| 434 | + | r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt} | |
| 435 | + | if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 { | |
| 436 | + | t.Fatal(got) | |
| 437 | + | } | |
| 438 | + | // Replugged while on screen: the guest stays until the user leaves it. | |
| 439 | + | set = r.land(r.plug(t, right)) | |
| 440 | + | if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 { | |
| 441 | + | t.Fatal(got) | |
| 442 | + | } | |
| 443 | + | r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true} | |
| 444 | + | set = r.land(receive(t, r.scenes)) | |
| 445 | + | if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 { | |
| 446 | + | t.Fatal(got) | |
| 447 | + | } | |
| 448 | + | }) | |
| 430 | 449 | } | |
| 431 | 450 | ||
| 432 | 451 | func TestReplugReturnsHiddenGuestAtOnce(t *testing.T) { | |
| @@ -763,38 +782,40 @@ func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) { | |||
| 763 | 782 | } | |
| 764 | 783 | ||
| 765 | 784 | func TestStateSnapshot(t *testing.T) { | |
| 766 | - | r := startMulti(t, nil, left, right) | |
| 767 | - | r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100} | |
| 768 | - | receive(t, r.scenes) | |
| 769 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 770 | - | <-r.state // the channel holds the latest snapshot only | |
| 771 | - | r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200} | |
| 772 | - | receive(t, r.scenes) | |
| 773 | - | // Snapshots from before window 2 may still be queued: wait for it. | |
| 774 | - | st := receive(t, r.state) | |
| 775 | - | for len(st.Windows) < 2 { | |
| 785 | + | both(t, func(t *testing.T, animated bool) { | |
| 786 | + | r := startLanding(t, animated, nil, left, right) | |
| 787 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100} | |
| 788 | + | r.land(receive(t, r.scenes)) | |
| 789 | + | r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 790 | + | <-r.state // the channel holds the latest snapshot only | |
| 791 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200} | |
| 792 | + | r.land(receive(t, r.scenes)) | |
| 793 | + | // Snapshots from before window 2 may still be queued: wait for it. | |
| 794 | + | st := receive(t, r.state) | |
| 795 | + | for len(st.Windows) < 2 { | |
| 796 | + | st = receive(t, r.state) | |
| 797 | + | } | |
| 798 | + | want := ports.State{ | |
| 799 | + | Output: "DP-2", | |
| 800 | + | Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}}, | |
| 801 | + | Windows: []ports.WindowState{ | |
| 802 | + | {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true}, | |
| 803 | + | {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true}, | |
| 804 | + | }, | |
| 805 | + | } | |
| 806 | + | want.Window = &want.Windows[1] | |
| 807 | + | if !reflect.DeepEqual(st, want) { | |
| 808 | + | t.Fatalf("got %+v\nwant %+v", st, want) | |
| 809 | + | } | |
| 810 | + | // Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen. | |
| 811 | + | r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true} | |
| 812 | + | r.land(receive(t, r.scenes)) | |
| 776 | 813 | st = receive(t, r.state) | |
| 777 | - | } | |
| 778 | - | want := ports.State{ | |
| 779 | - | Output: "DP-2", | |
| 780 | - | Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}}, | |
| 781 | - | Windows: []ports.WindowState{ | |
| 782 | - | {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true}, | |
| 783 | - | {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true}, | |
| 784 | - | }, | |
| 785 | - | } | |
| 786 | - | want.Window = &want.Windows[1] | |
| 787 | - | if !reflect.DeepEqual(st, want) { | |
| 788 | - | t.Fatalf("got %+v\nwant %+v", st, want) | |
| 789 | - | } | |
| 790 | - | // Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen. | |
| 791 | - | r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true} | |
| 792 | - | receive(t, r.scenes) | |
| 793 | - | st = receive(t, r.state) | |
| 794 | - | // The window keeps its workspace ID; the output now shows the new one. | |
| 795 | - | if o := st.Outputs[1]; o.Name != "DP-2" || o.Active != 2 || o.Count != 2 || o.WorkspaceID == 0 || o.WorkspaceID == 2 || st.Windows[1].WorkspaceID != 2 || st.Windows[1].Visible || st.Window != nil { | |
| 796 | - | t.Fatalf("%+v", st) | |
| 797 | - | } | |
| 814 | + | // The window keeps its workspace ID; the output now shows the new one. | |
| 815 | + | if o := st.Outputs[1]; o.Name != "DP-2" || o.Active != 2 || o.Count != 2 || o.WorkspaceID == 0 || o.WorkspaceID == 2 || st.Windows[1].WorkspaceID != 2 || st.Windows[1].Visible || st.Window != nil { | |
| 816 | + | t.Fatalf("%+v", st) | |
| 817 | + | } | |
| 818 | + | }) | |
| 798 | 819 | } | |
| 799 | 820 | ||
| 800 | 821 | func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) { | |
| @@ -1003,18 +1024,24 @@ func TestFocusColumnFromFloatStaysOnMonitor(t *testing.T) { | |||
| 1003 | 1024 | // and their popups wait, a click selects a peek, and the script state | |
| 1004 | 1025 | // tells the stash and whether it is hidden. | |
| 1005 | 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 | 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 stashed | |
| 1011 | - | r.key(t, "Left", ports.ModAlt) // stays on 3: its stash's only window | |
| 1012 | - | sc := r.key(t, "s", ports.ModAlt) | |
| 1037 | + | r.keyLanded(t, "s", ports.ModAlt|ports.ModShift) // 3 stashed | |
| 1038 | + | r.keyLanded(t, "Left", ports.ModAlt) // stays on 3: its stash's only window | |
| 1039 | + | sc := r.keyLanded(t, "s", ports.ModAlt) | |
| 1013 | 1040 | if got := shown(sc)["DP-1"]; !slices.Equal(got, []ports.WindowID{1, 2}) { | |
| 1014 | 1041 | t.Fatalf("hidden stash: %v", got) | |
| 1015 | 1042 | } | |
| 1016 | - | r.key(t, "Left", ports.ModAlt) // tiles: from 2 to 1 | |
| 1017 | - | sc = r.key(t, "s", ports.ModAlt|ports.ModShift) | |
| 1043 | + | r.keyLanded(t, "Left", ports.ModAlt) // tiles: from 2 to 1 | |
| 1044 | + | sc = r.keyLanded(t, "s", ports.ModAlt|ports.ModShift) | |
| 1018 | 1045 | // Stash 3 1: 1 selected in the middle, 3 peeking left, dimmed. | |
| 1019 | 1046 | var peek, sel ports.SceneWindow | |
| 1020 | 1047 | for _, w := range sc[0].Windows { | |
| @@ -1040,7 +1067,7 @@ func TestStashEndToEnd(t *testing.T) { | |||
| 1040 | 1067 | // A click on the peek (x 0..19 of 200) selects it. | |
| 1041 | 1068 | r.input <- ports.PointerMotion{X: 5, Y: 50} | |
| 1042 | 1069 | r.input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 1043 | - | receive(t, r.scenes) | |
| 1070 | + | r.land(receive(t, r.scenes)) | |
| 1044 | 1071 | st := receive(t, r.state) | |
| 1045 | 1072 | for st.Window == nil || st.Window.ID != 3 { | |
| 1046 | 1073 | st = receive(t, r.state) | |
| @@ -1056,7 +1083,7 @@ func TestStashEndToEnd(t *testing.T) { | |||
| 1056 | 1083 | t.Fatalf("tile %+v", w) | |
| 1057 | 1084 | } | |
| 1058 | 1085 | r.input <- ports.PointerButton{Button: 0x110} | |
| 1059 | - | r.key(t, "s", ports.ModAlt) | |
| 1086 | + | r.keyLanded(t, "s", ports.ModAlt) | |
| 1060 | 1087 | st = receive(t, r.state) | |
| 1061 | 1088 | for !st.Windows[0].Hidden { | |
| 1062 | 1089 | st = receive(t, r.state) | |
| @@ -1182,11 +1209,16 @@ func TestDialogFocusStaysOnMonitor(t *testing.T) { | |||
| 1182 | 1209 | // A click on a tile behind the shown stash hides the stash and focuses | |
| 1183 | 1210 | // the tile. | |
| 1184 | 1211 | func TestStashHidesOnBackgroundClick(t *testing.T) { | |
| 1185 | - | // Gap 10 at 80%: no peeks, the margins show the tile behind. | |
| 1186 | - | r := startMulti(t, func(c *ports.Config) { c.Stash.Gap = 10 }, left) | |
| 1187 | - | r.mapWindow(t, 1) | |
| 1188 | - | r.mapWindow(t, 2) | |
| 1189 | - | r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed and focused, over tile 1 | |
| 1212 | + | both(t, func(t *testing.T, animated bool) { | |
| 1213 | + | // Gap 10 at 80%: no peeks, the margins show the tile behind. | |
| 1214 | + | stashHidesOnBackgroundClick(t, startLanding(t, animated, func(c *ports.Config) { c.Stash.Gap = 10 }, left)) | |
| 1215 | + | }) | |
| 1216 | + | } | |
| 1217 | + | ||
| 1218 | + | func stashHidesOnBackgroundClick(t *testing.T, r *landRig) { | |
| 1219 | + | r.mapLanded(t, 1) | |
| 1220 | + | r.mapLanded(t, 2) | |
| 1221 | + | r.keyLanded(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed and focused, over tile 1 | |
| 1190 | 1222 | r.input <- ports.PointerMotion{X: 5, Y: 50} | |
| 1191 | 1223 | r.input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 1192 | 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 | 1542 | // A reload that shrinks the layout under a still pointer brings it back onto | |
| 1511 | 1543 | // an output. | |
| 1512 | 1544 | func TestTransformReloadClampsPointer(t *testing.T) { | |
| 1513 | - | r := startMulti(t, func(c *ports.Config) { | |
| 1514 | - | c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}} | |
| 1515 | - | }, left, right) | |
| 1516 | - | r.mapWindow(t, 1) | |
| 1517 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 1518 | - | r.mapWindow(t, 2) | |
| 1545 | + | both(t, func(t *testing.T, animated bool) { | |
| 1546 | + | transformReloadClampsPointer(t, startLanding(t, animated, func(c *ports.Config) { | |
| 1547 | + | c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}} | |
| 1548 | + | }, left, right)) | |
| 1549 | + | }) | |
| 1550 | + | } | |
| 1551 | + | ||
| 1552 | + | func transformReloadClampsPointer(t *testing.T, r *landRig) { | |
| 1553 | + | r.mapLanded(t, 1) | |
| 1554 | + | r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 1555 | + | r.mapLanded(t, 2) | |
| 1519 | 1556 | // DP-2 spans x 200..600: (550, 50) is (350, 50) on window 2. | |
| 1520 | 1557 | for len(r.commands) > 0 { | |
| 1521 | 1558 | <-r.commands | |
M internal/core/security.go +4 -7
| @@ -33,13 +33,7 @@ func (c *Core) syncSecurity() bool { | |||
| 33 | 33 | } | |
| 34 | 34 | c.dropSwipe() | |
| 35 | 35 | c.swipe = nil | |
| 36 | - | for _, sc := range c.screens { | |
| 37 | - | sc.mon.stopSwitch() | |
| 38 | - | for w := range sc.mon.all() { | |
| 39 | - | w.stopSlide() | |
| 40 | - | } | |
| 41 | - | } | |
| 42 | - | c.stopFrame() | |
| 36 | + | c.stopAnimations() | |
| 43 | 37 | c.stopPulse() | |
| 44 | 38 | if state.Protected { | |
| 45 | 39 | c.dropCaptureSessions() | |
| @@ -185,6 +179,9 @@ func (c *Core) publishProtected(ctx context.Context) error { | |||
| 185 | 179 | scenes := make([]ports.Scene, 0, len(c.screens)) | |
| 186 | 180 | for _, sc := range c.screens { | |
| 187 | 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 | 185 | o := sc.mon.Output() | |
| 189 | 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 | 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 | 38 | if p.Floating { | |
| 39 | 39 | 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 | 46 | out = append(out, ports.Separator{Rect: r, Active: active, Window: id}) | |
| 42 | 47 | } | |
| 43 | 48 | var focused *Placement | |
| 44 | 49 | tiles := 0 | |
| 45 | 50 | for i := range ps { | |
| 46 | 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 | 55 | continue | |
| 49 | 56 | } | |
| 50 | 57 | if !p.Floating { | |
M internal/core/slide.go +22 -6
| @@ -17,21 +17,32 @@ func (w *Workspace) slidable() bool { | |||
| 17 | 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 | 22 | // retarget keeps the view where it is on screen when ViewX moved from | |
| 23 | 23 | // before during a landing slide: the slide heads for the new view. | |
| 24 | 24 | // Without a landing slide the view moves at once, as it always has. | |
| 25 | 25 | func (w *Workspace) retarget(before int) { | |
| 26 | - | if w.ViewX == before || w.motion == nil { | |
| 26 | + | if w.ViewX == before || !w.motion.on { | |
| 27 | 27 | return | |
| 28 | 28 | } | |
| 29 | 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 | 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 | 46 | // swipeScale turns touchpad distance into pixels: viewSwipeMovement | |
| 36 | 47 | // scrolls one usable width. | |
| 37 | 48 | func (w *Workspace) swipeScale() float64 { | |
| @@ -105,7 +116,7 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) { | |||
| 105 | 116 | 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 | 121 | // framedSwitch keeps transitions involving a sized workspace settled. A | |
| 111 | 122 | // monitor-wide animation has no per-workspace crop; it must not expose | |
| @@ -163,12 +174,17 @@ func (m *Monitor) slideLayout(cur []Placement) []Placement { | |||
| 163 | 174 | if k == base || k < 0 || k >= len(list) || !m.has(list[k]) { | |
| 164 | 175 | continue | |
| 165 | 176 | } | |
| 166 | - | next := list[k].Layout() | |
| 177 | + | next := list[k].layoutInto(m.slideBuf) | |
| 178 | + | m.slideBuf = next | |
| 167 | 179 | for i := range next { | |
| 168 | 180 | next[i].Focused = false | |
| 169 | 181 | } | |
| 170 | 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 | 188 | for _, p := range offset(next, k) { | |
| 173 | 189 | shown[p.ID] = p | |
| 174 | 190 | } | |
M internal/core/slots_test.go +53 -5
| @@ -21,14 +21,30 @@ type slotRig struct { | |||
| 21 | 21 | workspaces chan ports.Workspaces | |
| 22 | 22 | errs chan error | |
| 23 | 23 | 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 | 31 | // startSlots runs core with a hidden "dev" workspace of two slots, toggled | |
| 27 | 32 | // by Alt+d, on a 100x80 output. One flag makes dev numbered, two also set | |
| 28 | 33 | // fixed overflow. | |
| 29 | 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 | 43 | t.Helper() | |
| 31 | 44 | cfg := altCmdDefaults() | |
| 45 | + | if animated != nil { | |
| 46 | + | cfg.Animations.On = *animated | |
| 47 | + | } | |
| 32 | 48 | cfg.Border.Width = 0 | |
| 33 | 49 | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{ | |
| 34 | 50 | {Index: 1, Width: "70%", Argv: []string{"code"}}, | |
| @@ -45,7 +61,17 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig { | |||
| 45 | 61 | } | |
| 46 | 62 | output := make(chan ports.OutputEvent, 1) | |
| 47 | 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 | 75 | if err != nil { | |
| 50 | 76 | t.Fatal(err) | |
| 51 | 77 | } | |
| @@ -62,6 +88,22 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig { | |||
| 62 | 88 | 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 | 107 | // token returns the SlotEnv value of a spawn request. | |
| 66 | 108 | func token(t *testing.T, req ports.SpawnRequest) string { | |
| 67 | 109 | t.Helper() | |
| @@ -194,7 +236,10 @@ func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) { | |||
| 194 | 236 | } | |
| 195 | 237 | ||
| 196 | 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 | 243 | // Wait for both windows: a one-window scene from before the unmap | |
| 199 | 244 | // would let the key below race it. | |
| 200 | 245 | _, foot := r.fill(t) | |
| @@ -209,7 +254,7 @@ func TestSlotRefilledOnlyWhenShown(t *testing.T) { | |||
| 209 | 254 | } | |
| 210 | 255 | r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)} | |
| 211 | 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 | 258 | t.Fatal(got) | |
| 214 | 259 | } | |
| 215 | 260 | // Leaving and showing dev again with full slots spawns nothing. | |
| @@ -344,7 +389,10 @@ func TestSlotOldTokenIsNormal(t *testing.T) { | |||
| 344 | 389 | // A slot whose command never maps a window gets one show to do it; on the | |
| 345 | 390 | // next show it is respawned and its old token no longer fills it. | |
| 346 | 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 | 396 | code, foot := receive(t, r.spawn), receive(t, r.spawn) | |
| 349 | 397 | r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)} | |
| 350 | 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 | 412 | r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)} | |
| 365 | 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 | 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 | 191 | 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 | 200 | // dropStashOver ends the stash override when the focus leaves it: the | |
| 195 | 201 | // stash hides again under the covering window, as it was. | |
| 196 | 202 | func (w *Workspace) dropStashOver() { | |
| @@ -217,18 +223,17 @@ func (w *Workspace) stashRect() Rect { | |||
| 217 | 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, its | |
| 226 | + | // appendStash appends the placements of the stash to out: the selected window centred, its | |
| 221 | 227 | // neighbors gap% of the usable width away, showing what the margins | |
| 222 | 228 | // leave of them, the others hidden. cover is the covering fullscreen | |
| 223 | 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 | 231 | u := w.Usable | |
| 226 | 232 | center := w.stashRect() | |
| 227 | 233 | fw, fh := center.W, center.H | |
| 228 | 234 | // Peeks stay in the margins: they never overlap the selected window. | |
| 229 | 235 | gap := u.W * w.stashGap / 100 | |
| 230 | 236 | peek := max((u.W-fw)/2-gap, 0) | |
| 231 | - | out := make([]Placement, 0, len(w.Stash)) | |
| 232 | 237 | for i, f := range w.Stash { | |
| 233 | 238 | p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll} | |
| 234 | 239 | switch { | |
M internal/core/state.go +4 -1
| @@ -38,7 +38,10 @@ func (c *Core) state() ports.State { | |||
| 38 | 38 | st.Outputs = append(st.Outputs, o) | |
| 39 | 39 | // Visible follows the configures: drawn on the output. | |
| 40 | 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 | 45 | shown[p.ID] = onScreen(p, m.Frame()) | |
| 43 | 46 | } | |
| 44 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "sync" | |
| 5 | + | "slices" | |
| 6 | 6 | "testing" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/neferwl/internal/core" | |
| 10 | - | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 11 | 10 | "github.com/bnema/neferwl/internal/ports" | |
| 12 | - | "github.com/stretchr/testify/mock" | |
| 13 | 11 | ) | |
| 14 | 12 | ||
| 15 | 13 | // swipeRig runs core on one 800x600 output with no gaps or border, a | |
| @@ -17,43 +15,40 @@ import ( | |||
| 17 | 15 | type swipeRig struct { | |
| 18 | 16 | *multiRig | |
| 19 | 17 | frames chan ports.OutputFrame | |
| 20 | - | mu sync.Mutex | |
| 21 | - | now time.Time | |
| 18 | + | clk *stepClock | |
| 22 | 19 | // at is the device time of the next swipe event. | |
| 23 | 20 | at time.Duration | |
| 21 | + | // outs are the plugged outputs, for landed. | |
| 22 | + | outs []string | |
| 24 | 23 | } | |
| 25 | 24 | ||
| 26 | 25 | var wide = ports.OutputInfo{Name: "DP-1", Width: 800, Height: 600, RefreshMilli: 60000} | |
| 27 | 26 | ||
| 28 | 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 | 34 | t.Helper() | |
| 30 | 35 | cfg := altCmdDefaults() | |
| 31 | 36 | cfg.Border.Width = 0 | |
| 32 | 37 | cfg.Layout.Gaps = 0 | |
| 33 | 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 | 42 | if edit != nil { | |
| 35 | 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 | 46 | r.multiRig = &multiRig{ | |
| 39 | 47 | client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 64), | |
| 40 | 48 | output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4), | |
| 41 | 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) | |
| 44 | - | clock.EXPECT().Now().RunAndReturn(func() time.Time { | |
| 45 | - | r.mu.Lock() | |
| 46 | - | defer r.mu.Unlock() | |
| 47 | - | return r.now | |
| 48 | - | }).Maybe() | |
| 49 | - | // The fallback timer never fires: frames come from the test. | |
| 50 | - | clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer { | |
| 51 | - | timer := portsmocks.NewMockTimer(t) | |
| 52 | - | timer.EXPECT().C().Return(make(chan time.Time)).Maybe() | |
| 53 | - | timer.EXPECT().Stop().Return(true).Maybe() | |
| 54 | - | return timer | |
| 55 | - | }).Maybe() | |
| 56 | - | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Clock: clock, Frames: r.frames}) | |
| 51 | + | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Clock: r.clk.clock, Frames: r.frames}) | |
| 57 | 52 | if err != nil { | |
| 58 | 53 | t.Fatal(err) | |
| 59 | 54 | } | |
| @@ -61,14 +56,28 @@ func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig { | |||
| 61 | 56 | done := make(chan error, 1) | |
| 62 | 57 | go func() { done <- c.Run(ctx) }() | |
| 63 | 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 | 63 | return r | |
| 66 | 64 | } | |
| 67 | 65 | ||
| 68 | 66 | func (r *swipeRig) advance(d time.Duration) { | |
| 69 | - | r.mu.Lock() | |
| 70 | - | r.now = r.now.Add(d) | |
| 71 | - | r.mu.Unlock() | |
| 67 | + | r.clk.advance(d) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // mapWindow maps a window and, with animations on, lands its entrance (and | |
| 71 | + | // the neighbours' re-flow) so the scene is the settled one: the tests of a | |
| 72 | + | // swipe rig look at what the map leaves. The entrance itself is checked on | |
| 73 | + | // the raw multiRig.mapWindow (TestMap*). | |
| 74 | + | func (r *swipeRig) mapWindow(t *testing.T, id ports.WindowID) []ports.Scene { | |
| 75 | + | t.Helper() | |
| 76 | + | set := r.multiRig.mapWindow(t, id) | |
| 77 | + | if landed, ok := r.settleAll(t, r.outs...); ok { | |
| 78 | + | return landed | |
| 79 | + | } | |
| 80 | + | return set | |
| 72 | 81 | } | |
| 73 | 82 | ||
| 74 | 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 | 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 | 124 | // flick sends a quick swipe of n updates of (dx, dy) and lifts the | |
| 109 | 125 | // fingers; it returns the scene right after the lift. | |
| 110 | 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 | 137 | func (r *swipeRig) workspaces(t *testing.T) { | |
| 122 | 138 | t.Helper() | |
| 123 | 139 | r.mapWindow(t, 1) | |
| 124 | - | r.key(t, "Next", ports.ModAlt) | |
| 140 | + | r.keySettled(t, "Next") | |
| 125 | 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 | 155 | func rectOf(s ports.Scene, id ports.WindowID) (ports.Rect, bool) { | |
| @@ -324,8 +350,8 @@ func TestSwipeFromEmptyWorkspaceLandsWithoutJump(t *testing.T) { | |||
| 324 | 350 | r := startSwipe(t, nil) | |
| 325 | 351 | r.workspaces(t) | |
| 326 | 352 | // Workspace 3 is the trailing empty one; start there and swipe up to 2. | |
| 327 | - | r.key(t, "Next", ports.ModAlt) | |
| 328 | - | r.key(t, "Next", ports.ModAlt) | |
| 353 | + | r.keySettled(t, "Next") | |
| 354 | + | r.keySettled(t, "Next") | |
| 329 | 355 | r.begin() | |
| 330 | 356 | var s ports.Scene | |
| 331 | 357 | for range 6 { | |
| @@ -382,6 +408,10 @@ func TestSwipeDropsWhenWorkspacesChange(t *testing.T) { | |||
| 382 | 408 | r.move(t, 0, 30) | |
| 383 | 409 | // A key switches workspace mid-swipe: the swipe lets go at once. | |
| 384 | 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 | 415 | if got, ok := rectOf(s, 2); !ok || got.Y != 0 { | |
| 386 | 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 | 436 | r.settle(t) | |
| 407 | 437 | var focus ports.PointerFocus | |
| 408 | 438 | var motion ports.PointerMotionTo | |
| 409 | - | // Core sends the pointer update right after the scene: wait for it. | |
| 410 | - | for { | |
| 411 | - | var v ports.ClientCommand | |
| 412 | - | select { | |
| 413 | - | case v = <-r.commands: | |
| 414 | - | case <-time.After(50 * time.Millisecond): | |
| 415 | - | } | |
| 416 | - | if v == nil { | |
| 417 | - | break | |
| 418 | - | } | |
| 439 | + | // Core sends the pointer update right after the scene. | |
| 440 | + | for _, v := range r.sent() { | |
| 419 | 441 | switch v := v.(type) { | |
| 420 | 442 | case ports.PointerFocus: | |
| 421 | 443 | focus = v | |
| @@ -478,7 +500,11 @@ func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) { | |||
| 478 | 500 | // The pointer goes to DP-2 and a bind switches its workspace. | |
| 479 | 501 | r.input <- ports.PointerMotion{X: 1000, Y: 300} | |
| 480 | 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 | 508 | if _, ok := rectOf(sc, 4); ok { | |
| 483 | 509 | t.Fatal("DP-2 did not switch workspace") | |
| 484 | 510 | } | |
| @@ -527,15 +553,7 @@ func TestDiscreteSwipeSkippedWhenPointerChangesOutput(t *testing.T) { | |||
| 527 | 553 | scene(t, r.scenes) | |
| 528 | 554 | r.drain() | |
| 529 | 555 | r.input <- ports.SwipeEnd{Time: r.at} | |
| 530 | - | for { | |
| 531 | - | var v ports.ClientCommand | |
| 532 | - | select { | |
| 533 | - | case v = <-r.commands: | |
| 534 | - | case <-time.After(50 * time.Millisecond): | |
| 535 | - | } | |
| 536 | - | if v == nil { | |
| 537 | - | break | |
| 538 | - | } | |
| 556 | + | for _, v := range r.sent() { | |
| 539 | 557 | if f, ok := v.(ports.FocusWindow); ok && f.ID == 3 { | |
| 540 | 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 | 563 | // drain waits for core to handle what was sent, then empties commands. | |
| 546 | 564 | func (r *swipeRig) drain() { | |
| 547 | - | for { | |
| 548 | - | select { | |
| 549 | - | case <-r.commands: | |
| 550 | - | case <-time.After(50 * time.Millisecond): | |
| 551 | - | return | |
| 552 | - | } | |
| 565 | + | for range r.sent() { | |
| 553 | 566 | } | |
| 554 | 567 | } | |
| 555 | 568 | ||
| 556 | - | func TestKeyWithoutSlideMovesAtOnce(t *testing.T) { | |
| 557 | - | r := startSwipe(t, nil) | |
| 558 | - | threeColumns(t, r) | |
| 559 | - | r.key(t, "Left", ports.ModAlt) | |
| 560 | - | s := r.key(t, "Left", ports.ModAlt)[0] | |
| 561 | - | if got, _ := rectOf(s, 1); got.X != 0 { | |
| 562 | - | t.Fatalf("column 1 at %d", got.X) | |
| 569 | + | // sent waits for core to handle what was sent and returns the commands it | |
| 570 | + | // sent meanwhile (the sync flip is a barrier, see published). | |
| 571 | + | func (r *swipeRig) sent() []ports.ClientCommand { | |
| 572 | + | send(r.frames, ports.OutputFrame{Output: "sync"}) | |
| 573 | + | var out []ports.ClientCommand | |
| 574 | + | for { | |
| 575 | + | select { | |
| 576 | + | case v := <-r.commands: | |
| 577 | + | out = append(out, v) | |
| 578 | + | default: | |
| 579 | + | return out | |
| 580 | + | } | |
| 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 | 844 | // Opening the overview during a column swipe drops the swipe: the rest | |
| 654 | 845 | // of it neither slides the columns under the overview nor lands them, | |
| 655 | 846 | // also once the overview closed again (escape before the rest). | |
| @@ -661,28 +852,40 @@ func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) { | |||
| 661 | 852 | r.move(t, -40, 0) | |
| 662 | 853 | r.move(t, -40, 0) | |
| 663 | 854 | r.key(t, "o", ports.ModAlt) | |
| 855 | + | // s is the latest scene. | |
| 856 | + | var s ports.Scene | |
| 664 | 857 | if closeFirst { | |
| 665 | - | r.key(t, "Escape", 0) | |
| 858 | + | s = r.key(t, "Escape", 0)[0] | |
| 666 | 859 | } | |
| 667 | 860 | for range 20 { | |
| 668 | 861 | r.at += 8 * time.Millisecond | |
| 669 | 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 | 868 | r.input <- ports.SwipeEnd{Time: r.at} | |
| 869 | + | s = scene(t, r.scenes) | |
| 672 | 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. | |
| 676 | - | r.advance(5 * time.Second) | |
| 677 | - | r.frames <- ports.OutputFrame{Output: wide.Name} | |
| 678 | - | s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { | |
| 679 | - | for _, w := range s.Windows { | |
| 680 | - | if w.Preview > 0 { | |
| 681 | - | return false | |
| 682 | - | } | |
| 873 | + | // Past any spring: the escape's own springs (the cards zooming | |
| 874 | + | // back) may chain after the swipe's landing, so settle until | |
| 875 | + | // none runs. | |
| 876 | + | for range 4 { | |
| 877 | + | set, ok := r.settleAll(t, wide.Name) | |
| 878 | + | if !ok { | |
| 879 | + | break | |
| 683 | 880 | } | |
| 684 | - | return true | |
| 685 | - | }) | |
| 881 | + | s = set[0] | |
| 882 | + | } | |
| 883 | + | if len(s.Windows) == 0 || slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { | |
| 884 | + | // Neither a card nor a window still zooming back. | |
| 885 | + | return w.Preview > 0 || w.Zoom > 0 || w.Fade > 0 | |
| 886 | + | }) { | |
| 887 | + | t.Fatalf("closeFirst=%v: the scene never settled: %+v", closeFirst, s.Windows) | |
| 888 | + | } | |
| 686 | 889 | for _, id := range []ports.WindowID{1, 2, 3} { | |
| 687 | 890 | before, _ := rectOf(settled, id) | |
| 688 | 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 | 91 | t.Fatal("popup of a visible window not on screen") | |
| 92 | 92 | } | |
| 93 | 93 | m.AddWindow(4) | |
| 94 | + | publish() // visibility reads the layout of the last publish | |
| 94 | 95 | if c.visible(1) || c.visible(10) { | |
| 95 | 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 | 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) | |
| 12 | - | // bound to Alt+w, and shows it. | |
| 13 | - | func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRig, ports.Scene) { | |
| 14 | - | t.Helper() | |
| 15 | - | r := startMulti(t, func(c *ports.Config) { | |
| 11 | + | // sizedConfig is the config of the sized-workspace rigs: gaps off, two | |
| 12 | + | // columns, the named workspace "focus" (1920x1080) bound to Alt+w. | |
| 13 | + | func sizedConfig(overflow string, edit func(*ports.Config)) func(*ports.Config) { | |
| 14 | + | return func(c *ports.Config) { | |
| 16 | 15 | c.Layout.Gaps = 0 | |
| 17 | 16 | c.Layout.MaxColumns = 2 | |
| 18 | 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 | 19 | if edit != nil { | |
| 21 | 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 | 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 | 42 | var frame = ports.Rect{X: 760, Y: 180, W: 1920, H: 1080} | |
| 28 | 43 | ||
| 29 | 44 | func within(r, o ports.Rect) bool { | |
| @@ -31,18 +46,26 @@ func within(r, o ports.Rect) bool { | |||
| 31 | 46 | } | |
| 32 | 47 | ||
| 33 | 48 | func TestWorkspaceSizeInheritsByDefault(t *testing.T) { | |
| 34 | - | r := startMulti(t, func(c *ports.Config) { c.Layout.Gaps = 0 }, ultrawide) | |
| 35 | - | set := r.mapWindow(t, 1) | |
| 36 | - | if set[0].WorkspaceClip != (ports.Rect{}) || set[0].Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) { | |
| 37 | - | t.Fatalf("%+v", set[0]) | |
| 38 | - | } | |
| 49 | + | both(t, func(t *testing.T, animated bool) { | |
| 50 | + | r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Gaps = 0 }, ultrawide) | |
| 51 | + | set := r.mapLanded(t, 1) | |
| 52 | + | if set[0].WorkspaceClip != (ports.Rect{}) || set[0].Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) { | |
| 53 | + | t.Fatalf("%+v", set[0]) | |
| 54 | + | } | |
| 55 | + | }) | |
| 39 | 56 | } | |
| 40 | 57 | ||
| 41 | 58 | func TestWorkspaceSizeScrollColumnsStayInFrame(t *testing.T) { | |
| 42 | - | r, _ := sizedRig(t, "scroll", nil) | |
| 43 | - | r.mapWindow(t, 1) | |
| 44 | - | r.mapWindow(t, 2) | |
| 45 | - | s := r.mapWindow(t, 3)[0] | |
| 59 | + | both(t, func(t *testing.T, animated bool) { | |
| 60 | + | r, _ := sizedLanding(t, animated, "scroll", nil) | |
| 61 | + | workspaceSizeScrollColumnsStayInFrame(t, r) | |
| 62 | + | }) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func workspaceSizeScrollColumnsStayInFrame(t *testing.T, r *landRig) { | |
| 66 | + | r.mapLanded(t, 1) | |
| 67 | + | r.mapLanded(t, 2) | |
| 68 | + | s := r.mapLanded(t, 3)[0] | |
| 46 | 69 | if s.WorkspaceClip != frame || s.OutputWidth != 3440 || s.OutputHeight != 1440 { | |
| 47 | 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 | 87 | func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) { | |
| 65 | - | r, _ := sizedRig(t, "fixed", nil) | |
| 66 | - | r.mapWindow(t, 1) | |
| 67 | - | s := r.mapWindow(t, 2)[0] | |
| 88 | + | both(t, func(t *testing.T, animated bool) { | |
| 89 | + | r, _ := sizedLanding(t, animated, "fixed", nil) | |
| 90 | + | workspaceSizeFixedColumnsFillFrame(t, r) | |
| 91 | + | }) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func workspaceSizeFixedColumnsFillFrame(t *testing.T, r *landRig) { | |
| 95 | + | r.mapLanded(t, 1) | |
| 96 | + | s := r.mapLanded(t, 2)[0] | |
| 68 | 97 | for i, want := range []ports.Rect{{X: 760, Y: 180, W: 960, H: 1080}, {X: 1720, Y: 180, W: 960, H: 1080}} { | |
| 69 | 98 | if s.Windows[i].Rect != want { | |
| 70 | 99 | t.Fatalf("%d: %+v", i, s.Windows[i]) | |
| @@ -73,48 +102,52 @@ func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) { | |||
| 73 | 102 | } | |
| 74 | 103 | ||
| 75 | 104 | func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) { | |
| 76 | - | r, _ := sizedRig(t, "scroll", nil) | |
| 77 | - | r.mapWindow(t, 1) | |
| 78 | - | r.mapWindow(t, 2) | |
| 79 | - | s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0] | |
| 80 | - | full := 0 | |
| 81 | - | for _, w := range s.Windows { | |
| 82 | - | if w.Fullscreen { | |
| 83 | - | full++ | |
| 84 | - | if w.Rect != frame { | |
| 85 | - | t.Fatalf("%+v", w) | |
| 105 | + | both(t, func(t *testing.T, animated bool) { | |
| 106 | + | r, _ := sizedLanding(t, animated, "scroll", nil) | |
| 107 | + | r.mapLanded(t, 1) | |
| 108 | + | r.mapLanded(t, 2) | |
| 109 | + | s := r.keyLanded(t, "f", ports.ModAlt|ports.ModShift)[0] | |
| 110 | + | full := 0 | |
| 111 | + | for _, w := range s.Windows { | |
| 112 | + | if w.Fullscreen { | |
| 113 | + | full++ | |
| 114 | + | if w.Rect != frame { | |
| 115 | + | t.Fatalf("%+v", w) | |
| 116 | + | } | |
| 86 | 117 | } | |
| 87 | 118 | } | |
| 88 | - | } | |
| 89 | - | if full != 1 { | |
| 90 | - | t.Fatal(s.Windows) | |
| 91 | - | } | |
| 119 | + | if full != 1 { | |
| 120 | + | t.Fatal(s.Windows) | |
| 121 | + | } | |
| 122 | + | }) | |
| 92 | 123 | } | |
| 93 | 124 | ||
| 94 | 125 | func TestWorkspaceSizeFixedFullscreenKeepsFrame(t *testing.T) { | |
| 95 | - | r, _ := sizedRig(t, "fixed", nil) | |
| 96 | - | r.mapWindow(t, 1) | |
| 97 | - | r.mapWindow(t, 2) | |
| 98 | - | s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0] | |
| 99 | - | if s.WorkspaceClip != frame { | |
| 100 | - | t.Fatalf("fullscreen clip %+v", s.WorkspaceClip) | |
| 101 | - | } | |
| 102 | - | for _, w := range s.Windows { | |
| 103 | - | if w.Fullscreen && w.Rect != frame { | |
| 104 | - | t.Fatalf("%+v", w) | |
| 126 | + | both(t, func(t *testing.T, animated bool) { | |
| 127 | + | r, _ := sizedLanding(t, animated, "fixed", nil) | |
| 128 | + | r.mapLanded(t, 1) | |
| 129 | + | r.mapLanded(t, 2) | |
| 130 | + | s := r.keyLanded(t, "f", ports.ModAlt|ports.ModShift)[0] | |
| 131 | + | if s.WorkspaceClip != frame { | |
| 132 | + | t.Fatalf("fullscreen clip %+v", s.WorkspaceClip) | |
| 105 | 133 | } | |
| 106 | - | } | |
| 107 | - | // Reload keeps the size; removing it frees the workspace. | |
| 108 | - | cfg := r.cfg | |
| 109 | - | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 110 | - | if s := receive(t, r.scenes)[0]; s.WorkspaceClip != frame { | |
| 111 | - | t.Fatalf("after reload %+v", s.WorkspaceClip) | |
| 112 | - | } | |
| 113 | - | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", LayoutRules: ports.LayoutRules{Overflow: "fixed"}}} | |
| 114 | - | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 115 | - | if s := receive(t, r.scenes)[0]; s.WorkspaceClip != (ports.Rect{}) { | |
| 116 | - | t.Fatalf("after size removal %+v", s.WorkspaceClip) | |
| 117 | - | } | |
| 134 | + | for _, w := range s.Windows { | |
| 135 | + | if w.Fullscreen && w.Rect != frame { | |
| 136 | + | t.Fatalf("%+v", w) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | // Reload keeps the size; removing it frees the workspace. | |
| 140 | + | cfg := r.cfg | |
| 141 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 142 | + | if s := r.land(receive(t, r.scenes))[0]; s.WorkspaceClip != frame { | |
| 143 | + | t.Fatalf("after reload %+v", s.WorkspaceClip) | |
| 144 | + | } | |
| 145 | + | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", LayoutRules: ports.LayoutRules{Overflow: "fixed"}}} | |
| 146 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 147 | + | if s := r.land(receive(t, r.scenes))[0]; s.WorkspaceClip != (ports.Rect{}) { | |
| 148 | + | t.Fatalf("after size removal %+v", s.WorkspaceClip) | |
| 149 | + | } | |
| 150 | + | }) | |
| 118 | 151 | } | |
| 119 | 152 | ||
| 120 | 153 | func TestWorkspaceSizeFloatsContained(t *testing.T) { | |
| @@ -130,8 +163,14 @@ func TestWorkspaceSizeFloatsContained(t *testing.T) { | |||
| 130 | 163 | } | |
| 131 | 164 | ||
| 132 | 165 | func TestWorkspaceSizeReloadAndRemoval(t *testing.T) { | |
| 133 | - | r, _ := sizedRig(t, "scroll", nil) | |
| 134 | - | r.mapWindow(t, 1) | |
| 166 | + | both(t, func(t *testing.T, animated bool) { | |
| 167 | + | r, _ := sizedLanding(t, animated, "scroll", nil) | |
| 168 | + | workspaceSizeReloadAndRemoval(t, r) | |
| 169 | + | }) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func workspaceSizeReloadAndRemoval(t *testing.T, r *landRig) { | |
| 173 | + | r.mapLanded(t, 1) | |
| 135 | 174 | cfg := r.cfg | |
| 136 | 175 | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1000, 600}}} | |
| 137 | 176 | r.reload <- ports.ConfigChanged{Config: cfg} | |
| @@ -159,28 +198,40 @@ func TestWorkspaceSizeReloadAndRemoval(t *testing.T) { | |||
| 159 | 198 | } | |
| 160 | 199 | ||
| 161 | 200 | func TestWorkspaceSizeFollowsOutputResize(t *testing.T) { | |
| 162 | - | r, _ := sizedRig(t, "scroll", nil) | |
| 163 | - | r.mapWindow(t, 1) | |
| 201 | + | both(t, func(t *testing.T, animated bool) { | |
| 202 | + | r, _ := sizedLanding(t, animated, "scroll", nil) | |
| 203 | + | workspaceSizeFollowsOutputResize(t, r) | |
| 204 | + | }) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func workspaceSizeFollowsOutputResize(t *testing.T, r *landRig) { | |
| 208 | + | r.mapLanded(t, 1) | |
| 164 | 209 | smaller := ultrawide | |
| 165 | 210 | smaller.Width, smaller.Height = 1600, 900 | |
| 166 | - | s := r.plug(t, smaller)[0] | |
| 211 | + | s := r.land(r.plug(t, smaller))[0] | |
| 167 | 212 | // 1920x1080 no longer fits: it clamps to the monitor. | |
| 168 | 213 | if s.WorkspaceClip != (ports.Rect{}) || s.Windows[0].Rect != (ports.Rect{W: 1600, H: 900}) { | |
| 169 | 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 | 217 | if s.WorkspaceClip != frame || s.Windows[0].Rect != frame { | |
| 173 | 218 | t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0]) | |
| 174 | 219 | } | |
| 175 | 220 | } | |
| 176 | 221 | ||
| 177 | 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 | 230 | // A bar inside the top of the monitor but above the frame reserves nothing of it. | |
| 180 | 231 | bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 100, ExclusiveZone: 100} | |
| 181 | 232 | r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}} | |
| 182 | 233 | receive(t, r.scenes) | |
| 183 | - | s := r.mapWindow(t, 1)[0] | |
| 234 | + | s := r.mapLanded(t, 1)[0] | |
| 184 | 235 | if s.Windows[0].Rect != frame { | |
| 185 | 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 | 387 | // ExportTargets provides a mock function for the type MockRenderer | |
| 388 | - | func (_mock *MockRenderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) { | |
| 389 | - | ret := _mock.Called(n, modifiers) | |
| 388 | + | func (_mock *MockRenderer) ExportTargets(n int, modifiers []uint64, singlePlane bool) ([]ports.DMABuf, error) { | |
| 389 | + | ret := _mock.Called(n, modifiers, singlePlane) | |
| 390 | 390 | ||
| 391 | 391 | if len(ret) == 0 { | |
| 392 | 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 | 395 | var r0 []ports.DMABuf | |
| 396 | 396 | var r1 error | |
| 397 | - | if returnFunc, ok := ret.Get(0).(func(int, []uint64) ([]ports.DMABuf, error)); ok { | |
| 398 | - | return returnFunc(n, modifiers) | |
| 397 | + | if returnFunc, ok := ret.Get(0).(func(int, []uint64, bool) ([]ports.DMABuf, error)); ok { | |
| 398 | + | return returnFunc(n, modifiers, singlePlane) | |
| 399 | 399 | } | |
| 400 | - | if returnFunc, ok := ret.Get(0).(func(int, []uint64) []ports.DMABuf); ok { | |
| 401 | - | r0 = returnFunc(n, modifiers) | |
| 400 | + | if returnFunc, ok := ret.Get(0).(func(int, []uint64, bool) []ports.DMABuf); ok { | |
| 401 | + | r0 = returnFunc(n, modifiers, singlePlane) | |
| 402 | 402 | } else { | |
| 403 | 403 | if ret.Get(0) != nil { | |
| 404 | 404 | r0 = ret.Get(0).([]ports.DMABuf) | |
| 405 | 405 | } | |
| 406 | 406 | } | |
| 407 | - | if returnFunc, ok := ret.Get(1).(func(int, []uint64) error); ok { | |
| 408 | - | r1 = returnFunc(n, modifiers) | |
| 407 | + | if returnFunc, ok := ret.Get(1).(func(int, []uint64, bool) error); ok { | |
| 408 | + | r1 = returnFunc(n, modifiers, singlePlane) | |
| 409 | 409 | } else { | |
| 410 | 410 | r1 = ret.Error(1) | |
| 411 | 411 | } | |
| @@ -420,11 +420,12 @@ type MockRenderer_ExportTargets_Call struct { | |||
| 420 | 420 | // ExportTargets is a helper method to define mock.On call | |
| 421 | 421 | // - n int | |
| 422 | 422 | // - modifiers []uint64 | |
| 423 | - | func (_e *MockRenderer_Expecter) ExportTargets(n any, modifiers any) *MockRenderer_ExportTargets_Call { | |
| 424 | - | return &MockRenderer_ExportTargets_Call{Call: _e.mock.On("ExportTargets", n, modifiers)} | |
| 423 | + | // - singlePlane bool | |
| 424 | + | func (_e *MockRenderer_Expecter) ExportTargets(n any, modifiers any, singlePlane any) *MockRenderer_ExportTargets_Call { | |
| 425 | + | return &MockRenderer_ExportTargets_Call{Call: _e.mock.On("ExportTargets", n, modifiers, singlePlane)} | |
| 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 | 429 | _c.Call.Run(func(args mock.Arguments) { | |
| 429 | 430 | var arg0 int | |
| 430 | 431 | if args[0] != nil { | |
| @@ -434,9 +435,14 @@ func (_c *MockRenderer_ExportTargets_Call) Run(run func(n int, modifiers []uint6 | |||
| 434 | 435 | if args[1] != nil { | |
| 435 | 436 | arg1 = args[1].([]uint64) | |
| 436 | 437 | } | |
| 438 | + | var arg2 bool | |
| 439 | + | if args[2] != nil { | |
| 440 | + | arg2 = args[2].(bool) | |
| 441 | + | } | |
| 437 | 442 | run( | |
| 438 | 443 | arg0, | |
| 439 | 444 | arg1, | |
| 445 | + | arg2, | |
| 440 | 446 | ) | |
| 441 | 447 | }) | |
| 442 | 448 | return _c | |
| @@ -447,7 +453,7 @@ func (_c *MockRenderer_ExportTargets_Call) Return(dMABufs []ports.DMABuf, err er | |||
| 447 | 453 | 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 | 457 | _c.Call.Return(run) | |
| 452 | 458 | return _c | |
| 453 | 459 | } | |
M internal/ports/config.go +8 -1
| @@ -143,7 +143,14 @@ type Config struct { | |||
| 143 | 143 | // Cursor.HideAfter hides the pointer cursor after this long without | |
| 144 | 144 | // motion; the next motion shows it. 0 never hides it. | |
| 145 | 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 | 154 | // FollowMove shows the target workspace after a column or window | |
| 148 | 155 | // moves to it. | |
| 149 | 156 | FollowMove bool | |
M internal/ports/damage_test.go +20 -0
| @@ -35,3 +35,23 @@ func TestDamageSince(t *testing.T) { | |||
| 35 | 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 | 47 | // ExportTargets allocates n images of the renderer's size that the | |
| 48 | 48 | // display can scan out, with one of the given XRGB8888 (SDR) or | |
| 49 | 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 | 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 | 55 | // UseTarget selects the exported image the next Render draws into. | |
| 54 | 56 | UseTarget(i int) | |
| 55 | 57 | // CursorBuffers allocates two linear ARGB8888 images of size×size the | |
M internal/ports/scene.go +65 -7
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "image" | |
| 5 | 5 | "math" | |
| 6 | 6 | "os" | |
| 7 | + | "slices" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | // Scene carries core → renderer immutable snapshots with fresh Windows slices, | |
| @@ -68,6 +69,37 @@ type Scene struct { | |||
| 68 | 69 | 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 | 103 | // Shows reports whether the scene draws the surface of id: only its | |
| 72 | 104 | // content changes need a new frame. A window scrolled off the output is | |
| 73 | 105 | // not drawn. WorkspaceClip also excludes off-viewport windows; OverLayers | |
| @@ -175,6 +207,11 @@ type SurfaceContent struct { | |||
| 175 | 207 | // Opaque ignores the alpha byte: an x format, or an opaque region that | |
| 176 | 208 | // covers the whole surface. It is false while Fade is set. | |
| 177 | 209 | 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 | 215 | // Fade is how much wp_alpha_modifier_v1 fades the surface: 0 none, | |
| 179 | 216 | // 1 invisible. Its opacity is multiplied by 1-Fade. | |
| 180 | 217 | Fade float32 | |
| @@ -270,24 +307,35 @@ type SeqDamage struct { | |||
| 270 | 307 | // DamageSince is the union of changes after content seq up to this one, | |
| 271 | 308 | // and false when the history does not reach back to seq (redraw all). | |
| 272 | 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 | 321 | if seq >= c.Seq { | |
| 274 | - | return nil, true | |
| 322 | + | return dst, true | |
| 275 | 323 | } | |
| 276 | 324 | h := c.DamageHistory | |
| 277 | 325 | if len(h) == 0 || h[0].Seq > seq+1 || h[len(h)-1].Seq != c.Seq { | |
| 278 | - | return nil, false | |
| 326 | + | return dst, false | |
| 279 | 327 | } | |
| 280 | - | var out []Rect | |
| 328 | + | n := len(dst) | |
| 281 | 329 | for _, d := range h { | |
| 282 | 330 | if d.Seq <= seq { | |
| 283 | 331 | continue | |
| 284 | 332 | } | |
| 285 | 333 | if d.Full { | |
| 286 | - | return nil, false | |
| 334 | + | return dst[:n], false | |
| 287 | 335 | } | |
| 288 | - | out = append(out, d.Rects...) | |
| 336 | + | dst = append(dst, d.Rects...) | |
| 289 | 337 | } | |
| 290 | - | return out, true | |
| 338 | + | return dst, true | |
| 291 | 339 | } | |
| 292 | 340 | ||
| 293 | 341 | // Subsurface is a child surface at X, Y logical pixels from the root | |
| @@ -371,6 +419,11 @@ type SceneWindow struct { | |||
| 371 | 419 | // Dim darkens the window, border included, with black at this | |
| 372 | 420 | // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing. | |
| 373 | 421 | 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 | 427 | // FocusEffect, above 0, is the focus indicator's effect at this frame | |
| 375 | 428 | // (focus.animation, focus.effect): the window's surfaces are lifted | |
| 376 | 429 | // toward white by that fraction (screen blend). Renderers without the | |
| @@ -382,8 +435,13 @@ type SceneWindow struct { | |||
| 382 | 435 | // needs its kind here and in the push constants (a misc flag). | |
| 383 | 436 | FocusEffect float64 | |
| 384 | 437 | // 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 | 440 | 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 | 445 | // Popups are drawn from their content only: no border, no background. | |
| 388 | 446 | Popup bool | |
| 389 | 447 | // 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 | + | } | |