feat(core): slide the stash with a finger-following three-finger swipe (#139)
* feat(core): slide the stash view with a finger-following three-finger swipe The shown stash now slides: a sideways three-finger swipe follows the fingers with a detent on each window and lands on a neighbor at most, so no window is skipped. Keyboard navigation uses the same slide, so the old per-window rect and veil motions for stash navigation are gone. * docs(config): describe the sliding stash swipe * fix(core): settle stash swipe edge cases found in review - a stash landing no longer reports a shown workspace, which respawned the empty slots of a named workspace - a focused native float keeps the swipe discrete instead of sliding the stash behind it - a swipe whose output is unplugged lets go of the stash offset it set - the overview stops a running stash spring, and the capture scene draws the sliding veil - a navigation slide keeps the drawn view however far it is from the selection, and only counts when the stash was on screen - refresh the comments the slide made stale * refactor(core): share one slide type between the columns, workspaces and stash The column view, the workspace switch and the stash each kept an offset and a spring with their own stop, animate, springing and settled code. They are one slide type now (off, motion, step, stop, busy). Also simplify the stash layout to one rule for peeks and sliding windows, mark peeks through one helper that sets Peek and Veil together, drop the stash swipe when its output is unplugged, and cover the float focus, lock, unplug, other-output lift, capture veil and far-window cases with tests. * docs(core): use the slide field names in comments * fix(core): keep stash neighbors hidden where no margin is left With an odd usable width and a gap equal to the margin, the wider side still showed a one pixel sliver of a neighbor. Neighbors at rest need a margin again; a sliding window still shows wherever it overlaps the view. Share the gap and pitch computation between the layout and the spring, and make the capture veil test call captureScene so it fails without the fix. * test(core): budget the allocations of a published stash slide frame
- commit
- ccce658a0f43a0446ec7a55bdc8a406f0e4866aa
- author
- bnema · · 2026-10-05 17:54 UTC
- committer
- GitHub · · 2026-10-05 17:54 UTC
- parent
- 454609d
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.5.0
28 files · +860 -196
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/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 | |
| 63 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishStashSlideAllocations|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 | |
| @@ -60,7 +60,7 @@ arch: | |||||
| 60 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | 60 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | ||
| 61 | # Fast, mandatory guards for tiled content publications and SHM copies. | 61 | # Fast, mandatory guards for tiled content publications and SHM copies. | ||
| 62 | perf-check: | 62 | perf-check: | ||
| 63 | - | CGO_ENABLED=0 go test ./internal/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 | 63 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 |
| 64 | check: vet test arch fakes-check log-check perf-check | 64 | check: vet test arch fakes-check log-check perf-check | ||
| 65 | 65 | ||||
| 66 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | 66 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | ||
M docs/config.md +2 -2
| @@ -147,7 +147,7 @@ With `natural-scroll = off`, a swipe left shows the columns to the left and a sw | |||
| 147 | 147 | ||
| 148 | 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 | 149 | ||
| 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. | |
| 150 | + | Over the shown stash a sideways swipe slides it with the fingers, with a stop on each window, and moves one window at most. Where the view cannot scroll (`fixed` overflow, 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. | |
| 151 | 151 | ||
| 152 | 152 | Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia. | |
| 153 | 153 | ||
| @@ -222,7 +222,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 222 | 222 | ||
| 223 | 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. | |
| 224 | 224 | ||
| 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). | |
| 225 | + | While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it, sliding the view, 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). | |
| 226 | 226 | ||
| 227 | 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. | |
| 228 | 228 | ||
| @@ -147,7 +147,7 @@ With `natural-scroll = off`, a swipe left shows the columns to the left and a sw | |||||
| 147 | 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`. | 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 | 149 | ||||
| 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. | 150 | + | Over the shown stash a sideways swipe slides it with the fingers, with a stop on each window, and moves one window at most. Where the view cannot scroll (`fixed` overflow, 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. |
| 151 | 151 | ||||
| 152 | Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia. | 152 | Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia. | ||
| 153 | 153 | ||||
| @@ -222,7 +222,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 222 | 222 | ||||
| 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. | 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. | ||
| 224 | 224 | ||||
| 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). | 225 | + | While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it, sliding the view, 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). |
| 226 | 226 | ||||
| 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. | 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. | ||
| 228 | 228 | ||||
M internal/core/animate_alloc_test.go +8 -6
| @@ -18,10 +18,12 @@ func TestAnimateAllocations(t *testing.T) { | |||
| 18 | 18 | m.AddWindow(2) | |
| 19 | 19 | ||
| 20 | 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 | |
| 21 | + | ws.view.motion = c.spring(viewSpring(100, 0), t0) | |
| 22 | + | ws.view.off = 100 | |
| 23 | + | m.switchView.motion = c.spring(workspaceSpring(100, 0), t0) | |
| 24 | + | m.switchView.off = 100 | |
| 25 | + | ws.stashView.motion = c.spring(workspaceSpring(0.5, 0), t0) | |
| 26 | + | ws.stashView.off = 0.5 | |
| 25 | 27 | sc.rects = make(map[WindowID]rectMotion, 20) | |
| 26 | 28 | for i := range 20 { | |
| 27 | 29 | sp := viewSpring(50, 0) | |
| @@ -57,7 +59,7 @@ func TestAnimateAllocations(t *testing.T) { | |||
| 57 | 59 | t.Errorf("animate(%s) allocs = %v, want 0", name, n) | |
| 58 | 60 | } | |
| 59 | 61 | } | |
| 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 | + | if !ws.view.motion.on || !ws.stashView.motion.on || !m.switchView.motion.on || len(sc.rects) != 24 { | |
| 63 | + | t.Fatalf("motions settled: camera %v switch %v rects %d", ws.view.motion.on, m.switchView.motion.on, len(sc.rects)) | |
| 62 | 64 | } | |
| 63 | 65 | } | |
| @@ -18,10 +18,12 @@ func TestAnimateAllocations(t *testing.T) { | |||||
| 18 | m.AddWindow(2) | 18 | m.AddWindow(2) | ||
| 19 | 19 | ||||
| 20 | ws := m.Workspaces[0] | 20 | ws := m.Workspaces[0] | ||
| 21 | - | ws.motion = c.spring(viewSpring(100, 0), t0) | 21 | + | ws.view.motion = c.spring(viewSpring(100, 0), t0) |
| 22 | - | ws.shift = 100 | 22 | + | ws.view.off = 100 |
| 23 | - | m.switchMotion = c.spring(workspaceSpring(100, 0), t0) | 23 | + | m.switchView.motion = c.spring(workspaceSpring(100, 0), t0) |
| 24 | - | m.switchOff = 100 | 24 | + | m.switchView.off = 100 |
| 25 | + | ws.stashView.motion = c.spring(workspaceSpring(0.5, 0), t0) | |||
| 26 | + | ws.stashView.off = 0.5 | |||
| 25 | sc.rects = make(map[WindowID]rectMotion, 20) | 27 | sc.rects = make(map[WindowID]rectMotion, 20) | ||
| 26 | for i := range 20 { | 28 | for i := range 20 { | ||
| 27 | sp := viewSpring(50, 0) | 29 | sp := viewSpring(50, 0) | ||
| @@ -57,7 +59,7 @@ func TestAnimateAllocations(t *testing.T) { | |||||
| 57 | t.Errorf("animate(%s) allocs = %v, want 0", name, n) | 59 | t.Errorf("animate(%s) allocs = %v, want 0", name, n) | ||
| 58 | } | 60 | } | ||
| 59 | } | 61 | } | ||
| 60 | - | if !ws.motion.on || !m.switchMotion.on || len(sc.rects) != 24 { | 62 | + | if !ws.view.motion.on || !ws.stashView.motion.on || !m.switchView.motion.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)) | 63 | + | t.Fatalf("motions settled: camera %v switch %v rects %d", ws.view.motion.on, m.switchView.motion.on, len(sc.rects)) |
| 62 | } | 64 | } | ||
| 63 | } | 65 | } | ||
M internal/core/animations_interrupt_internal_test.go +4 -4
| @@ -134,13 +134,13 @@ func TestLastOutputUnplugMidRectMotion(t *testing.T) { | |||
| 134 | 134 | movingRects(t, c, ic, sc) | |
| 135 | 135 | // A workspace switch spring runs too. | |
| 136 | 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() { | |
| 137 | + | m.switchView.off, m.switchView.motion = 0.5, c.spring(workspaceSpring(0.5, 0), ic.now) | |
| 138 | + | if !m.switchView.motion.on || !sc.springing() { | |
| 139 | 139 | t.Fatal("no workspace switch to interrupt") | |
| 140 | 140 | } | |
| 141 | 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) | |
| 142 | + | if sc.mon.springing() || len(sc.rects) != 0 || sc.rectsWS != nil || sc.mon.switchView.motion.on || sc.mon.switchView.off != 0 { | |
| 143 | + | t.Fatalf("the placeholder keeps springs: monitor %t, rects %d, switch %t (%v)", sc.mon.springing(), len(sc.rects), sc.mon.switchView.motion.on, sc.mon.switchView.off) | |
| 144 | 144 | } | |
| 145 | 145 | if err := c.publish(context.Background()); err != nil { | |
| 146 | 146 | t.Fatal(err) | |
| @@ -134,13 +134,13 @@ func TestLastOutputUnplugMidRectMotion(t *testing.T) { | |||||
| 134 | movingRects(t, c, ic, sc) | 134 | movingRects(t, c, ic, sc) | ||
| 135 | // A workspace switch spring runs too. | 135 | // A workspace switch spring runs too. | ||
| 136 | m := sc.mon | 136 | m := sc.mon | ||
| 137 | - | m.switchOff, m.switchMotion = 0.5, c.spring(workspaceSpring(0.5, 0), ic.now) | 137 | + | m.switchView.off, m.switchView.motion = 0.5, c.spring(workspaceSpring(0.5, 0), ic.now) |
| 138 | - | if !m.switchMotion.on || !sc.springing() { | 138 | + | if !m.switchView.motion.on || !sc.springing() { |
| 139 | t.Fatal("no workspace switch to interrupt") | 139 | t.Fatal("no workspace switch to interrupt") | ||
| 140 | } | 140 | } | ||
| 141 | c.removeScreen("A") | 141 | c.removeScreen("A") | ||
| 142 | - | if sc.mon.springing() || len(sc.rects) != 0 || sc.rectsWS != nil || sc.mon.switchMotion.on || sc.mon.switchOff != 0 { | 142 | + | if sc.mon.springing() || len(sc.rects) != 0 || sc.rectsWS != nil || sc.mon.switchView.motion.on || sc.mon.switchView.off != 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) | 143 | + | t.Fatalf("the placeholder keeps springs: monitor %t, rects %d, switch %t (%v)", sc.mon.springing(), len(sc.rects), sc.mon.switchView.motion.on, sc.mon.switchView.off) |
| 144 | } | 144 | } | ||
| 145 | if err := c.publish(context.Background()); err != nil { | 145 | if err := c.publish(context.Background()); err != nil { | ||
| 146 | t.Fatal(err) | 146 | t.Fatal(err) | ||
M internal/core/animations_stop_test.go +4 -4
| @@ -8,14 +8,14 @@ func TestStopAnimationsSettlesEverySpring(t *testing.T) { | |||
| 8 | 8 | m := c.cur().mon | |
| 9 | 9 | m.AddWindow(1) | |
| 10 | 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) | |
| 11 | + | w.view.off, w.view.motion = 40, c.spring(viewSpring(40, 0), ic.now) | |
| 12 | + | m.switchView.off, m.switchView.motion = 0.5, c.spring(workspaceSpring(0.5, 0), ic.now) | |
| 13 | 13 | c.armFrame() | |
| 14 | 14 | if !c.animating() || c.frameC == nil { | |
| 15 | 15 | t.Fatal("no running spring to stop") | |
| 16 | 16 | } | |
| 17 | 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) | |
| 18 | + | if c.animating() || w.view.motion.on || w.view.off != 0 || m.switchView.motion.on || m.switchView.off != 0 || c.frameC != nil { | |
| 19 | + | t.Fatalf("springs left: view %v, switch %v, motions %v %v, timer %v", w.view.off, m.switchView.off, w.view.motion.on, m.switchView.motion.on, c.frameC != nil) | |
| 20 | 20 | } | |
| 21 | 21 | } | |
| @@ -8,14 +8,14 @@ func TestStopAnimationsSettlesEverySpring(t *testing.T) { | |||||
| 8 | m := c.cur().mon | 8 | m := c.cur().mon | ||
| 9 | m.AddWindow(1) | 9 | m.AddWindow(1) | ||
| 10 | w := m.Current() | 10 | w := m.Current() | ||
| 11 | - | w.shift, w.motion = 40, c.spring(viewSpring(40, 0), ic.now) | 11 | + | w.view.off, w.view.motion = 40, c.spring(viewSpring(40, 0), ic.now) |
| 12 | - | m.switchOff, m.switchMotion = 0.5, c.spring(workspaceSpring(0.5, 0), ic.now) | 12 | + | m.switchView.off, m.switchView.motion = 0.5, c.spring(workspaceSpring(0.5, 0), ic.now) |
| 13 | c.armFrame() | 13 | c.armFrame() | ||
| 14 | if !c.animating() || c.frameC == nil { | 14 | if !c.animating() || c.frameC == nil { | ||
| 15 | t.Fatal("no running spring to stop") | 15 | t.Fatal("no running spring to stop") | ||
| 16 | } | 16 | } | ||
| 17 | c.stopAnimations() | 17 | c.stopAnimations() | ||
| 18 | - | if c.animating() || w.motion.on || w.shift != 0 || m.switchMotion.on || m.switchOff != 0 || c.frameC != nil { | 18 | + | if c.animating() || w.view.motion.on || w.view.off != 0 || m.switchView.motion.on || m.switchView.off != 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) | 19 | + | t.Fatalf("springs left: view %v, switch %v, motions %v %v, timer %v", w.view.off, m.switchView.off, w.view.motion.on, m.switchView.motion.on, c.frameC != nil) |
| 20 | } | 20 | } | ||
| 21 | } | 21 | } | ||
M internal/core/capture_workspace.go +1 -3
| @@ -161,9 +161,7 @@ func (c *Core) captureScene(seq uint64) *ports.Scene { | |||
| 161 | 161 | if !p.Hidden { | |
| 162 | 162 | w.Rect = rebase(p.Rect) | |
| 163 | 163 | } | |
| 164 | - | if p.Peek { | |
| 165 | - | w.Dim = c.cfg.Stash.Dim | |
| 166 | - | } | |
| 164 | + | w.Dim = c.peekDim(p) | |
| 167 | 165 | s.Windows = append(s.Windows, w) | |
| 168 | 166 | } | |
| 169 | 167 | s.Separators = separators(cw.placements, c.cfg.Border.Width, cw.ws.gap(), f, false) | |
| @@ -161,9 +161,7 @@ func (c *Core) captureScene(seq uint64) *ports.Scene { | |||||
| 161 | if !p.Hidden { | 161 | if !p.Hidden { | ||
| 162 | w.Rect = rebase(p.Rect) | 162 | w.Rect = rebase(p.Rect) | ||
| 163 | } | 163 | } | ||
| 164 | - | if p.Peek { | 164 | + | w.Dim = c.peekDim(p) |
| 165 | - | w.Dim = c.cfg.Stash.Dim | |||
| 166 | - | } | |||
| 167 | s.Windows = append(s.Windows, w) | 165 | s.Windows = append(s.Windows, w) | ||
| 168 | } | 166 | } | ||
| 169 | s.Separators = separators(cw.placements, c.cfg.Border.Width, cw.ws.gap(), f, false) | 167 | s.Separators = separators(cw.placements, c.cfg.Border.Width, cw.ws.gap(), f, false) | ||
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{}, 1) | |
| 196 | + | ws.view.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 = motion{} | |
| 210 | + | ws.view.motion = motion{} | |
| 211 | 211 | if err := c.publish(ctx); err != nil { | |
| 212 | 212 | t.Fatal(err) | |
| 213 | 213 | } | |
| @@ -193,7 +193,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) { | |||||
| 193 | c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}} | 193 | c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}} | ||
| 194 | c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true} | 194 | c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true} | ||
| 195 | c.popupOrder = append(c.popupOrder, 10) | 195 | c.popupOrder = append(c.popupOrder, 10) | ||
| 196 | - | ws.motion = newMotion(viewSpring(10, 0), time.Time{}, 1) | 196 | + | ws.view.motion = newMotion(viewSpring(10, 0), time.Time{}, 1) |
| 197 | ctx := context.Background() | 197 | ctx := context.Background() | ||
| 198 | if err := c.publish(ctx); err != nil { | 198 | if err := c.publish(ctx); err != nil { | ||
| 199 | t.Fatal(err) | 199 | t.Fatal(err) | ||
| @@ -207,7 +207,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) { | |||||
| 207 | if v, ok := c.configures.sent[1]; !ok || !v.Captured || v.Visible || v.Activated || v.Width <= 0 { | 207 | if v, ok := c.configures.sent[1]; !ok || !v.Captured || v.Visible || v.Activated || v.Width <= 0 { | ||
| 208 | t.Fatalf("window during the slide %+v (sent %v)", v, ok) | 208 | t.Fatalf("window during the slide %+v (sent %v)", v, ok) | ||
| 209 | } | 209 | } | ||
| 210 | - | ws.motion = motion{} | 210 | + | ws.view.motion = motion{} |
| 211 | if err := c.publish(ctx); err != nil { | 211 | if err := c.publish(ctx); err != nil { | ||
| 212 | t.Fatal(err) | 212 | t.Fatal(err) | ||
| 213 | } | 213 | } | ||
M internal/core/drag.go +1 -1
| @@ -578,7 +578,7 @@ func (w *Workspace) scrollBy(dir int) bool { | |||
| 578 | 578 | if view == w.ViewX { | |
| 579 | 579 | return false | |
| 580 | 580 | } | |
| 581 | - | w.stopSlide() | |
| 581 | + | w.view.stop() | |
| 582 | 582 | w.ViewX = view | |
| 583 | 583 | return true | |
| 584 | 584 | } | |
| @@ -578,7 +578,7 @@ func (w *Workspace) scrollBy(dir int) bool { | |||||
| 578 | if view == w.ViewX { | 578 | if view == w.ViewX { | ||
| 579 | return false | 579 | return false | ||
| 580 | } | 580 | } | ||
| 581 | - | w.stopSlide() | 581 | + | w.view.stop() |
| 582 | w.ViewX = view | 582 | w.ViewX = view | ||
| 583 | return true | 583 | return true | ||
| 584 | } | 584 | } | ||
M internal/core/floating_test.go +25 -0
| @@ -114,6 +114,31 @@ func TestStashStrip(t *testing.T) { | |||
| 114 | 114 | } | |
| 115 | 115 | } | |
| 116 | 116 | ||
| 117 | + | // Neighbors show only in a margin that is left: with an odd width the gap | |
| 118 | + | // can match the narrower side, and the wider one must not show a sliver. | |
| 119 | + | func TestStashNeighborNeedsAMargin(t *testing.T) { | |
| 120 | + | w := &Workspace{} | |
| 121 | + | w.SetOutput(1001, 600) | |
| 122 | + | w.SetMaxColumns(2) | |
| 123 | + | w.stashGap = 10 | |
| 124 | + | for id := WindowID(1); id <= 3; id++ { | |
| 125 | + | w.AddWindow(id) | |
| 126 | + | } | |
| 127 | + | for _, id := range []WindowID{3, 2} { | |
| 128 | + | w.FocusID(id) | |
| 129 | + | w.Apply(ActionToggleWindowStash) | |
| 130 | + | } | |
| 131 | + | w.FocusID(3) | |
| 132 | + | if p := placement(w, 3); p.Rect.X != 100 || p.Rect.W != 800 { | |
| 133 | + | t.Fatalf("setup: selected at %+v, want X 100 W 800", p.Rect) | |
| 134 | + | } | |
| 135 | + | for _, id := range []WindowID{2} { | |
| 136 | + | if p := placement(w, id); !p.Hidden || p.Peek { | |
| 137 | + | t.Fatalf("window %d shows as a sliver where no margin is left: %+v", id, p) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 117 | 142 | // Hiding the stash is the way out: tiles get the focus, a new stashed | |
| 118 | 143 | // window shows it again, and the native floats stay as they are. | |
| 119 | 144 | func TestToggleStashVisible(t *testing.T) { | |
| @@ -114,6 +114,31 @@ func TestStashStrip(t *testing.T) { | |||||
| 114 | } | 114 | } | ||
| 115 | } | 115 | } | ||
| 116 | 116 | ||||
| 117 | + | // Neighbors show only in a margin that is left: with an odd width the gap | |||
| 118 | + | // can match the narrower side, and the wider one must not show a sliver. | |||
| 119 | + | func TestStashNeighborNeedsAMargin(t *testing.T) { | |||
| 120 | + | w := &Workspace{} | |||
| 121 | + | w.SetOutput(1001, 600) | |||
| 122 | + | w.SetMaxColumns(2) | |||
| 123 | + | w.stashGap = 10 | |||
| 124 | + | for id := WindowID(1); id <= 3; id++ { | |||
| 125 | + | w.AddWindow(id) | |||
| 126 | + | } | |||
| 127 | + | for _, id := range []WindowID{3, 2} { | |||
| 128 | + | w.FocusID(id) | |||
| 129 | + | w.Apply(ActionToggleWindowStash) | |||
| 130 | + | } | |||
| 131 | + | w.FocusID(3) | |||
| 132 | + | if p := placement(w, 3); p.Rect.X != 100 || p.Rect.W != 800 { | |||
| 133 | + | t.Fatalf("setup: selected at %+v, want X 100 W 800", p.Rect) | |||
| 134 | + | } | |||
| 135 | + | for _, id := range []WindowID{2} { | |||
| 136 | + | if p := placement(w, id); !p.Hidden || p.Peek { | |||
| 137 | + | t.Fatalf("window %d shows as a sliver where no margin is left: %+v", id, p) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 117 | // Hiding the stash is the way out: tiles get the focus, a new stashed | 142 | // Hiding the stash is the way out: tiles get the focus, a new stashed | ||
| 118 | // window shows it again, and the native floats stay as they are. | 143 | // window shows it again, and the native floats stay as they are. | ||
| 119 | func TestToggleStashVisible(t *testing.T) { | 144 | func TestToggleStashVisible(t *testing.T) { | ||
M internal/core/frame_pulse_test.go +2 -2
| @@ -42,8 +42,8 @@ func TestFlipOfOtherOutputKeepsPulseSeq(t *testing.T) { | |||
| 42 | 42 | } | |
| 43 | 43 | // A camera transition runs on B. | |
| 44 | 44 | w := b.mon.Current() | |
| 45 | - | w.shift = 100 | |
| 46 | - | w.motion = newMotion(viewSpring(w.shift, 0), ic.now, 1) | |
| 45 | + | w.view.off = 100 | |
| 46 | + | w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1) | |
| 47 | 47 | ||
| 48 | 48 | ic.now = ic.now.Add(pulseRise / 2) | |
| 49 | 49 | first := stepScenes(t, c, a) | |
| @@ -42,8 +42,8 @@ func TestFlipOfOtherOutputKeepsPulseSeq(t *testing.T) { | |||||
| 42 | } | 42 | } | ||
| 43 | // A camera transition runs on B. | 43 | // A camera transition runs on B. | ||
| 44 | w := b.mon.Current() | 44 | w := b.mon.Current() | ||
| 45 | - | w.shift = 100 | 45 | + | w.view.off = 100 |
| 46 | - | w.motion = newMotion(viewSpring(w.shift, 0), ic.now, 1) | 46 | + | w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1) |
| 47 | 47 | ||||
| 48 | ic.now = ic.now.Add(pulseRise / 2) | 48 | ic.now = ic.now.Add(pulseRise / 2) | ||
| 49 | first := stepScenes(t, c, a) | 49 | first := stepScenes(t, c, a) | ||
M internal/core/gesture.go +112 -37
| @@ -12,9 +12,11 @@ import ( | |||
| 12 | 12 | // up or down it slides between workspaces. Each swipe moves one step at | |
| 13 | 13 | // most (snapswipe.go): the next column edge or the next workspace. When | |
| 14 | 14 | // the fingers lift, a quick swipe goes on to that step, a slow one settles | |
| 15 | - | // on the closest, and a spring takes the view there (motion.go). Where the | |
| 16 | - | // view cannot slide (fixed overflow, the stash, a float, a fullscreen | |
| 17 | - | // window, the overview), the swipe runs one focus action instead. | |
| 15 | + | // on the closest, and a spring takes the view there (motion.go). Sideways | |
| 16 | + | // over the shown stash the swipe slides the stash the same way, one window | |
| 17 | + | // at most. Where the view cannot slide (fixed overflow, a float, a | |
| 18 | + | // fullscreen window, the overview), the swipe runs one focus action | |
| 19 | + | // instead. | |
| 18 | 20 | ||
| 19 | 21 | const ( | |
| 20 | 22 | // swipeDecide is the touchpad distance before the swipe picks its axis | |
| @@ -26,6 +28,9 @@ const ( | |||
| 26 | 28 | // workspaceSwipeMovement is the touchpad distance that slides one | |
| 27 | 29 | // workspace. | |
| 28 | 30 | workspaceSwipeMovement = 300.0 | |
| 31 | + | // stashSwipeMovement is the touchpad distance that slides the stash by | |
| 32 | + | // one window. | |
| 33 | + | stashSwipeMovement = 300.0 | |
| 29 | 34 | ) | |
| 30 | 35 | ||
| 31 | 36 | type swipeMode uint8 | |
| @@ -40,6 +45,10 @@ const ( | |||
| 40 | 45 | // the overview closed, down shows or hides the stash, and up hides | |
| 41 | 46 | // it when it is shown, else opens the overview. | |
| 42 | 47 | swipeOverview | |
| 48 | + | // swipeStash is a sideways swipe over the shown stash: its view | |
| 49 | + | // follows the fingers, with a stop on each window, and lands on a | |
| 50 | + | // neighbor of the selection at most. | |
| 51 | + | swipeStash | |
| 43 | 52 | // swipeDropped ignores the rest of a swipe whose workspace changed. | |
| 44 | 53 | swipeDropped | |
| 45 | 54 | ) | |
| @@ -66,6 +75,10 @@ type swipeGesture struct { | |||
| 66 | 75 | // list is the numbered workspaces when a workspace slide began: the | |
| 67 | 76 | // slide indexes it, so it ends if the list changes. | |
| 68 | 77 | list []*Workspace | |
| 78 | + | // ids and home are the stash windows and the selection when a stash | |
| 79 | + | // swipe began: it ends if either changes. | |
| 80 | + | ids []WindowID | |
| 81 | + | home int | |
| 69 | 82 | // opens is the monitor's overviewOpens when the swipe picked its | |
| 70 | 83 | // mode: a slide the overview opened over is dropped, even once the | |
| 71 | 84 | // overview closed again. | |
| @@ -86,6 +99,15 @@ func (g *swipeGesture) columnsChanged(m *Monitor) bool { | |||
| 86 | 99 | return m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || !slices.Equal(g.ws.snapPoints(), g.points) | |
| 87 | 100 | } | |
| 88 | 101 | ||
| 102 | + | // stashChanged reports whether a stash swipe lost its stash: it is no | |
| 103 | + | // longer shown or focused, its windows or selection changed, or the | |
| 104 | + | // overview opened over it. | |
| 105 | + | func (g *swipeGesture) stashChanged(m *Monitor) bool { | |
| 106 | + | w := g.ws | |
| 107 | + | return m.overviewOpens != g.opens || m.Current() != w || !w.stashFocused() || w.floatFocus || !w.stashShown() || w.stashAt != g.home || | |
| 108 | + | !slices.EqualFunc(w.Stash, g.ids, func(f Float, id WindowID) bool { return f.ID == id }) | |
| 109 | + | } | |
| 110 | + | ||
| 89 | 111 | // swipeSign turns finger movement into view movement: natural scroll moves | |
| 90 | 112 | // the content with the fingers. | |
| 91 | 113 | func (c *Core) swipeSign() float64 { | |
| @@ -110,7 +132,12 @@ func (c *Core) swipeBegin(b ports.SwipeBegin) bool { | |||
| 110 | 132 | // swipeUpdate moves the swipe; it reports whether the scene changed. | |
| 111 | 133 | func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |
| 112 | 134 | g := c.swipe | |
| 113 | - | if g == nil || !c.hasScreen(g.screen) { | |
| 135 | + | if g == nil { | |
| 136 | + | return false | |
| 137 | + | } | |
| 138 | + | if !c.hasScreen(g.screen) { | |
| 139 | + | // Its screen went: let go of what it moved, or that stays shifted. | |
| 140 | + | c.dropSwipe() | |
| 114 | 141 | c.swipe = nil | |
| 115 | 142 | return false | |
| 116 | 143 | } | |
| @@ -137,11 +164,11 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |||
| 137 | 164 | switch g.mode { | |
| 138 | 165 | case swipeColumns: | |
| 139 | 166 | if g.columnsChanged(m) { | |
| 140 | - | g.ws.stopSlide() | |
| 167 | + | g.ws.view.stop() | |
| 141 | 168 | g.mode = swipeDropped | |
| 142 | 169 | return true | |
| 143 | 170 | } | |
| 144 | - | g.ws.shift = g.snap.pos() - float64(g.ws.ViewX) | |
| 171 | + | g.ws.view.off = g.snap.pos() - float64(g.ws.ViewX) | |
| 145 | 172 | return true | |
| 146 | 173 | case swipeWorkspaces: | |
| 147 | 174 | if g.listChanged(m) { | |
| @@ -149,7 +176,15 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |||
| 149 | 176 | g.mode = swipeDropped | |
| 150 | 177 | return true | |
| 151 | 178 | } | |
| 152 | - | m.switchOff = g.snap.pos() - float64(m.Active) | |
| 179 | + | m.switchView.off = g.snap.pos() - float64(m.Active) | |
| 180 | + | return true | |
| 181 | + | case swipeStash: | |
| 182 | + | if g.stashChanged(m) { | |
| 183 | + | g.ws.stashView.stop() | |
| 184 | + | g.mode = swipeDropped | |
| 185 | + | return true | |
| 186 | + | } | |
| 187 | + | g.ws.stashView.off = g.snap.pos() - float64(g.ws.stashAt) | |
| 153 | 188 | return true | |
| 154 | 189 | } | |
| 155 | 190 | return false | |
| @@ -173,10 +208,18 @@ func (c *Core) decide(g *swipeGesture) { | |||
| 173 | 208 | // With animations off nothing slides either: the swipe runs a | |
| 174 | 209 | // focus action when the fingers lift. | |
| 175 | 210 | g.mode, g.snap = swipeDiscrete, newStepSwipe() | |
| 211 | + | case g.horizontal && w.stashFocused() && !w.floatFocus && w.stashShown(): | |
| 212 | + | g.mode, g.ws, g.home = swipeStash, w, w.stashAt | |
| 213 | + | g.ids = make([]WindowID, 0, len(w.Stash)) | |
| 214 | + | for _, f := range w.Stash { | |
| 215 | + | g.ids = append(g.ids, f.ID) | |
| 216 | + | } | |
| 217 | + | w.stashView.motion = motion{} | |
| 218 | + | g.snap = newSnapSwipe(float64(w.stashAt)+w.stashView.off, float64(w.stashAt), 1/stashSwipeMovement, indexPoints(len(w.Stash)), workspaceBand) | |
| 176 | 219 | case g.horizontal && w.slidable(): | |
| 177 | 220 | g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints() | |
| 178 | - | w.motion = motion{} | |
| 179 | - | g.snap = newSnapSwipe(float64(w.ViewX)+w.shift, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W))) | |
| 221 | + | w.view.motion = motion{} | |
| 222 | + | g.snap = newSnapSwipe(float64(w.ViewX)+w.view.off, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W))) | |
| 180 | 223 | case !g.horizontal && m.shown == nil: | |
| 181 | 224 | // A landing slide measured in an older list lands at once first. | |
| 182 | 225 | if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) { | |
| @@ -184,8 +227,8 @@ func (c *Core) decide(g *swipeGesture) { | |||
| 184 | 227 | } | |
| 185 | 228 | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | |
| 186 | 229 | g.list = slices.Clone(m.Workspaces) | |
| 187 | - | m.switchMotion, m.switchList = motion{}, nil | |
| 188 | - | g.snap = newSnapSwipe(float64(m.Active)+m.switchOff, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand) | |
| 230 | + | m.switchView.motion, m.switchList = motion{}, nil | |
| 231 | + | g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand) | |
| 189 | 232 | default: | |
| 190 | 233 | g.mode, g.snap = swipeDiscrete, newStepSwipe() | |
| 191 | 234 | } | |
| @@ -195,6 +238,9 @@ func (c *Core) decide(g *swipeGesture) { | |||
| 195 | 238 | // screen. | |
| 196 | 239 | func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |
| 197 | 240 | g := c.swipe | |
| 241 | + | if g != nil && !c.hasScreen(g.screen) { | |
| 242 | + | c.dropSwipe() | |
| 243 | + | } | |
| 198 | 244 | c.swipe = nil | |
| 199 | 245 | if g == nil || !c.hasScreen(g.screen) { | |
| 200 | 246 | return false | |
| @@ -205,10 +251,10 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 205 | 251 | case swipeColumns: | |
| 206 | 252 | w := g.ws | |
| 207 | 253 | if g.columnsChanged(m) { | |
| 208 | - | w.stopSlide() | |
| 254 | + | w.view.stop() | |
| 209 | 255 | return false | |
| 210 | 256 | } | |
| 211 | - | shown := float64(w.ViewX) + w.shift | |
| 257 | + | shown := float64(w.ViewX) + w.view.off | |
| 212 | 258 | target, velocity := g.snap.end(e.Cancelled, e.Time) | |
| 213 | 259 | if !e.Cancelled { | |
| 214 | 260 | view := int(math.Round(target)) | |
| @@ -219,18 +265,18 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 219 | 265 | c.focusScreen = c.screenIndex(g.screen.name()) | |
| 220 | 266 | c.keyboard.takeBack() | |
| 221 | 267 | } | |
| 222 | - | w.shift = shown - float64(w.ViewX) | |
| 268 | + | w.view.off = shown - float64(w.ViewX) | |
| 223 | 269 | if c.animOn() { | |
| 224 | - | w.motion = c.spring(viewSpring(w.shift, velocity), now) | |
| 270 | + | w.view.motion = c.spring(viewSpring(w.view.off, velocity), now) | |
| 225 | 271 | } else { | |
| 226 | - | w.stopSlide() | |
| 272 | + | w.view.stop() | |
| 227 | 273 | } | |
| 228 | 274 | case swipeWorkspaces: | |
| 229 | 275 | if g.listChanged(m) { | |
| 230 | 276 | m.stopSwitch() | |
| 231 | 277 | return false | |
| 232 | 278 | } | |
| 233 | - | cur := float64(m.Active) + m.switchOff | |
| 279 | + | cur := float64(m.Active) + m.switchView.off | |
| 234 | 280 | target, velocity := g.snap.end(e.Cancelled, e.Time) | |
| 235 | 281 | idx := m.Active | |
| 236 | 282 | if !e.Cancelled { | |
| @@ -247,12 +293,42 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 247 | 293 | c.focusScreen = c.screenIndex(g.screen.name()) | |
| 248 | 294 | c.keyboard.takeBack() | |
| 249 | 295 | } | |
| 250 | - | m.switchOff, m.switchList = off, g.list | |
| 296 | + | m.switchView.off, m.switchList = off, g.list | |
| 251 | 297 | if c.animOn() { | |
| 252 | - | m.switchMotion = c.spring(m.switchSpring(off, velocity), now) | |
| 298 | + | m.switchView.motion = c.spring(m.switchSpring(off, velocity), now) | |
| 253 | 299 | } else { | |
| 254 | 300 | m.stopSwitch() | |
| 255 | 301 | } | |
| 302 | + | case swipeStash: | |
| 303 | + | w := g.ws | |
| 304 | + | if g.stashChanged(m) { | |
| 305 | + | w.stashView.stop() | |
| 306 | + | return false | |
| 307 | + | } | |
| 308 | + | drawn := float64(w.stashAt) + w.stashView.off | |
| 309 | + | target, velocity := g.snap.end(e.Cancelled, e.Time) | |
| 310 | + | dir := 0 | |
| 311 | + | if !e.Cancelled { | |
| 312 | + | dir = int(math.Round(target)) - w.stashAt | |
| 313 | + | } | |
| 314 | + | if c.cur() == g.screen && dir != 0 { | |
| 315 | + | // The landing is the focus action's: the selection, the focus | |
| 316 | + | // and the veils move as a bind moves them. | |
| 317 | + | a := ActionFocusColumnRight | |
| 318 | + | if dir < 0 { | |
| 319 | + | a = ActionFocusColumnLeft | |
| 320 | + | } | |
| 321 | + | c.keyboard.takeBack() | |
| 322 | + | c.applyAction(a) | |
| 323 | + | } | |
| 324 | + | w.stashView.off = drawn - float64(w.stashAt) | |
| 325 | + | if c.animOn() { | |
| 326 | + | w.stashView.motion = c.spring(w.stashSpring(w.stashView.off, velocity), now) | |
| 327 | + | } else { | |
| 328 | + | w.stashView.stop() | |
| 329 | + | } | |
| 330 | + | // The stash never brings another workspace on screen. | |
| 331 | + | return false | |
| 256 | 332 | case swipeOverview: | |
| 257 | 333 | step := g.snap.step(e.Cancelled, e.Time) | |
| 258 | 334 | if step == 0 { | |
| @@ -340,9 +416,11 @@ func (c *Core) dropSwipe() { | |||
| 340 | 416 | } | |
| 341 | 417 | switch g.mode { | |
| 342 | 418 | case swipeColumns: | |
| 343 | - | g.ws.stopSlide() | |
| 419 | + | g.ws.view.stop() | |
| 344 | 420 | case swipeWorkspaces: | |
| 345 | 421 | g.screen.mon.stopSwitch() | |
| 422 | + | case swipeStash: | |
| 423 | + | g.ws.stashView.stop() | |
| 346 | 424 | } | |
| 347 | 425 | g.mode = swipeDropped | |
| 348 | 426 | } | |
| @@ -379,12 +457,15 @@ func (c *Core) stopAnimations() { | |||
| 379 | 457 | ||
| 380 | 458 | // stopAnimations settles the screen's running springs where they are going. | |
| 381 | 459 | func (s *screen) stopAnimations() { | |
| 382 | - | if s.mon.switchMotion.on { | |
| 460 | + | if s.mon.switchView.motion.on { | |
| 383 | 461 | s.mon.stopSwitch() | |
| 384 | 462 | } | |
| 385 | 463 | for w := range s.mon.all() { | |
| 386 | - | if w.motion.on { | |
| 387 | - | w.stopSlide() | |
| 464 | + | if w.view.motion.on { | |
| 465 | + | w.view.stop() | |
| 466 | + | } | |
| 467 | + | if w.stashView.motion.on { | |
| 468 | + | w.stashView.stop() | |
| 388 | 469 | } | |
| 389 | 470 | } | |
| 390 | 471 | s.stopRects() | |
| @@ -398,21 +479,15 @@ func (c *Core) animate(now time.Time, only *screen) { | |||
| 398 | 479 | continue | |
| 399 | 480 | } | |
| 400 | 481 | m := sc.mon | |
| 401 | - | if m.switchMotion.on { | |
| 402 | - | v, done := m.switchMotion.at(now) | |
| 403 | - | m.switchOff = v | |
| 404 | - | if done { | |
| 405 | - | m.stopSwitch() | |
| 482 | + | if m.switchView.motion.on { | |
| 483 | + | m.switchView.step(now) | |
| 484 | + | if !m.switchView.motion.on { | |
| 485 | + | m.switchList = nil | |
| 406 | 486 | } | |
| 407 | 487 | } | |
| 408 | 488 | for w := range m.all() { | |
| 409 | - | if w.motion.on { | |
| 410 | - | v, done := w.motion.at(now) | |
| 411 | - | w.shift = v | |
| 412 | - | if done { | |
| 413 | - | w.stopSlide() | |
| 414 | - | } | |
| 415 | - | } | |
| 489 | + | w.view.step(now) | |
| 490 | + | w.stashView.step(now) | |
| 416 | 491 | } | |
| 417 | 492 | for id, rm := range sc.rects { | |
| 418 | 493 | rm.advance(now) | |
| @@ -445,11 +520,11 @@ func (c *Core) animating() bool { | |||
| 445 | 520 | } | |
| 446 | 521 | ||
| 447 | 522 | func (m *Monitor) springing() bool { | |
| 448 | - | if m.switchMotion.on { | |
| 523 | + | if m.switchView.motion.on { | |
| 449 | 524 | return true | |
| 450 | 525 | } | |
| 451 | 526 | for w := range m.all() { | |
| 452 | - | if w.motion.on { | |
| 527 | + | if w.view.motion.on || w.stashView.motion.on { | |
| 453 | 528 | return true | |
| 454 | 529 | } | |
| 455 | 530 | } | |
| @@ -12,9 +12,11 @@ import ( | |||||
| 12 | // up or down it slides between workspaces. Each swipe moves one step at | 12 | // up or down it slides between workspaces. Each swipe moves one step at | ||
| 13 | // most (snapswipe.go): the next column edge or the next workspace. When | 13 | // most (snapswipe.go): the next column edge or the next workspace. When | ||
| 14 | // the fingers lift, a quick swipe goes on to that step, a slow one settles | 14 | // the fingers lift, a quick swipe goes on to that step, a slow one settles | ||
| 15 | - | // on the closest, and a spring takes the view there (motion.go). Where the | 15 | + | // on the closest, and a spring takes the view there (motion.go). Sideways |
| 16 | - | // view cannot slide (fixed overflow, the stash, a float, a fullscreen | 16 | + | // over the shown stash the swipe slides the stash the same way, one window |
| 17 | - | // window, the overview), the swipe runs one focus action instead. | 17 | + | // at most. Where the view cannot slide (fixed overflow, a float, a |
| 18 | + | // fullscreen window, the overview), the swipe runs one focus action | |||
| 19 | + | // instead. | |||
| 18 | 20 | ||||
| 19 | const ( | 21 | const ( | ||
| 20 | // swipeDecide is the touchpad distance before the swipe picks its axis | 22 | // swipeDecide is the touchpad distance before the swipe picks its axis | ||
| @@ -26,6 +28,9 @@ const ( | |||||
| 26 | // workspaceSwipeMovement is the touchpad distance that slides one | 28 | // workspaceSwipeMovement is the touchpad distance that slides one | ||
| 27 | // workspace. | 29 | // workspace. | ||
| 28 | workspaceSwipeMovement = 300.0 | 30 | workspaceSwipeMovement = 300.0 | ||
| 31 | + | // stashSwipeMovement is the touchpad distance that slides the stash by | |||
| 32 | + | // one window. | |||
| 33 | + | stashSwipeMovement = 300.0 | |||
| 29 | ) | 34 | ) | ||
| 30 | 35 | ||||
| 31 | type swipeMode uint8 | 36 | type swipeMode uint8 | ||
| @@ -40,6 +45,10 @@ const ( | |||||
| 40 | // the overview closed, down shows or hides the stash, and up hides | 45 | // the overview closed, down shows or hides the stash, and up hides | ||
| 41 | // it when it is shown, else opens the overview. | 46 | // it when it is shown, else opens the overview. | ||
| 42 | swipeOverview | 47 | swipeOverview | ||
| 48 | + | // swipeStash is a sideways swipe over the shown stash: its view | |||
| 49 | + | // follows the fingers, with a stop on each window, and lands on a | |||
| 50 | + | // neighbor of the selection at most. | |||
| 51 | + | swipeStash | |||
| 43 | // swipeDropped ignores the rest of a swipe whose workspace changed. | 52 | // swipeDropped ignores the rest of a swipe whose workspace changed. | ||
| 44 | swipeDropped | 53 | swipeDropped | ||
| 45 | ) | 54 | ) | ||
| @@ -66,6 +75,10 @@ type swipeGesture struct { | |||||
| 66 | // list is the numbered workspaces when a workspace slide began: the | 75 | // list is the numbered workspaces when a workspace slide began: the | ||
| 67 | // slide indexes it, so it ends if the list changes. | 76 | // slide indexes it, so it ends if the list changes. | ||
| 68 | list []*Workspace | 77 | list []*Workspace | ||
| 78 | + | // ids and home are the stash windows and the selection when a stash | |||
| 79 | + | // swipe began: it ends if either changes. | |||
| 80 | + | ids []WindowID | |||
| 81 | + | home int | |||
| 69 | // opens is the monitor's overviewOpens when the swipe picked its | 82 | // opens is the monitor's overviewOpens when the swipe picked its | ||
| 70 | // mode: a slide the overview opened over is dropped, even once the | 83 | // mode: a slide the overview opened over is dropped, even once the | ||
| 71 | // overview closed again. | 84 | // overview closed again. | ||
| @@ -86,6 +99,15 @@ func (g *swipeGesture) columnsChanged(m *Monitor) bool { | |||||
| 86 | return m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || !slices.Equal(g.ws.snapPoints(), g.points) | 99 | return m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || !slices.Equal(g.ws.snapPoints(), g.points) | ||
| 87 | } | 100 | } | ||
| 88 | 101 | ||||
| 102 | + | // stashChanged reports whether a stash swipe lost its stash: it is no | |||
| 103 | + | // longer shown or focused, its windows or selection changed, or the | |||
| 104 | + | // overview opened over it. | |||
| 105 | + | func (g *swipeGesture) stashChanged(m *Monitor) bool { | |||
| 106 | + | w := g.ws | |||
| 107 | + | return m.overviewOpens != g.opens || m.Current() != w || !w.stashFocused() || w.floatFocus || !w.stashShown() || w.stashAt != g.home || | |||
| 108 | + | !slices.EqualFunc(w.Stash, g.ids, func(f Float, id WindowID) bool { return f.ID == id }) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 89 | // swipeSign turns finger movement into view movement: natural scroll moves | 111 | // swipeSign turns finger movement into view movement: natural scroll moves | ||
| 90 | // the content with the fingers. | 112 | // the content with the fingers. | ||
| 91 | func (c *Core) swipeSign() float64 { | 113 | func (c *Core) swipeSign() float64 { | ||
| @@ -110,7 +132,12 @@ func (c *Core) swipeBegin(b ports.SwipeBegin) bool { | |||||
| 110 | // swipeUpdate moves the swipe; it reports whether the scene changed. | 132 | // swipeUpdate moves the swipe; it reports whether the scene changed. | ||
| 111 | func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | 133 | func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | ||
| 112 | g := c.swipe | 134 | g := c.swipe | ||
| 113 | - | if g == nil || !c.hasScreen(g.screen) { | 135 | + | if g == nil { |
| 136 | + | return false | |||
| 137 | + | } | |||
| 138 | + | if !c.hasScreen(g.screen) { | |||
| 139 | + | // Its screen went: let go of what it moved, or that stays shifted. | |||
| 140 | + | c.dropSwipe() | |||
| 114 | c.swipe = nil | 141 | c.swipe = nil | ||
| 115 | return false | 142 | return false | ||
| 116 | } | 143 | } | ||
| @@ -137,11 +164,11 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |||||
| 137 | switch g.mode { | 164 | switch g.mode { | ||
| 138 | case swipeColumns: | 165 | case swipeColumns: | ||
| 139 | if g.columnsChanged(m) { | 166 | if g.columnsChanged(m) { | ||
| 140 | - | g.ws.stopSlide() | 167 | + | g.ws.view.stop() |
| 141 | g.mode = swipeDropped | 168 | g.mode = swipeDropped | ||
| 142 | return true | 169 | return true | ||
| 143 | } | 170 | } | ||
| 144 | - | g.ws.shift = g.snap.pos() - float64(g.ws.ViewX) | 171 | + | g.ws.view.off = g.snap.pos() - float64(g.ws.ViewX) |
| 145 | return true | 172 | return true | ||
| 146 | case swipeWorkspaces: | 173 | case swipeWorkspaces: | ||
| 147 | if g.listChanged(m) { | 174 | if g.listChanged(m) { | ||
| @@ -149,7 +176,15 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |||||
| 149 | g.mode = swipeDropped | 176 | g.mode = swipeDropped | ||
| 150 | return true | 177 | return true | ||
| 151 | } | 178 | } | ||
| 152 | - | m.switchOff = g.snap.pos() - float64(m.Active) | 179 | + | m.switchView.off = g.snap.pos() - float64(m.Active) |
| 180 | + | return true | |||
| 181 | + | case swipeStash: | |||
| 182 | + | if g.stashChanged(m) { | |||
| 183 | + | g.ws.stashView.stop() | |||
| 184 | + | g.mode = swipeDropped | |||
| 185 | + | return true | |||
| 186 | + | } | |||
| 187 | + | g.ws.stashView.off = g.snap.pos() - float64(g.ws.stashAt) | |||
| 153 | return true | 188 | return true | ||
| 154 | } | 189 | } | ||
| 155 | return false | 190 | return false | ||
| @@ -173,10 +208,18 @@ func (c *Core) decide(g *swipeGesture) { | |||||
| 173 | // With animations off nothing slides either: the swipe runs a | 208 | // With animations off nothing slides either: the swipe runs a | ||
| 174 | // focus action when the fingers lift. | 209 | // focus action when the fingers lift. | ||
| 175 | g.mode, g.snap = swipeDiscrete, newStepSwipe() | 210 | g.mode, g.snap = swipeDiscrete, newStepSwipe() | ||
| 211 | + | case g.horizontal && w.stashFocused() && !w.floatFocus && w.stashShown(): | |||
| 212 | + | g.mode, g.ws, g.home = swipeStash, w, w.stashAt | |||
| 213 | + | g.ids = make([]WindowID, 0, len(w.Stash)) | |||
| 214 | + | for _, f := range w.Stash { | |||
| 215 | + | g.ids = append(g.ids, f.ID) | |||
| 216 | + | } | |||
| 217 | + | w.stashView.motion = motion{} | |||
| 218 | + | g.snap = newSnapSwipe(float64(w.stashAt)+w.stashView.off, float64(w.stashAt), 1/stashSwipeMovement, indexPoints(len(w.Stash)), workspaceBand) | |||
| 176 | case g.horizontal && w.slidable(): | 219 | case g.horizontal && w.slidable(): | ||
| 177 | g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints() | 220 | g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints() | ||
| 178 | - | w.motion = motion{} | 221 | + | w.view.motion = motion{} |
| 179 | - | g.snap = newSnapSwipe(float64(w.ViewX)+w.shift, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W))) | 222 | + | g.snap = newSnapSwipe(float64(w.ViewX)+w.view.off, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W))) |
| 180 | case !g.horizontal && m.shown == nil: | 223 | case !g.horizontal && m.shown == nil: | ||
| 181 | // A landing slide measured in an older list lands at once first. | 224 | // A landing slide measured in an older list lands at once first. | ||
| 182 | if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) { | 225 | if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) { | ||
| @@ -184,8 +227,8 @@ func (c *Core) decide(g *swipeGesture) { | |||||
| 184 | } | 227 | } | ||
| 185 | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | 228 | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | ||
| 186 | g.list = slices.Clone(m.Workspaces) | 229 | g.list = slices.Clone(m.Workspaces) | ||
| 187 | - | m.switchMotion, m.switchList = motion{}, nil | 230 | + | m.switchView.motion, m.switchList = motion{}, nil |
| 188 | - | g.snap = newSnapSwipe(float64(m.Active)+m.switchOff, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand) | 231 | + | g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand) |
| 189 | default: | 232 | default: | ||
| 190 | g.mode, g.snap = swipeDiscrete, newStepSwipe() | 233 | g.mode, g.snap = swipeDiscrete, newStepSwipe() | ||
| 191 | } | 234 | } | ||
| @@ -195,6 +238,9 @@ func (c *Core) decide(g *swipeGesture) { | |||||
| 195 | // screen. | 238 | // screen. | ||
| 196 | func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | 239 | func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | ||
| 197 | g := c.swipe | 240 | g := c.swipe | ||
| 241 | + | if g != nil && !c.hasScreen(g.screen) { | |||
| 242 | + | c.dropSwipe() | |||
| 243 | + | } | |||
| 198 | c.swipe = nil | 244 | c.swipe = nil | ||
| 199 | if g == nil || !c.hasScreen(g.screen) { | 245 | if g == nil || !c.hasScreen(g.screen) { | ||
| 200 | return false | 246 | return false | ||
| @@ -205,10 +251,10 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 205 | case swipeColumns: | 251 | case swipeColumns: | ||
| 206 | w := g.ws | 252 | w := g.ws | ||
| 207 | if g.columnsChanged(m) { | 253 | if g.columnsChanged(m) { | ||
| 208 | - | w.stopSlide() | 254 | + | w.view.stop() |
| 209 | return false | 255 | return false | ||
| 210 | } | 256 | } | ||
| 211 | - | shown := float64(w.ViewX) + w.shift | 257 | + | shown := float64(w.ViewX) + w.view.off |
| 212 | target, velocity := g.snap.end(e.Cancelled, e.Time) | 258 | target, velocity := g.snap.end(e.Cancelled, e.Time) | ||
| 213 | if !e.Cancelled { | 259 | if !e.Cancelled { | ||
| 214 | view := int(math.Round(target)) | 260 | view := int(math.Round(target)) | ||
| @@ -219,18 +265,18 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 219 | c.focusScreen = c.screenIndex(g.screen.name()) | 265 | c.focusScreen = c.screenIndex(g.screen.name()) | ||
| 220 | c.keyboard.takeBack() | 266 | c.keyboard.takeBack() | ||
| 221 | } | 267 | } | ||
| 222 | - | w.shift = shown - float64(w.ViewX) | 268 | + | w.view.off = shown - float64(w.ViewX) |
| 223 | if c.animOn() { | 269 | if c.animOn() { | ||
| 224 | - | w.motion = c.spring(viewSpring(w.shift, velocity), now) | 270 | + | w.view.motion = c.spring(viewSpring(w.view.off, velocity), now) |
| 225 | } else { | 271 | } else { | ||
| 226 | - | w.stopSlide() | 272 | + | w.view.stop() |
| 227 | } | 273 | } | ||
| 228 | case swipeWorkspaces: | 274 | case swipeWorkspaces: | ||
| 229 | if g.listChanged(m) { | 275 | if g.listChanged(m) { | ||
| 230 | m.stopSwitch() | 276 | m.stopSwitch() | ||
| 231 | return false | 277 | return false | ||
| 232 | } | 278 | } | ||
| 233 | - | cur := float64(m.Active) + m.switchOff | 279 | + | cur := float64(m.Active) + m.switchView.off |
| 234 | target, velocity := g.snap.end(e.Cancelled, e.Time) | 280 | target, velocity := g.snap.end(e.Cancelled, e.Time) | ||
| 235 | idx := m.Active | 281 | idx := m.Active | ||
| 236 | if !e.Cancelled { | 282 | if !e.Cancelled { | ||
| @@ -247,12 +293,42 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 247 | c.focusScreen = c.screenIndex(g.screen.name()) | 293 | c.focusScreen = c.screenIndex(g.screen.name()) | ||
| 248 | c.keyboard.takeBack() | 294 | c.keyboard.takeBack() | ||
| 249 | } | 295 | } | ||
| 250 | - | m.switchOff, m.switchList = off, g.list | 296 | + | m.switchView.off, m.switchList = off, g.list |
| 251 | if c.animOn() { | 297 | if c.animOn() { | ||
| 252 | - | m.switchMotion = c.spring(m.switchSpring(off, velocity), now) | 298 | + | m.switchView.motion = c.spring(m.switchSpring(off, velocity), now) |
| 253 | } else { | 299 | } else { | ||
| 254 | m.stopSwitch() | 300 | m.stopSwitch() | ||
| 255 | } | 301 | } | ||
| 302 | + | case swipeStash: | |||
| 303 | + | w := g.ws | |||
| 304 | + | if g.stashChanged(m) { | |||
| 305 | + | w.stashView.stop() | |||
| 306 | + | return false | |||
| 307 | + | } | |||
| 308 | + | drawn := float64(w.stashAt) + w.stashView.off | |||
| 309 | + | target, velocity := g.snap.end(e.Cancelled, e.Time) | |||
| 310 | + | dir := 0 | |||
| 311 | + | if !e.Cancelled { | |||
| 312 | + | dir = int(math.Round(target)) - w.stashAt | |||
| 313 | + | } | |||
| 314 | + | if c.cur() == g.screen && dir != 0 { | |||
| 315 | + | // The landing is the focus action's: the selection, the focus | |||
| 316 | + | // and the veils move as a bind moves them. | |||
| 317 | + | a := ActionFocusColumnRight | |||
| 318 | + | if dir < 0 { | |||
| 319 | + | a = ActionFocusColumnLeft | |||
| 320 | + | } | |||
| 321 | + | c.keyboard.takeBack() | |||
| 322 | + | c.applyAction(a) | |||
| 323 | + | } | |||
| 324 | + | w.stashView.off = drawn - float64(w.stashAt) | |||
| 325 | + | if c.animOn() { | |||
| 326 | + | w.stashView.motion = c.spring(w.stashSpring(w.stashView.off, velocity), now) | |||
| 327 | + | } else { | |||
| 328 | + | w.stashView.stop() | |||
| 329 | + | } | |||
| 330 | + | // The stash never brings another workspace on screen. | |||
| 331 | + | return false | |||
| 256 | case swipeOverview: | 332 | case swipeOverview: | ||
| 257 | step := g.snap.step(e.Cancelled, e.Time) | 333 | step := g.snap.step(e.Cancelled, e.Time) | ||
| 258 | if step == 0 { | 334 | if step == 0 { | ||
| @@ -340,9 +416,11 @@ func (c *Core) dropSwipe() { | |||||
| 340 | } | 416 | } | ||
| 341 | switch g.mode { | 417 | switch g.mode { | ||
| 342 | case swipeColumns: | 418 | case swipeColumns: | ||
| 343 | - | g.ws.stopSlide() | 419 | + | g.ws.view.stop() |
| 344 | case swipeWorkspaces: | 420 | case swipeWorkspaces: | ||
| 345 | g.screen.mon.stopSwitch() | 421 | g.screen.mon.stopSwitch() | ||
| 422 | + | case swipeStash: | |||
| 423 | + | g.ws.stashView.stop() | |||
| 346 | } | 424 | } | ||
| 347 | g.mode = swipeDropped | 425 | g.mode = swipeDropped | ||
| 348 | } | 426 | } | ||
| @@ -379,12 +457,15 @@ func (c *Core) stopAnimations() { | |||||
| 379 | 457 | ||||
| 380 | // stopAnimations settles the screen's running springs where they are going. | 458 | // stopAnimations settles the screen's running springs where they are going. | ||
| 381 | func (s *screen) stopAnimations() { | 459 | func (s *screen) stopAnimations() { | ||
| 382 | - | if s.mon.switchMotion.on { | 460 | + | if s.mon.switchView.motion.on { |
| 383 | s.mon.stopSwitch() | 461 | s.mon.stopSwitch() | ||
| 384 | } | 462 | } | ||
| 385 | for w := range s.mon.all() { | 463 | for w := range s.mon.all() { | ||
| 386 | - | if w.motion.on { | 464 | + | if w.view.motion.on { |
| 387 | - | w.stopSlide() | 465 | + | w.view.stop() |
| 466 | + | } | |||
| 467 | + | if w.stashView.motion.on { | |||
| 468 | + | w.stashView.stop() | |||
| 388 | } | 469 | } | ||
| 389 | } | 470 | } | ||
| 390 | s.stopRects() | 471 | s.stopRects() | ||
| @@ -398,21 +479,15 @@ func (c *Core) animate(now time.Time, only *screen) { | |||||
| 398 | continue | 479 | continue | ||
| 399 | } | 480 | } | ||
| 400 | m := sc.mon | 481 | m := sc.mon | ||
| 401 | - | if m.switchMotion.on { | 482 | + | if m.switchView.motion.on { |
| 402 | - | v, done := m.switchMotion.at(now) | 483 | + | m.switchView.step(now) |
| 403 | - | m.switchOff = v | 484 | + | if !m.switchView.motion.on { |
| 404 | - | if done { | 485 | + | m.switchList = nil |
| 405 | - | m.stopSwitch() | |||
| 406 | } | 486 | } | ||
| 407 | } | 487 | } | ||
| 408 | for w := range m.all() { | 488 | for w := range m.all() { | ||
| 409 | - | if w.motion.on { | 489 | + | w.view.step(now) |
| 410 | - | v, done := w.motion.at(now) | 490 | + | w.stashView.step(now) |
| 411 | - | w.shift = v | |||
| 412 | - | if done { | |||
| 413 | - | w.stopSlide() | |||
| 414 | - | } | |||
| 415 | - | } | |||
| 416 | } | 491 | } | ||
| 417 | for id, rm := range sc.rects { | 492 | for id, rm := range sc.rects { | ||
| 418 | rm.advance(now) | 493 | rm.advance(now) | ||
| @@ -445,11 +520,11 @@ func (c *Core) animating() bool { | |||||
| 445 | } | 520 | } | ||
| 446 | 521 | ||||
| 447 | func (m *Monitor) springing() bool { | 522 | func (m *Monitor) springing() bool { | ||
| 448 | - | if m.switchMotion.on { | 523 | + | if m.switchView.motion.on { |
| 449 | return true | 524 | return true | ||
| 450 | } | 525 | } | ||
| 451 | for w := range m.all() { | 526 | for w := range m.all() { | ||
| 452 | - | if w.motion.on { | 527 | + | if w.view.motion.on || w.stashView.motion.on { |
| 453 | return true | 528 | return true | ||
| 454 | } | 529 | } | ||
| 455 | } | 530 | } | ||
M internal/core/layout.go +16 -7
| @@ -115,6 +115,10 @@ type Placement struct { | |||
| 115 | 115 | // Peek is a stashed window peeking in beside the selected one, or an | |
| 116 | 116 | // overview preview of a neighbor workspace: dimmed. | |
| 117 | 117 | Peek bool | |
| 118 | + | // Veil is the share (0..1) of the peek veil (Stash.Dim) the window | |
| 119 | + | // draws: 1 for a settled peek, less while a stash slide brings it to | |
| 120 | + | // the centre. Set wherever Peek is, and on a sliding stash selection. | |
| 121 | + | Veil float64 | |
| 118 | 122 | // Preview is the scale of an overview preview: its buffer is drawn | |
| 119 | 123 | // that much smaller in Rect, the client keeps its size. 0 otherwise. | |
| 120 | 124 | Preview float64 | |
| @@ -128,7 +132,7 @@ type Placement struct { | |||
| 128 | 132 | Leaving bool | |
| 129 | 133 | // Fade, Dim and Zoom are set on shown layouts only (refreshShown), | |
| 130 | 134 | // 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 | |
| 135 | + | // added to the one Veil draws (0..1), and the content zoom as drawn | |
| 132 | 136 | // when a scale motion runs (Preview times the drawn/settled width, 1 | |
| 133 | 137 | // when the content is drawn at its size; 0 without one). Preview | |
| 134 | 138 | // itself stays the settled one: input, popups and the renderer's paint | |
| @@ -136,6 +140,9 @@ type Placement struct { | |||
| 136 | 140 | Fade, Dim, Zoom float64 | |
| 137 | 141 | } | |
| 138 | 142 | ||
| 143 | + | // peeking marks p a peek drawing share of the peek veil (Stash.Dim). | |
| 144 | + | func (p *Placement) peeking(veil float64) { p.Peek, p.Veil = true, veil } | |
| 145 | + | ||
| 139 | 146 | // Overflow says what happens past MaxColumns columns. | |
| 140 | 147 | type Overflow string | |
| 141 | 148 | ||
| @@ -163,10 +170,8 @@ type Workspace struct { | |||
| 163 | 170 | maximized []WindowID | |
| 164 | 171 | Focus int | |
| 165 | 172 | ViewX int | |
| 166 | - | // shift slides the columns on screen past ViewX, in logical pixels, | |
| 167 | - | // while a swipe follows the fingers or its spring (motion) lands. | |
| 168 | - | shift float64 | |
| 169 | - | motion motion | |
| 173 | + | // view slides the columns on screen past ViewX, in logical pixels. | |
| 174 | + | view slide | |
| 170 | 175 | // Output is the effective viewport in monitor coordinates: the whole | |
| 171 | 176 | // monitor unless the workspace has a size override, then a centered | |
| 172 | 177 | // rectangle no larger than the monitor. Fullscreen fills it. | |
| @@ -194,8 +199,12 @@ type Workspace struct { | |||
| 194 | 199 | // Stash holds the windows set aside by toggle-window-stash, left to | |
| 195 | 200 | // right (stash.go); stashAt is the selected one. stashFocus is set | |
| 196 | 201 | // while it has the focus, under a focused native float. | |
| 197 | - | Stash []Float | |
| 198 | - | stashAt int | |
| 202 | + | Stash []Float | |
| 203 | + | stashAt int | |
| 204 | + | // stashView slides the stash: the selected window is drawn at | |
| 205 | + | // stashAt+stashView.off, in stash windows. Pure presentation over | |
| 206 | + | // stashAt. | |
| 207 | + | stashView slide | |
| 199 | 208 | stashFocus bool | |
| 200 | 209 | stashHidden bool | |
| 201 | 210 | // stashWidth is the width of a stashed window (0: the default) and | |
| @@ -115,6 +115,10 @@ type Placement struct { | |||||
| 115 | // Peek is a stashed window peeking in beside the selected one, or an | 115 | // Peek is a stashed window peeking in beside the selected one, or an | ||
| 116 | // overview preview of a neighbor workspace: dimmed. | 116 | // overview preview of a neighbor workspace: dimmed. | ||
| 117 | Peek bool | 117 | Peek bool | ||
| 118 | + | // Veil is the share (0..1) of the peek veil (Stash.Dim) the window | |||
| 119 | + | // draws: 1 for a settled peek, less while a stash slide brings it to | |||
| 120 | + | // the centre. Set wherever Peek is, and on a sliding stash selection. | |||
| 121 | + | Veil float64 | |||
| 118 | // Preview is the scale of an overview preview: its buffer is drawn | 122 | // Preview is the scale of an overview preview: its buffer is drawn | ||
| 119 | // that much smaller in Rect, the client keeps its size. 0 otherwise. | 123 | // that much smaller in Rect, the client keeps its size. 0 otherwise. | ||
| 120 | Preview float64 | 124 | Preview float64 | ||
| @@ -128,7 +132,7 @@ type Placement struct { | |||||
| 128 | Leaving bool | 132 | Leaving bool | ||
| 129 | // Fade, Dim and Zoom are set on shown layouts only (refreshShown), | 133 | // Fade, Dim and Zoom are set on shown layouts only (refreshShown), | ||
| 130 | // animated: the window's fade (0 opaque, 1 invisible), a veil opacity | 134 | // 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 | 135 | + | // added to the one Veil draws (0..1), and the content zoom as drawn |
| 132 | // when a scale motion runs (Preview times the drawn/settled width, 1 | 136 | // 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 | 137 | // 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 | 138 | // itself stays the settled one: input, popups and the renderer's paint | ||
| @@ -136,6 +140,9 @@ type Placement struct { | |||||
| 136 | Fade, Dim, Zoom float64 | 140 | Fade, Dim, Zoom float64 | ||
| 137 | } | 141 | } | ||
| 138 | 142 | ||||
| 143 | + | // peeking marks p a peek drawing share of the peek veil (Stash.Dim). | |||
| 144 | + | func (p *Placement) peeking(veil float64) { p.Peek, p.Veil = true, veil } | |||
| 145 | + | ||||
| 139 | // Overflow says what happens past MaxColumns columns. | 146 | // Overflow says what happens past MaxColumns columns. | ||
| 140 | type Overflow string | 147 | type Overflow string | ||
| 141 | 148 | ||||
| @@ -163,10 +170,8 @@ type Workspace struct { | |||||
| 163 | maximized []WindowID | 170 | maximized []WindowID | ||
| 164 | Focus int | 171 | Focus int | ||
| 165 | ViewX int | 172 | ViewX int | ||
| 166 | - | // shift slides the columns on screen past ViewX, in logical pixels, | 173 | + | // view slides the columns on screen past ViewX, in logical pixels. |
| 167 | - | // while a swipe follows the fingers or its spring (motion) lands. | 174 | + | view slide |
| 168 | - | shift float64 | |||
| 169 | - | motion motion | |||
| 170 | // Output is the effective viewport in monitor coordinates: the whole | 175 | // Output is the effective viewport in monitor coordinates: the whole | ||
| 171 | // monitor unless the workspace has a size override, then a centered | 176 | // monitor unless the workspace has a size override, then a centered | ||
| 172 | // rectangle no larger than the monitor. Fullscreen fills it. | 177 | // rectangle no larger than the monitor. Fullscreen fills it. | ||
| @@ -194,8 +199,12 @@ type Workspace struct { | |||||
| 194 | // Stash holds the windows set aside by toggle-window-stash, left to | 199 | // Stash holds the windows set aside by toggle-window-stash, left to | ||
| 195 | // right (stash.go); stashAt is the selected one. stashFocus is set | 200 | // right (stash.go); stashAt is the selected one. stashFocus is set | ||
| 196 | // while it has the focus, under a focused native float. | 201 | // while it has the focus, under a focused native float. | ||
| 197 | - | Stash []Float | 202 | + | Stash []Float |
| 198 | - | stashAt int | 203 | + | stashAt int |
| 204 | + | // stashView slides the stash: the selected window is drawn at | |||
| 205 | + | // stashAt+stashView.off, in stash windows. Pure presentation over | |||
| 206 | + | // stashAt. | |||
| 207 | + | stashView slide | |||
| 199 | stashFocus bool | 208 | stashFocus bool | ||
| 200 | stashHidden bool | 209 | stashHidden bool | ||
| 201 | // stashWidth is the width of a stashed window (0: the default) and | 210 | // stashWidth is the width of a stashed window (0: the default) and | ||
M internal/core/layout_buffers_test.go +6 -6
| @@ -72,7 +72,7 @@ func layoutVariants(t *testing.T) map[string]*Monitor { | |||
| 72 | 72 | m.Apply(ActionFocusWorkspaceDown) | |
| 73 | 73 | m.AddWindow(20) | |
| 74 | 74 | m.Apply(ActionFocusWorkspaceUp) | |
| 75 | - | m.switchOff = 0.4 | |
| 75 | + | m.switchView.off = 0.4 | |
| 76 | 76 | v["slide"] = m | |
| 77 | 77 | ||
| 78 | 78 | // The variants must hold what they are named for, on the workspace on | |
| @@ -182,9 +182,9 @@ func TestLayoutResultsDoNotAlias(t *testing.T) { | |||
| 182 | 182 | } | |
| 183 | 183 | _ = m.Layout() | |
| 184 | 184 | _ = m.Current().Layout() | |
| 185 | - | m.switchOff = 0.3 | |
| 185 | + | m.switchView.off = 0.3 | |
| 186 | 186 | _ = m.Layout() | |
| 187 | - | m.switchOff = 0 | |
| 187 | + | m.switchView.off = 0 | |
| 188 | 188 | ||
| 189 | 189 | if !slices.Equal(first, kept) { | |
| 190 | 190 | t.Fatalf("%s: a retained Monitor.Layout() changed\n got %+v\nwant %+v", name, first, kept) | |
| @@ -250,7 +250,7 @@ func TestPublishedScenesAreNeverMutated(t *testing.T) { | |||
| 250 | 250 | var e []expect | |
| 251 | 251 | for _, sc := range c.screens { | |
| 252 | 252 | if sc.name() != "" { | |
| 253 | - | e = append(e, expect{sc.name(), sc.mon.Layout(), len(sc.rects) == 0 && !sc.mon.Current().motion.on}) | |
| 253 | + | e = append(e, expect{sc.name(), sc.mon.Layout(), len(sc.rects) == 0 && !sc.mon.Current().view.motion.on}) | |
| 254 | 254 | } | |
| 255 | 255 | } | |
| 256 | 256 | return e | |
| @@ -299,8 +299,8 @@ func TestPublishedScenesAreNeverMutated(t *testing.T) { | |||
| 299 | 299 | continue | |
| 300 | 300 | } | |
| 301 | 301 | ws := sc.mon.Current() | |
| 302 | - | ws.motion = c.spring(viewSpring(100, 0), t0) | |
| 303 | - | ws.shift = 100 | |
| 302 | + | ws.view.motion = c.spring(viewSpring(100, 0), t0) | |
| 303 | + | ws.view.off = 100 | |
| 304 | 304 | startRectMotions(c, sc, t0) | |
| 305 | 305 | } | |
| 306 | 306 | for range 12 { | |
| @@ -72,7 +72,7 @@ func layoutVariants(t *testing.T) map[string]*Monitor { | |||||
| 72 | m.Apply(ActionFocusWorkspaceDown) | 72 | m.Apply(ActionFocusWorkspaceDown) | ||
| 73 | m.AddWindow(20) | 73 | m.AddWindow(20) | ||
| 74 | m.Apply(ActionFocusWorkspaceUp) | 74 | m.Apply(ActionFocusWorkspaceUp) | ||
| 75 | - | m.switchOff = 0.4 | 75 | + | m.switchView.off = 0.4 |
| 76 | v["slide"] = m | 76 | v["slide"] = m | ||
| 77 | 77 | ||||
| 78 | // The variants must hold what they are named for, on the workspace on | 78 | // The variants must hold what they are named for, on the workspace on | ||
| @@ -182,9 +182,9 @@ func TestLayoutResultsDoNotAlias(t *testing.T) { | |||||
| 182 | } | 182 | } | ||
| 183 | _ = m.Layout() | 183 | _ = m.Layout() | ||
| 184 | _ = m.Current().Layout() | 184 | _ = m.Current().Layout() | ||
| 185 | - | m.switchOff = 0.3 | 185 | + | m.switchView.off = 0.3 |
| 186 | _ = m.Layout() | 186 | _ = m.Layout() | ||
| 187 | - | m.switchOff = 0 | 187 | + | m.switchView.off = 0 |
| 188 | 188 | ||||
| 189 | if !slices.Equal(first, kept) { | 189 | if !slices.Equal(first, kept) { | ||
| 190 | t.Fatalf("%s: a retained Monitor.Layout() changed\n got %+v\nwant %+v", name, first, kept) | 190 | t.Fatalf("%s: a retained Monitor.Layout() changed\n got %+v\nwant %+v", name, first, kept) | ||
| @@ -250,7 +250,7 @@ func TestPublishedScenesAreNeverMutated(t *testing.T) { | |||||
| 250 | var e []expect | 250 | var e []expect | ||
| 251 | for _, sc := range c.screens { | 251 | for _, sc := range c.screens { | ||
| 252 | if sc.name() != "" { | 252 | if sc.name() != "" { | ||
| 253 | - | e = append(e, expect{sc.name(), sc.mon.Layout(), len(sc.rects) == 0 && !sc.mon.Current().motion.on}) | 253 | + | e = append(e, expect{sc.name(), sc.mon.Layout(), len(sc.rects) == 0 && !sc.mon.Current().view.motion.on}) |
| 254 | } | 254 | } | ||
| 255 | } | 255 | } | ||
| 256 | return e | 256 | return e | ||
| @@ -299,8 +299,8 @@ func TestPublishedScenesAreNeverMutated(t *testing.T) { | |||||
| 299 | continue | 299 | continue | ||
| 300 | } | 300 | } | ||
| 301 | ws := sc.mon.Current() | 301 | ws := sc.mon.Current() | ||
| 302 | - | ws.motion = c.spring(viewSpring(100, 0), t0) | 302 | + | ws.view.motion = c.spring(viewSpring(100, 0), t0) |
| 303 | - | ws.shift = 100 | 303 | + | ws.view.off = 100 |
| 304 | startRectMotions(c, sc, t0) | 304 | startRectMotions(c, sc, t0) | ||
| 305 | } | 305 | } | ||
| 306 | for range 12 { | 306 | for range 12 { | ||
M internal/core/monitor.go +4 -6
| @@ -34,13 +34,11 @@ type Monitor struct { | |||
| 34 | 34 | followMove bool | |
| 35 | 35 | // stashCapture puts new windows in the shown stash (stash.capture). | |
| 36 | 36 | stashCapture bool | |
| 37 | - | // switchOff slides the view between numbered workspaces: the view is | |
| 38 | - | // at Active+switchOff while a swipe follows the fingers or its spring | |
| 39 | - | // (switchMotion) lands. | |
| 40 | - | switchOff float64 | |
| 41 | - | switchMotion motion | |
| 37 | + | // switchView slides the view between numbered workspaces: the view is | |
| 38 | + | // at Active+switchView.off, in workspaces. | |
| 39 | + | switchView slide | |
| 42 | 40 | // switchList is the numbered list a landing slide measures from (the | |
| 43 | - | // one its swipe began on); switchOff is then from the current | |
| 41 | + | // one its swipe began on); switchView.off is then from the current | |
| 44 | 42 | // workspace's place in it. | |
| 45 | 43 | switchList []*Workspace | |
| 46 | 44 | // ov holds the overview selection and its Escape snapshot. | |
| @@ -34,13 +34,11 @@ type Monitor struct { | |||||
| 34 | followMove bool | 34 | followMove bool | ||
| 35 | // stashCapture puts new windows in the shown stash (stash.capture). | 35 | // stashCapture puts new windows in the shown stash (stash.capture). | ||
| 36 | stashCapture bool | 36 | stashCapture bool | ||
| 37 | - | // switchOff slides the view between numbered workspaces: the view is | 37 | + | // switchView slides the view between numbered workspaces: the view is |
| 38 | - | // at Active+switchOff while a swipe follows the fingers or its spring | 38 | + | // at Active+switchView.off, in workspaces. |
| 39 | - | // (switchMotion) lands. | 39 | + | switchView slide |
| 40 | - | switchOff float64 | |||
| 41 | - | switchMotion motion | |||
| 42 | // switchList is the numbered list a landing slide measures from (the | 40 | // switchList is the numbered list a landing slide measures from (the | ||
| 43 | - | // one its swipe began on); switchOff is then from the current | 41 | + | // one its swipe began on); switchView.off is then from the current |
| 44 | // workspace's place in it. | 42 | // workspace's place in it. | ||
| 45 | switchList []*Workspace | 43 | switchList []*Workspace | ||
| 46 | // ov holds the overview selection and its Escape snapshot. | 44 | // ov holds the overview selection and its Escape snapshot. | ||
M internal/core/motion_test.go +9 -9
| @@ -433,28 +433,28 @@ func TestMotionVelocity(t *testing.T) { | |||
| 433 | 433 | ||
| 434 | 434 | func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) { | |
| 435 | 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() | |
| 436 | + | w := &Workspace{ViewX: 100, view: slide{off: -300}} | |
| 437 | + | w.view.motion = newMotion(viewSpring(w.view.off, 2000), start, 2) | |
| 438 | + | w.view.motion.at(start.Add(20 * time.Millisecond)) | |
| 439 | + | v := w.view.motion.velocity() | |
| 440 | 440 | if v <= 0 { | |
| 441 | 441 | t.Fatalf("velocity %v", v) | |
| 442 | 442 | } | |
| 443 | 443 | w.ViewX = 40 | |
| 444 | 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) | |
| 445 | + | if w.view.off != -240 || !w.view.motion.on || w.view.motion.slow != 2 { | |
| 446 | + | t.Fatalf("shift %v motion %+v", w.view.off, w.view.motion) | |
| 447 | 447 | } | |
| 448 | - | if got := w.motion.velocity(); got != v { | |
| 448 | + | if got := w.view.motion.velocity(); got != v { | |
| 449 | 449 | t.Fatalf("velocity %v after retarget, want %v", got, v) | |
| 450 | 450 | } | |
| 451 | - | if got := w.motion.spring.Velocity; math.Abs(got-2*v) > 1e-9 { | |
| 451 | + | if got := w.view.motion.spring.Velocity; math.Abs(got-2*v) > 1e-9 { | |
| 452 | 452 | t.Fatalf("spring velocity %v, want %v in spring time", got, 2*v) | |
| 453 | 453 | } | |
| 454 | 454 | var idle Workspace | |
| 455 | 455 | idle.ViewX = 5 | |
| 456 | 456 | idle.retarget(0) | |
| 457 | - | if idle.motion.on || idle.shift != 0 { | |
| 457 | + | if idle.view.motion.on || idle.view.off != 0 { | |
| 458 | 458 | t.Fatal("retarget without a slide must not start one") | |
| 459 | 459 | } | |
| 460 | 460 | } | |
| @@ -433,28 +433,28 @@ func TestMotionVelocity(t *testing.T) { | |||||
| 433 | 433 | ||||
| 434 | func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) { | 434 | func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) { | ||
| 435 | start := time.Unix(10, 0) | 435 | start := time.Unix(10, 0) | ||
| 436 | - | w := &Workspace{ViewX: 100, shift: -300} | 436 | + | w := &Workspace{ViewX: 100, view: slide{off: -300}} |
| 437 | - | w.motion = newMotion(viewSpring(w.shift, 2000), start, 2) | 437 | + | w.view.motion = newMotion(viewSpring(w.view.off, 2000), start, 2) |
| 438 | - | w.motion.at(start.Add(20 * time.Millisecond)) | 438 | + | w.view.motion.at(start.Add(20 * time.Millisecond)) |
| 439 | - | v := w.motion.velocity() | 439 | + | v := w.view.motion.velocity() |
| 440 | if v <= 0 { | 440 | if v <= 0 { | ||
| 441 | t.Fatalf("velocity %v", v) | 441 | t.Fatalf("velocity %v", v) | ||
| 442 | } | 442 | } | ||
| 443 | w.ViewX = 40 | 443 | w.ViewX = 40 | ||
| 444 | w.retarget(100) | 444 | w.retarget(100) | ||
| 445 | - | if w.shift != -240 || !w.motion.on || w.motion.slow != 2 { | 445 | + | if w.view.off != -240 || !w.view.motion.on || w.view.motion.slow != 2 { |
| 446 | - | t.Fatalf("shift %v motion %+v", w.shift, w.motion) | 446 | + | t.Fatalf("shift %v motion %+v", w.view.off, w.view.motion) |
| 447 | } | 447 | } | ||
| 448 | - | if got := w.motion.velocity(); got != v { | 448 | + | if got := w.view.motion.velocity(); got != v { |
| 449 | t.Fatalf("velocity %v after retarget, want %v", got, v) | 449 | t.Fatalf("velocity %v after retarget, want %v", got, v) | ||
| 450 | } | 450 | } | ||
| 451 | - | if got := w.motion.spring.Velocity; math.Abs(got-2*v) > 1e-9 { | 451 | + | if got := w.view.motion.spring.Velocity; math.Abs(got-2*v) > 1e-9 { |
| 452 | t.Fatalf("spring velocity %v, want %v in spring time", got, 2*v) | 452 | t.Fatalf("spring velocity %v, want %v in spring time", got, 2*v) | ||
| 453 | } | 453 | } | ||
| 454 | var idle Workspace | 454 | var idle Workspace | ||
| 455 | idle.ViewX = 5 | 455 | idle.ViewX = 5 | ||
| 456 | idle.retarget(0) | 456 | idle.retarget(0) | ||
| 457 | - | if idle.motion.on || idle.shift != 0 { | 457 | + | if idle.view.motion.on || idle.view.off != 0 { |
| 458 | t.Fatal("retarget without a slide must not start one") | 458 | t.Fatal("retarget without a slide must not start one") | ||
| 459 | } | 459 | } | ||
| 460 | } | 460 | } | ||
M internal/core/overview.go +8 -3
| @@ -75,7 +75,7 @@ func (m *Monitor) ToggleOverview() { | |||
| 75 | 75 | // A slide in progress lands at once: the overview lays out the | |
| 76 | 76 | // settled state. | |
| 77 | 77 | m.stopSwitch() | |
| 78 | - | m.each(func(w *Workspace) { w.stopSlide() }) | |
| 78 | + | m.each(func(w *Workspace) { w.view.stop(); w.stashView.stop() }) | |
| 79 | 79 | m.ov.open, m.ov.from, m.ov.back = true, w, m.back | |
| 80 | 80 | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 0, 0, false, false | |
| 81 | 81 | m.overviewOpens++ | |
| @@ -558,7 +558,9 @@ func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool, | |||
| 558 | 558 | if !p.Hidden { | |
| 559 | 559 | p.Rect.X += dx | |
| 560 | 560 | p.Rect.Y += dy | |
| 561 | - | p.Peek = p.Peek || peek || dim | |
| 561 | + | if p.Peek || peek || dim { | |
| 562 | + | p.peeking(1) | |
| 563 | + | } | |
| 562 | 564 | p.Focused = p.Focused && !peek && !dim && lit | |
| 563 | 565 | } | |
| 564 | 566 | out = append(out, p) | |
| @@ -797,7 +799,10 @@ func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, spa | |||
| 797 | 799 | r := tiles[i].Rect | |
| 798 | 800 | x0, y0 := scale(r.X), scale(r.Y) | |
| 799 | 801 | tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0} | |
| 800 | - | tiles[i].Preview, tiles[i].Peek = z, dim | |
| 802 | + | tiles[i].Preview = z | |
| 803 | + | if dim { | |
| 804 | + | tiles[i].peeking(1) | |
| 805 | + | } | |
| 801 | 806 | tiles[i].Focused = tiles[i].Focused && !dim && lit | |
| 802 | 807 | } | |
| 803 | 808 | return tiles | |
| @@ -75,7 +75,7 @@ func (m *Monitor) ToggleOverview() { | |||||
| 75 | // A slide in progress lands at once: the overview lays out the | 75 | // A slide in progress lands at once: the overview lays out the | ||
| 76 | // settled state. | 76 | // settled state. | ||
| 77 | m.stopSwitch() | 77 | m.stopSwitch() | ||
| 78 | - | m.each(func(w *Workspace) { w.stopSlide() }) | 78 | + | m.each(func(w *Workspace) { w.view.stop(); w.stashView.stop() }) |
| 79 | m.ov.open, m.ov.from, m.ov.back = true, w, m.back | 79 | m.ov.open, m.ov.from, m.ov.back = true, w, m.back | ||
| 80 | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 0, 0, false, false | 80 | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 0, 0, false, false | ||
| 81 | m.overviewOpens++ | 81 | m.overviewOpens++ | ||
| @@ -558,7 +558,9 @@ func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool, | |||||
| 558 | if !p.Hidden { | 558 | if !p.Hidden { | ||
| 559 | p.Rect.X += dx | 559 | p.Rect.X += dx | ||
| 560 | p.Rect.Y += dy | 560 | p.Rect.Y += dy | ||
| 561 | - | p.Peek = p.Peek || peek || dim | 561 | + | if p.Peek || peek || dim { |
| 562 | + | p.peeking(1) | |||
| 563 | + | } | |||
| 562 | p.Focused = p.Focused && !peek && !dim && lit | 564 | p.Focused = p.Focused && !peek && !dim && lit | ||
| 563 | } | 565 | } | ||
| 564 | out = append(out, p) | 566 | out = append(out, p) | ||
| @@ -797,7 +799,10 @@ func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, spa | |||||
| 797 | r := tiles[i].Rect | 799 | r := tiles[i].Rect | ||
| 798 | x0, y0 := scale(r.X), scale(r.Y) | 800 | x0, y0 := scale(r.X), scale(r.Y) | ||
| 799 | tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0} | 801 | tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0} | ||
| 800 | - | tiles[i].Preview, tiles[i].Peek = z, dim | 802 | + | tiles[i].Preview = z |
| 803 | + | if dim { | |||
| 804 | + | tiles[i].peeking(1) | |||
| 805 | + | } | |||
| 801 | tiles[i].Focused = tiles[i].Focused && !dim && lit | 806 | tiles[i].Focused = tiles[i].Focused && !dim && lit | ||
| 802 | } | 807 | } | ||
| 803 | return tiles | 808 | return tiles | ||
M internal/core/publish_alloc_test.go +43 -4
| @@ -106,8 +106,8 @@ func checkRunning(t *testing.T, c *Core, when string) { | |||
| 106 | 106 | if sc.name() == "" { | |
| 107 | 107 | continue | |
| 108 | 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)) | |
| 109 | + | if len(sc.rects) != 6 || !sc.mon.Current().view.motion.on || len(sc.shown) < 6 { | |
| 110 | + | t.Fatalf("%s: rects %d camera %v shown %d", when, len(sc.rects), sc.mon.Current().view.motion.on, len(sc.shown)) | |
| 111 | 111 | } | |
| 112 | 112 | } | |
| 113 | 113 | } | |
| @@ -123,8 +123,8 @@ func TestPublishAllocations(t *testing.T) { | |||
| 123 | 123 | continue | |
| 124 | 124 | } | |
| 125 | 125 | ws := sc.mon.Current() | |
| 126 | - | ws.motion = c.spring(viewSpring(100, 0), t0) | |
| 127 | - | ws.shift = 100 | |
| 126 | + | ws.view.motion = c.spring(viewSpring(100, 0), t0) | |
| 127 | + | ws.view.off = 100 | |
| 128 | 128 | startRectMotions(c, sc, t0) | |
| 129 | 129 | } | |
| 130 | 130 | for _, tc := range []struct { | |
| @@ -146,6 +146,45 @@ func TestPublishAllocations(t *testing.T) { | |||
| 146 | 146 | } | |
| 147 | 147 | } | |
| 148 | 148 | ||
| 149 | + | // TestPublishStashSlideAllocations pins the cost of publishing a frame while | |
| 150 | + | // the stash slides: windows laid out at a fractional offset, each with a | |
| 151 | + | // partial veil, with the same budget as any other animation frame. | |
| 152 | + | func TestPublishStashSlideAllocations(t *testing.T) { | |
| 153 | + | c := publishRig(t, 1) | |
| 154 | + | sc := c.screens[0] | |
| 155 | + | ws := sc.mon.Current() | |
| 156 | + | for _, id := range []WindowID{2, 3, 4, 5} { | |
| 157 | + | ws.FocusID(id) | |
| 158 | + | ws.ToggleWindowStash() | |
| 159 | + | } | |
| 160 | + | sc.arrange() | |
| 161 | + | c.refreshShown() | |
| 162 | + | ws.stashView.off = 0.5 | |
| 163 | + | ws.stashView.motion = c.spring(ws.stashSpring(0.5, 0), time.Now()) | |
| 164 | + | step := publishStep(t, c, nil) | |
| 165 | + | step() | |
| 166 | + | if !ws.stashView.motion.on || !ws.stashShown() { | |
| 167 | + | t.Fatal("setup: no stash slide on screen") | |
| 168 | + | } | |
| 169 | + | var partial int | |
| 170 | + | for _, p := range sc.shown { | |
| 171 | + | if p.Veil > 0 && p.Veil < 1 { | |
| 172 | + | partial++ | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | if partial == 0 { | |
| 176 | + | t.Fatal("setup: no window draws a partial veil") | |
| 177 | + | } | |
| 178 | + | n := testing.AllocsPerRun(50, step) | |
| 179 | + | t.Logf("stash slide allocs per step = %v", n) | |
| 180 | + | if !ws.stashView.motion.on { | |
| 181 | + | t.Fatal("the slide settled during the measure") | |
| 182 | + | } | |
| 183 | + | if n > publishAllocBudget { | |
| 184 | + | t.Errorf("stash slide publish allocs per frame = %v, budget %d", n, publishAllocBudget) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 149 | 188 | // TestPublishOverviewAllocations pins the cost of publishing while the | |
| 150 | 189 | // overview opens: its cards fly from the full rects (scale and fade motions | |
| 151 | 190 | // on every card), which guards the reuse of Core.overviewReal. | |
| @@ -106,8 +106,8 @@ func checkRunning(t *testing.T, c *Core, when string) { | |||||
| 106 | if sc.name() == "" { | 106 | if sc.name() == "" { | ||
| 107 | continue | 107 | continue | ||
| 108 | } | 108 | } | ||
| 109 | - | if len(sc.rects) != 6 || !sc.mon.Current().motion.on || len(sc.shown) < 6 { | 109 | + | if len(sc.rects) != 6 || !sc.mon.Current().view.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)) | 110 | + | t.Fatalf("%s: rects %d camera %v shown %d", when, len(sc.rects), sc.mon.Current().view.motion.on, len(sc.shown)) |
| 111 | } | 111 | } | ||
| 112 | } | 112 | } | ||
| 113 | } | 113 | } | ||
| @@ -123,8 +123,8 @@ func TestPublishAllocations(t *testing.T) { | |||||
| 123 | continue | 123 | continue | ||
| 124 | } | 124 | } | ||
| 125 | ws := sc.mon.Current() | 125 | ws := sc.mon.Current() | ||
| 126 | - | ws.motion = c.spring(viewSpring(100, 0), t0) | 126 | + | ws.view.motion = c.spring(viewSpring(100, 0), t0) |
| 127 | - | ws.shift = 100 | 127 | + | ws.view.off = 100 |
| 128 | startRectMotions(c, sc, t0) | 128 | startRectMotions(c, sc, t0) | ||
| 129 | } | 129 | } | ||
| 130 | for _, tc := range []struct { | 130 | for _, tc := range []struct { | ||
| @@ -146,6 +146,45 @@ func TestPublishAllocations(t *testing.T) { | |||||
| 146 | } | 146 | } | ||
| 147 | } | 147 | } | ||
| 148 | 148 | ||||
| 149 | + | // TestPublishStashSlideAllocations pins the cost of publishing a frame while | |||
| 150 | + | // the stash slides: windows laid out at a fractional offset, each with a | |||
| 151 | + | // partial veil, with the same budget as any other animation frame. | |||
| 152 | + | func TestPublishStashSlideAllocations(t *testing.T) { | |||
| 153 | + | c := publishRig(t, 1) | |||
| 154 | + | sc := c.screens[0] | |||
| 155 | + | ws := sc.mon.Current() | |||
| 156 | + | for _, id := range []WindowID{2, 3, 4, 5} { | |||
| 157 | + | ws.FocusID(id) | |||
| 158 | + | ws.ToggleWindowStash() | |||
| 159 | + | } | |||
| 160 | + | sc.arrange() | |||
| 161 | + | c.refreshShown() | |||
| 162 | + | ws.stashView.off = 0.5 | |||
| 163 | + | ws.stashView.motion = c.spring(ws.stashSpring(0.5, 0), time.Now()) | |||
| 164 | + | step := publishStep(t, c, nil) | |||
| 165 | + | step() | |||
| 166 | + | if !ws.stashView.motion.on || !ws.stashShown() { | |||
| 167 | + | t.Fatal("setup: no stash slide on screen") | |||
| 168 | + | } | |||
| 169 | + | var partial int | |||
| 170 | + | for _, p := range sc.shown { | |||
| 171 | + | if p.Veil > 0 && p.Veil < 1 { | |||
| 172 | + | partial++ | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | if partial == 0 { | |||
| 176 | + | t.Fatal("setup: no window draws a partial veil") | |||
| 177 | + | } | |||
| 178 | + | n := testing.AllocsPerRun(50, step) | |||
| 179 | + | t.Logf("stash slide allocs per step = %v", n) | |||
| 180 | + | if !ws.stashView.motion.on { | |||
| 181 | + | t.Fatal("the slide settled during the measure") | |||
| 182 | + | } | |||
| 183 | + | if n > publishAllocBudget { | |||
| 184 | + | t.Errorf("stash slide publish allocs per frame = %v, budget %d", n, publishAllocBudget) | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 149 | // TestPublishOverviewAllocations pins the cost of publishing while the | 188 | // TestPublishOverviewAllocations pins the cost of publishing while the | ||
| 150 | // overview opens: its cards fly from the full rects (scale and fade motions | 189 | // overview opens: its cards fly from the full rects (scale and fade motions | ||
| 151 | // on every card), which guards the reuse of Core.overviewReal. | 190 | // on every card), which guards the reuse of Core.overviewReal. | ||
M internal/core/pulse.go +2 -2
| @@ -140,11 +140,11 @@ func (c *Core) advancePulse(now time.Time) float64 { | |||
| 140 | 140 | // settled reports whether no swipe or slide moves the monitor's layout | |
| 141 | 141 | // (screen.settled adds the rect motions). | |
| 142 | 142 | func (m *Monitor) settled() bool { | |
| 143 | - | if m.switchMotion.on || m.switchOff != 0 { | |
| 143 | + | if m.switchView.busy() { | |
| 144 | 144 | return false | |
| 145 | 145 | } | |
| 146 | 146 | for w := range m.all() { | |
| 147 | - | if w.motion.on || w.shift != 0 { | |
| 147 | + | if w.view.busy() || w.stashView.busy() { | |
| 148 | 148 | return false | |
| 149 | 149 | } | |
| 150 | 150 | } | |
| @@ -140,11 +140,11 @@ func (c *Core) advancePulse(now time.Time) float64 { | |||||
| 140 | // 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). | 141 | // (screen.settled adds the rect motions). | ||
| 142 | func (m *Monitor) settled() bool { | 142 | func (m *Monitor) settled() bool { | ||
| 143 | - | if m.switchMotion.on || m.switchOff != 0 { | 143 | + | if m.switchView.busy() { |
| 144 | return false | 144 | return false | ||
| 145 | } | 145 | } | ||
| 146 | for w := range m.all() { | 146 | for w := range m.all() { | ||
| 147 | - | if w.motion.on || w.shift != 0 { | 147 | + | if w.view.busy() || w.stashView.busy() { |
| 148 | return false | 148 | return false | ||
| 149 | } | 149 | } | ||
| 150 | } | 150 | } | ||
M internal/core/pulse_test.go +5 -5
| @@ -194,10 +194,10 @@ func TestFocusPulseWaitsForSlide(t *testing.T) { | |||
| 194 | 194 | alone bool | |
| 195 | 195 | }{ | |
| 196 | 196 | {"workspace landing, alone", func(_ *testing.T, c *Core, now time.Time) { | |
| 197 | - | c.cur().mon.switchMotion = newMotion(workspaceSpring(0.5, 0), now, 1) | |
| 197 | + | c.cur().mon.switchView.motion = newMotion(workspaceSpring(0.5, 0), now, 1) | |
| 198 | 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}, | |
| 199 | + | {"workspace drag, alone", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.switchView.off = 0.3 }, true}, | |
| 200 | + | {"column drag, beside another", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.Current().view.off = 40 }, false}, | |
| 201 | 201 | {"rect motion, beside another", func(_ *testing.T, c *Core, now time.Time) { | |
| 202 | 202 | sc := c.cur() | |
| 203 | 203 | sc.rects = map[WindowID]rectMotion{1: {x: c.spring(viewSpring(40, 0), now), dx: 40}} | |
| @@ -214,7 +214,7 @@ func TestFocusPulseWaitsForSlide(t *testing.T) { | |||
| 214 | 214 | c.applyAction(ActionFocusColumnLeft) | |
| 215 | 215 | } | |
| 216 | 216 | c.transition(before, now) | |
| 217 | - | if !m.Current().motion.on || m.settled() { | |
| 217 | + | if !m.Current().view.motion.on || m.settled() { | |
| 218 | 218 | t.Fatal("no camera transition after the focus moved the view") | |
| 219 | 219 | } | |
| 220 | 220 | }, false}, | |
| @@ -231,7 +231,7 @@ func TestFocusPulseWaitsForSlide(t *testing.T) { | |||
| 231 | 231 | t.Fatal("pulse decided during a slide") | |
| 232 | 232 | } | |
| 233 | 233 | m.stopSwitch() | |
| 234 | - | m.Current().stopSlide() | |
| 234 | + | m.Current().view.stop() | |
| 235 | 235 | c.cur().stopRects() | |
| 236 | 236 | if tc.alone { | |
| 237 | 237 | m.Current().ToggleFullWidth() | |
| @@ -194,10 +194,10 @@ func TestFocusPulseWaitsForSlide(t *testing.T) { | |||||
| 194 | alone bool | 194 | alone bool | ||
| 195 | }{ | 195 | }{ | ||
| 196 | {"workspace landing, alone", func(_ *testing.T, c *Core, now time.Time) { | 196 | {"workspace landing, alone", func(_ *testing.T, c *Core, now time.Time) { | ||
| 197 | - | c.cur().mon.switchMotion = newMotion(workspaceSpring(0.5, 0), now, 1) | 197 | + | c.cur().mon.switchView.motion = newMotion(workspaceSpring(0.5, 0), now, 1) |
| 198 | }, true}, | 198 | }, true}, | ||
| 199 | - | {"workspace drag, alone", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.switchOff = 0.3 }, true}, | 199 | + | {"workspace drag, alone", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.switchView.off = 0.3 }, true}, |
| 200 | - | {"column drag, beside another", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.Current().shift = 40 }, false}, | 200 | + | {"column drag, beside another", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.Current().view.off = 40 }, false}, |
| 201 | {"rect motion, beside another", func(_ *testing.T, c *Core, now time.Time) { | 201 | {"rect motion, beside another", func(_ *testing.T, c *Core, now time.Time) { | ||
| 202 | sc := c.cur() | 202 | sc := c.cur() | ||
| 203 | sc.rects = map[WindowID]rectMotion{1: {x: c.spring(viewSpring(40, 0), now), dx: 40}} | 203 | sc.rects = map[WindowID]rectMotion{1: {x: c.spring(viewSpring(40, 0), now), dx: 40}} | ||
| @@ -214,7 +214,7 @@ func TestFocusPulseWaitsForSlide(t *testing.T) { | |||||
| 214 | c.applyAction(ActionFocusColumnLeft) | 214 | c.applyAction(ActionFocusColumnLeft) | ||
| 215 | } | 215 | } | ||
| 216 | c.transition(before, now) | 216 | c.transition(before, now) | ||
| 217 | - | if !m.Current().motion.on || m.settled() { | 217 | + | if !m.Current().view.motion.on || m.settled() { |
| 218 | t.Fatal("no camera transition after the focus moved the view") | 218 | t.Fatal("no camera transition after the focus moved the view") | ||
| 219 | } | 219 | } | ||
| 220 | }, false}, | 220 | }, false}, | ||
| @@ -231,7 +231,7 @@ func TestFocusPulseWaitsForSlide(t *testing.T) { | |||||
| 231 | t.Fatal("pulse decided during a slide") | 231 | t.Fatal("pulse decided during a slide") | ||
| 232 | } | 232 | } | ||
| 233 | m.stopSwitch() | 233 | m.stopSwitch() | ||
| 234 | - | m.Current().stopSlide() | 234 | + | m.Current().view.stop() |
| 235 | c.cur().stopRects() | 235 | c.cur().stopRects() | ||
| 236 | if tc.alone { | 236 | if tc.alone { | ||
| 237 | m.Current().ToggleFullWidth() | 237 | m.Current().ToggleFullWidth() | ||
M internal/core/screens.go +5 -0
| @@ -351,6 +351,11 @@ func (c *Core) removeScreen(name string) { | |||
| 351 | 351 | return | |
| 352 | 352 | } | |
| 353 | 353 | gone := c.screens[i] | |
| 354 | + | if c.swipe != nil && c.swipe.screen == gone { | |
| 355 | + | // Its slide would stay on the workspaces the host adopts. | |
| 356 | + | c.dropSwipe() | |
| 357 | + | c.swipe = nil | |
| 358 | + | } | |
| 354 | 359 | focused := c.cur() | |
| 355 | 360 | c.screens = slices.Delete(c.screens, i, i+1) | |
| 356 | 361 | c.focusScreen = max(slices.Index(c.screens, focused), 0) | |
| @@ -351,6 +351,11 @@ func (c *Core) removeScreen(name string) { | |||||
| 351 | return | 351 | return | ||
| 352 | } | 352 | } | ||
| 353 | gone := c.screens[i] | 353 | gone := c.screens[i] | ||
| 354 | + | if c.swipe != nil && c.swipe.screen == gone { | |||
| 355 | + | // Its slide would stay on the workspaces the host adopts. | |||
| 356 | + | c.dropSwipe() | |||
| 357 | + | c.swipe = nil | |||
| 358 | + | } | |||
| 354 | focused := c.cur() | 359 | focused := c.cur() | ||
| 355 | c.screens = slices.Delete(c.screens, i, i+1) | 360 | c.screens = slices.Delete(c.screens, i, i+1) | ||
| 356 | c.focusScreen = max(slices.Index(c.screens, focused), 0) | 361 | c.focusScreen = max(slices.Index(c.screens, focused), 0) | ||
M internal/core/slide.go +39 -14
| @@ -6,10 +6,36 @@ import ( | |||
| 6 | 6 | "time" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | // Slides move what is on screen smoothly: a workspace's columns (shift) and | |
| 10 | - | // the monitor between workspaces (switchOff). Both are pure presentation | |
| 11 | - | // over the settled state (ViewX, Focus, Active): the layout ends where the | |
| 12 | - | // state says as soon as a slide stops. | |
| 9 | + | // Slides move what is on screen smoothly: a workspace's columns (view), its | |
| 10 | + | // stash (stashView) and the monitor between workspaces (switchView). All | |
| 11 | + | // are pure presentation over the settled state (ViewX, Focus, stashAt, | |
| 12 | + | // Active): the layout ends where the state says as soon as a slide stops. | |
| 13 | + | ||
| 14 | + | // slide is a view offset that a swipe sets while the fingers follow it, and | |
| 15 | + | // a spring then takes to 0 (off: pixels for the columns, windows for the | |
| 16 | + | // stash, workspaces for the monitor). Pure presentation over settled state. | |
| 17 | + | type slide struct { | |
| 18 | + | off float64 | |
| 19 | + | motion motion | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // stop lands the slide where its state says. | |
| 23 | + | func (s *slide) stop() { *s = slide{} } | |
| 24 | + | ||
| 25 | + | // step moves a running spring to now and stops it once it settles. | |
| 26 | + | func (s *slide) step(now time.Time) { | |
| 27 | + | if !s.motion.on { | |
| 28 | + | return | |
| 29 | + | } | |
| 30 | + | v, done := s.motion.at(now) | |
| 31 | + | s.off = v | |
| 32 | + | if done { | |
| 33 | + | s.stop() | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // busy reports whether the slide is off its state or its spring runs. | |
| 38 | + | func (s *slide) busy() bool { return s.motion.on || s.off != 0 } | |
| 13 | 39 | ||
| 14 | 40 | // slidable reports whether a swipe can scroll the columns: only scroll | |
| 15 | 41 | // overflow, with the focus on the columns and no fullscreen window. | |
| @@ -17,20 +43,18 @@ func (w *Workspace) slidable() bool { | |||
| 17 | 43 | return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat() | |
| 18 | 44 | } | |
| 19 | 45 | ||
| 20 | - | func (w *Workspace) stopSlide() { w.shift, w.motion = 0, motion{} } | |
| 21 | - | ||
| 22 | 46 | // retarget keeps the view where it is on screen when ViewX moved from | |
| 23 | 47 | // before during a landing slide: the slide heads for the new view. | |
| 24 | 48 | // Without a landing slide the view moves at once, as it always has. | |
| 25 | 49 | func (w *Workspace) retarget(before int) { | |
| 26 | - | if w.ViewX == before || !w.motion.on { | |
| 50 | + | if w.ViewX == before || !w.view.motion.on { | |
| 27 | 51 | return | |
| 28 | 52 | } | |
| 29 | - | w.shift += float64(before - w.ViewX) | |
| 30 | - | w.motion = newMotion(viewSpring(w.shift, w.motion.velocity()), time.Time{}, w.motion.slow) | |
| 53 | + | w.view.off += float64(before - w.ViewX) | |
| 54 | + | w.view.motion = newMotion(viewSpring(w.view.off, w.view.motion.velocity()), time.Time{}, w.view.motion.slow) | |
| 31 | 55 | } | |
| 32 | 56 | ||
| 33 | - | func (w *Workspace) shiftPixels() int { return int(math.Round(w.shift)) } | |
| 57 | + | func (w *Workspace) shiftPixels() int { return int(math.Round(w.view.off)) } | |
| 34 | 58 | ||
| 35 | 59 | // switchSpring is the workspace slide's spring from off: it snaps once it | |
| 36 | 60 | // is under half a logical pixel of the output height from its target | |
| @@ -116,7 +140,8 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) { | |||
| 116 | 140 | return focus | |
| 117 | 141 | } | |
| 118 | 142 | ||
| 119 | - | func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, motion{}, nil } | |
| 143 | + | // stopSwitch lands the workspace slide and forgets the list it measured in. | |
| 144 | + | func (m *Monitor) stopSwitch() { m.switchView.stop(); m.switchList = nil } | |
| 120 | 145 | ||
| 121 | 146 | // framedSwitch keeps transitions involving a sized workspace settled. A | |
| 122 | 147 | // monitor-wide animation has no per-workspace crop; it must not expose | |
| @@ -133,7 +158,7 @@ func (m *Monitor) framedSwitch() bool { | |||
| 133 | 158 | if base < 0 { | |
| 134 | 159 | return false | |
| 135 | 160 | } | |
| 136 | - | pos := float64(base) + m.switchOff | |
| 161 | + | pos := float64(base) + m.switchView.off | |
| 137 | 162 | for _, i := range [2]int{int(math.Floor(pos)), int(math.Ceil(pos))} { | |
| 138 | 163 | if i >= 0 && i < len(list) && m.has(list[i]) && list[i].Output != m.Output() { | |
| 139 | 164 | return true | |
| @@ -147,7 +172,7 @@ func (m *Monitor) framedSwitch() bool { | |||
| 147 | 172 | // A landing slide places them as the list was when the swipe began | |
| 148 | 173 | // (switchList): an empty workspace dropped since shows as background. | |
| 149 | 174 | func (m *Monitor) slideLayout(cur []Placement) []Placement { | |
| 150 | - | if m.switchOff == 0 || m.shown != nil || m.framedSwitch() { | |
| 175 | + | if m.switchView.off == 0 || m.shown != nil || m.framedSwitch() { | |
| 151 | 176 | return cur | |
| 152 | 177 | } | |
| 153 | 178 | list := m.Workspaces | |
| @@ -159,7 +184,7 @@ func (m *Monitor) slideLayout(cur []Placement) []Placement { | |||
| 159 | 184 | return cur | |
| 160 | 185 | } | |
| 161 | 186 | h := float64(m.template.Output.H) | |
| 162 | - | pos := float64(base) + m.switchOff | |
| 187 | + | pos := float64(base) + m.switchView.off | |
| 163 | 188 | offset := func(p []Placement, k int) []Placement { | |
| 164 | 189 | dy := int(math.Round((float64(k) - pos) * h)) | |
| 165 | 190 | for i := range p { | |
| @@ -6,10 +6,36 @@ import ( | |||||
| 6 | "time" | 6 | "time" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | // Slides move what is on screen smoothly: a workspace's columns (shift) and | 9 | + | // Slides move what is on screen smoothly: a workspace's columns (view), its |
| 10 | - | // the monitor between workspaces (switchOff). Both are pure presentation | 10 | + | // stash (stashView) and the monitor between workspaces (switchView). All |
| 11 | - | // over the settled state (ViewX, Focus, Active): the layout ends where the | 11 | + | // are pure presentation over the settled state (ViewX, Focus, stashAt, |
| 12 | - | // state says as soon as a slide stops. | 12 | + | // Active): the layout ends where the state says as soon as a slide stops. |
| 13 | + | ||||
| 14 | + | // slide is a view offset that a swipe sets while the fingers follow it, and | |||
| 15 | + | // a spring then takes to 0 (off: pixels for the columns, windows for the | |||
| 16 | + | // stash, workspaces for the monitor). Pure presentation over settled state. | |||
| 17 | + | type slide struct { | |||
| 18 | + | off float64 | |||
| 19 | + | motion motion | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // stop lands the slide where its state says. | |||
| 23 | + | func (s *slide) stop() { *s = slide{} } | |||
| 24 | + | ||||
| 25 | + | // step moves a running spring to now and stops it once it settles. | |||
| 26 | + | func (s *slide) step(now time.Time) { | |||
| 27 | + | if !s.motion.on { | |||
| 28 | + | return | |||
| 29 | + | } | |||
| 30 | + | v, done := s.motion.at(now) | |||
| 31 | + | s.off = v | |||
| 32 | + | if done { | |||
| 33 | + | s.stop() | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // busy reports whether the slide is off its state or its spring runs. | |||
| 38 | + | func (s *slide) busy() bool { return s.motion.on || s.off != 0 } | |||
| 13 | 39 | ||||
| 14 | // slidable reports whether a swipe can scroll the columns: only scroll | 40 | // slidable reports whether a swipe can scroll the columns: only scroll | ||
| 15 | // overflow, with the focus on the columns and no fullscreen window. | 41 | // overflow, with the focus on the columns and no fullscreen window. | ||
| @@ -17,20 +43,18 @@ func (w *Workspace) slidable() bool { | |||||
| 17 | return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat() | 43 | return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat() | ||
| 18 | } | 44 | } | ||
| 19 | 45 | ||||
| 20 | - | func (w *Workspace) stopSlide() { w.shift, w.motion = 0, motion{} } | |||
| 21 | - | ||||
| 22 | // retarget keeps the view where it is on screen when ViewX moved from | 46 | // retarget keeps the view where it is on screen when ViewX moved from | ||
| 23 | // before during a landing slide: the slide heads for the new view. | 47 | // before during a landing slide: the slide heads for the new view. | ||
| 24 | // Without a landing slide the view moves at once, as it always has. | 48 | // Without a landing slide the view moves at once, as it always has. | ||
| 25 | func (w *Workspace) retarget(before int) { | 49 | func (w *Workspace) retarget(before int) { | ||
| 26 | - | if w.ViewX == before || !w.motion.on { | 50 | + | if w.ViewX == before || !w.view.motion.on { |
| 27 | return | 51 | return | ||
| 28 | } | 52 | } | ||
| 29 | - | w.shift += float64(before - w.ViewX) | 53 | + | w.view.off += float64(before - w.ViewX) |
| 30 | - | w.motion = newMotion(viewSpring(w.shift, w.motion.velocity()), time.Time{}, w.motion.slow) | 54 | + | w.view.motion = newMotion(viewSpring(w.view.off, w.view.motion.velocity()), time.Time{}, w.view.motion.slow) |
| 31 | } | 55 | } | ||
| 32 | 56 | ||||
| 33 | - | func (w *Workspace) shiftPixels() int { return int(math.Round(w.shift)) } | 57 | + | func (w *Workspace) shiftPixels() int { return int(math.Round(w.view.off)) } |
| 34 | 58 | ||||
| 35 | // switchSpring is the workspace slide's spring from off: it snaps once it | 59 | // 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 | 60 | // is under half a logical pixel of the output height from its target | ||
| @@ -116,7 +140,8 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) { | |||||
| 116 | return focus | 140 | return focus | ||
| 117 | } | 141 | } | ||
| 118 | 142 | ||||
| 119 | - | func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, motion{}, nil } | 143 | + | // stopSwitch lands the workspace slide and forgets the list it measured in. |
| 144 | + | func (m *Monitor) stopSwitch() { m.switchView.stop(); m.switchList = nil } | |||
| 120 | 145 | ||||
| 121 | // framedSwitch keeps transitions involving a sized workspace settled. A | 146 | // framedSwitch keeps transitions involving a sized workspace settled. A | ||
| 122 | // monitor-wide animation has no per-workspace crop; it must not expose | 147 | // monitor-wide animation has no per-workspace crop; it must not expose | ||
| @@ -133,7 +158,7 @@ func (m *Monitor) framedSwitch() bool { | |||||
| 133 | if base < 0 { | 158 | if base < 0 { | ||
| 134 | return false | 159 | return false | ||
| 135 | } | 160 | } | ||
| 136 | - | pos := float64(base) + m.switchOff | 161 | + | pos := float64(base) + m.switchView.off |
| 137 | for _, i := range [2]int{int(math.Floor(pos)), int(math.Ceil(pos))} { | 162 | for _, i := range [2]int{int(math.Floor(pos)), int(math.Ceil(pos))} { | ||
| 138 | if i >= 0 && i < len(list) && m.has(list[i]) && list[i].Output != m.Output() { | 163 | if i >= 0 && i < len(list) && m.has(list[i]) && list[i].Output != m.Output() { | ||
| 139 | return true | 164 | return true | ||
| @@ -147,7 +172,7 @@ func (m *Monitor) framedSwitch() bool { | |||||
| 147 | // A landing slide places them as the list was when the swipe began | 172 | // A landing slide places them as the list was when the swipe began | ||
| 148 | // (switchList): an empty workspace dropped since shows as background. | 173 | // (switchList): an empty workspace dropped since shows as background. | ||
| 149 | func (m *Monitor) slideLayout(cur []Placement) []Placement { | 174 | func (m *Monitor) slideLayout(cur []Placement) []Placement { | ||
| 150 | - | if m.switchOff == 0 || m.shown != nil || m.framedSwitch() { | 175 | + | if m.switchView.off == 0 || m.shown != nil || m.framedSwitch() { |
| 151 | return cur | 176 | return cur | ||
| 152 | } | 177 | } | ||
| 153 | list := m.Workspaces | 178 | list := m.Workspaces | ||
| @@ -159,7 +184,7 @@ func (m *Monitor) slideLayout(cur []Placement) []Placement { | |||||
| 159 | return cur | 184 | return cur | ||
| 160 | } | 185 | } | ||
| 161 | h := float64(m.template.Output.H) | 186 | h := float64(m.template.Output.H) | ||
| 162 | - | pos := float64(base) + m.switchOff | 187 | + | pos := float64(base) + m.switchView.off |
| 163 | offset := func(p []Placement, k int) []Placement { | 188 | offset := func(p []Placement, k int) []Placement { | ||
| 164 | dy := int(math.Round((float64(k) - pos) * h)) | 189 | dy := int(math.Round((float64(k) - pos) * h)) | ||
| 165 | for i := range p { | 190 | for i := range p { | ||
M internal/core/stash.go +40 -9
| @@ -231,22 +231,31 @@ func (w *Workspace) appendStash(out []Placement, focusedID, cover WindowID) []Pl | |||
| 231 | 231 | u := w.Usable | |
| 232 | 232 | center := w.stashRect() | |
| 233 | 233 | fw, fh := center.W, center.H | |
| 234 | - | // Peeks stay in the margins: they never overlap the selected window. | |
| 235 | - | gap := u.W * w.stashGap / 100 | |
| 236 | - | peek := max((u.W-fw)/2-gap, 0) | |
| 234 | + | // Neighbors rest in the margins, never over the selected window: the | |
| 235 | + | // side with the least room says whether there is one. | |
| 236 | + | margin := (u.W-fw)/2 - w.stashGapPx() | |
| 237 | + | // The view is moved by stashView.off pitches, rounded once so every | |
| 238 | + | // window shares the offset. | |
| 239 | + | pitch := w.stashPitch(fw) | |
| 240 | + | shift := int(math.Round(w.stashView.off * float64(pitch))) | |
| 237 | 241 | for i, f := range w.Stash { | |
| 238 | 242 | p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll} | |
| 243 | + | d := i - w.stashAt | |
| 244 | + | r := Rect{X: center.X + d*pitch - shift, Y: center.Y, W: fw, H: fh} | |
| 239 | 245 | switch { | |
| 240 | 246 | case f.ID == cover: | |
| 241 | 247 | p.Rect, p.Fullscreen, p.Inset = w.Output, true, 0 | |
| 242 | 248 | case w.stashHidden || cover != 0 && !w.stashOverCover(): | |
| 243 | 249 | p.Hidden = true | |
| 244 | - | case i == w.stashAt: | |
| 245 | - | p.Rect = center | |
| 246 | - | case peek > 0 && i == w.stashAt-1: | |
| 247 | - | p.Rect, p.Peek = Rect{X: center.X - gap - fw, Y: center.Y, W: fw, H: fh}, true | |
| 248 | - | case peek > 0 && i == w.stashAt+1: | |
| 249 | - | p.Rect, p.Peek = Rect{X: center.X + fw + gap, Y: center.Y, W: fw, H: fh}, true | |
| 250 | + | case d == 0: | |
| 251 | + | // The selection is no peek, but its veil grows as it slides. | |
| 252 | + | p.Rect, p.Veil = r, w.stashVeil(d) | |
| 253 | + | case (shift != 0 || margin > 0 && (d == -1 || d == 1)) && r.Overlaps(u): | |
| 254 | + | // A neighbor shows in its margin, if it leaves one. A slide | |
| 255 | + | // shows what the view moves in, and keeps what it moves out | |
| 256 | + | // until it is gone. | |
| 257 | + | p.Rect = r | |
| 258 | + | p.peeking(w.stashVeil(d)) | |
| 250 | 259 | default: | |
| 251 | 260 | p.Hidden = true | |
| 252 | 261 | } | |
| @@ -254,3 +263,25 @@ func (w *Workspace) appendStash(out []Placement, focusedID, cover WindowID) []Pl | |||
| 254 | 263 | } | |
| 255 | 264 | return out | |
| 256 | 265 | } | |
| 266 | + | ||
| 267 | + | // stashGapPx is the space between the selected stash window and its | |
| 268 | + | // neighbors, in pixels. | |
| 269 | + | func (w *Workspace) stashGapPx() int { return w.Usable.W * w.stashGap / 100 } | |
| 270 | + | ||
| 271 | + | // stashPitch is the distance between two stash windows of width fw. | |
| 272 | + | func (w *Workspace) stashPitch(fw int) int { return max(fw+w.stashGapPx(), 1) } | |
| 273 | + | ||
| 274 | + | // stashSpring lands a stash slide from off (in stash windows) with the | |
| 275 | + | // speed velocity; it snaps under half a pixel like the other slides. | |
| 276 | + | func (w *Workspace) stashSpring(off, velocity float64) spring { | |
| 277 | + | s := workspaceSpring(off, velocity) | |
| 278 | + | s.Snap = pixelSnap / float64(w.stashPitch(w.stashRect().W)) | |
| 279 | + | return s | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // stashVeil is how much of the peek veil the stash window d places away | |
| 283 | + | // from stashAt draws: none at the centre of the view, all of it one window | |
| 284 | + | // away or more. | |
| 285 | + | func (w *Workspace) stashVeil(d int) float64 { | |
| 286 | + | return min(math.Abs(float64(d)-w.stashView.off), 1) | |
| 287 | + | } | |
| @@ -231,22 +231,31 @@ func (w *Workspace) appendStash(out []Placement, focusedID, cover WindowID) []Pl | |||||
| 231 | u := w.Usable | 231 | u := w.Usable | ||
| 232 | center := w.stashRect() | 232 | center := w.stashRect() | ||
| 233 | fw, fh := center.W, center.H | 233 | fw, fh := center.W, center.H | ||
| 234 | - | // Peeks stay in the margins: they never overlap the selected window. | 234 | + | // Neighbors rest in the margins, never over the selected window: the |
| 235 | - | gap := u.W * w.stashGap / 100 | 235 | + | // side with the least room says whether there is one. |
| 236 | - | peek := max((u.W-fw)/2-gap, 0) | 236 | + | margin := (u.W-fw)/2 - w.stashGapPx() |
| 237 | + | // The view is moved by stashView.off pitches, rounded once so every | |||
| 238 | + | // window shares the offset. | |||
| 239 | + | pitch := w.stashPitch(fw) | |||
| 240 | + | shift := int(math.Round(w.stashView.off * float64(pitch))) | |||
| 237 | for i, f := range w.Stash { | 241 | for i, f := range w.Stash { | ||
| 238 | p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll} | 242 | p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll} | ||
| 243 | + | d := i - w.stashAt | |||
| 244 | + | r := Rect{X: center.X + d*pitch - shift, Y: center.Y, W: fw, H: fh} | |||
| 239 | switch { | 245 | switch { | ||
| 240 | case f.ID == cover: | 246 | case f.ID == cover: | ||
| 241 | p.Rect, p.Fullscreen, p.Inset = w.Output, true, 0 | 247 | p.Rect, p.Fullscreen, p.Inset = w.Output, true, 0 | ||
| 242 | case w.stashHidden || cover != 0 && !w.stashOverCover(): | 248 | case w.stashHidden || cover != 0 && !w.stashOverCover(): | ||
| 243 | p.Hidden = true | 249 | p.Hidden = true | ||
| 244 | - | case i == w.stashAt: | 250 | + | case d == 0: |
| 245 | - | p.Rect = center | 251 | + | // The selection is no peek, but its veil grows as it slides. |
| 246 | - | case peek > 0 && i == w.stashAt-1: | 252 | + | p.Rect, p.Veil = r, w.stashVeil(d) |
| 247 | - | p.Rect, p.Peek = Rect{X: center.X - gap - fw, Y: center.Y, W: fw, H: fh}, true | 253 | + | case (shift != 0 || margin > 0 && (d == -1 || d == 1)) && r.Overlaps(u): |
| 248 | - | case peek > 0 && i == w.stashAt+1: | 254 | + | // A neighbor shows in its margin, if it leaves one. A slide |
| 249 | - | p.Rect, p.Peek = Rect{X: center.X + fw + gap, Y: center.Y, W: fw, H: fh}, true | 255 | + | // shows what the view moves in, and keeps what it moves out |
| 256 | + | // until it is gone. | |||
| 257 | + | p.Rect = r | |||
| 258 | + | p.peeking(w.stashVeil(d)) | |||
| 250 | default: | 259 | default: | ||
| 251 | p.Hidden = true | 260 | p.Hidden = true | ||
| 252 | } | 261 | } | ||
| @@ -254,3 +263,25 @@ func (w *Workspace) appendStash(out []Placement, focusedID, cover WindowID) []Pl | |||||
| 254 | } | 263 | } | ||
| 255 | return out | 264 | return out | ||
| 256 | } | 265 | } | ||
| 266 | + | ||||
| 267 | + | // stashGapPx is the space between the selected stash window and its | |||
| 268 | + | // neighbors, in pixels. | |||
| 269 | + | func (w *Workspace) stashGapPx() int { return w.Usable.W * w.stashGap / 100 } | |||
| 270 | + | ||||
| 271 | + | // stashPitch is the distance between two stash windows of width fw. | |||
| 272 | + | func (w *Workspace) stashPitch(fw int) int { return max(fw+w.stashGapPx(), 1) } | |||
| 273 | + | ||||
| 274 | + | // stashSpring lands a stash slide from off (in stash windows) with the | |||
| 275 | + | // speed velocity; it snaps under half a pixel like the other slides. | |||
| 276 | + | func (w *Workspace) stashSpring(off, velocity float64) spring { | |||
| 277 | + | s := workspaceSpring(off, velocity) | |||
| 278 | + | s.Snap = pixelSnap / float64(w.stashPitch(w.stashRect().W)) | |||
| 279 | + | return s | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | // stashVeil is how much of the peek veil the stash window d places away | |||
| 283 | + | // from stashAt draws: none at the centre of the view, all of it one window | |||
| 284 | + | // away or more. | |||
| 285 | + | func (w *Workspace) stashVeil(d int) float64 { | |||
| 286 | + | return min(math.Abs(float64(d)-w.stashView.off), 1) | |||
| 287 | + | } | |||
A internal/core/stash_swipe_internal_test.go +421 -0
| @@ -0,0 +1,421 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // stashSwipe drives a three-finger swipe on the stash of stashRig (windows | |
| 13 | + | // 4, 3, 2; 2 selected at the right end). | |
| 14 | + | type stashSwipe struct { | |
| 15 | + | t *testing.T | |
| 16 | + | c *Core | |
| 17 | + | ic *indicatorClock | |
| 18 | + | at time.Duration | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func newStashSwipe(t *testing.T, edit func(*Core)) (*stashSwipe, *Workspace) { | |
| 22 | + | t.Helper() | |
| 23 | + | c, ic, sc, _ := stashRig(t, true) | |
| 24 | + | if edit != nil { | |
| 25 | + | edit(c) | |
| 26 | + | } | |
| 27 | + | return &stashSwipe{t: t, c: c, ic: ic}, sc.mon.Current() | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (s *stashSwipe) begin() { | |
| 31 | + | s.at += time.Second | |
| 32 | + | s.c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: s.at}) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (s *stashSwipe) move(dx float64) { | |
| 36 | + | s.at += 8 * time.Millisecond | |
| 37 | + | s.c.swipeUpdate(ports.SwipeUpdate{DX: dx, Time: s.at}) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (s *stashSwipe) end(cancelled bool) { | |
| 41 | + | s.c.swipeEnd(ports.SwipeEnd{Cancelled: cancelled, Time: s.at}) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // flick is a quick swipe of n updates of dx, lifted at once. | |
| 45 | + | func (s *stashSwipe) flick(n int, dx float64) { | |
| 46 | + | s.begin() | |
| 47 | + | for range n { | |
| 48 | + | s.move(dx) | |
| 49 | + | } | |
| 50 | + | s.end(false) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // The selection is the right end of 4, 3, 2. Fingers moving right bring the | |
| 54 | + | // left neighbor in (natural scroll off: the content follows the fingers | |
| 55 | + | // opposite, so sideways left reaches the left neighbor like focus left). | |
| 56 | + | ||
| 57 | + | // The view follows the fingers: halfway to the neighbor it has moved half a | |
| 58 | + | // window, its veil half, and nothing is selected yet. | |
| 59 | + | func TestStashSwipeFollowsFingers(t *testing.T) { | |
| 60 | + | s, w := newStashSwipe(t, nil) | |
| 61 | + | s.begin() | |
| 62 | + | s.move(-10) // under the decision threshold | |
| 63 | + | if w.stashView.off != 0 { | |
| 64 | + | t.Fatalf("moved %v before picking an axis", w.stashView.off) | |
| 65 | + | } | |
| 66 | + | s.move(-20) | |
| 67 | + | if g := s.c.swipe; g == nil || g.mode != swipeStash { | |
| 68 | + | t.Fatalf("swipe %+v, want a stash swipe", g) | |
| 69 | + | } | |
| 70 | + | before := w.stashView.off | |
| 71 | + | s.move(-40) | |
| 72 | + | if !(w.stashView.off < before) || w.stashAt != 2 { | |
| 73 | + | t.Fatalf("offset %v then %v, stashAt %d: the view must follow and the selection stay", before, w.stashView.off, w.stashAt) | |
| 74 | + | } | |
| 75 | + | sc := sceneWindow(t, indicatorScene(t, s.c), 2) | |
| 76 | + | if sc.Dim <= 0 || sc.Dim >= 0.5 { | |
| 77 | + | t.Fatalf("selected window veil %v mid-swipe, want strictly between 0 and the stash dim", sc.Dim) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // A flick of any length lands on the neighbor at most: no window is skipped. | |
| 82 | + | func TestStashSwipeNeverSkipsAWindow(t *testing.T) { | |
| 83 | + | for _, n := range []int{3, 10, 40, 200} { | |
| 84 | + | s, w := newStashSwipe(t, nil) | |
| 85 | + | s.flick(n, -80) | |
| 86 | + | if w.stashAt != 1 { | |
| 87 | + | t.Fatalf("%d updates: stashAt %d, want 1 (one window at most)", n, w.stashAt) | |
| 88 | + | } | |
| 89 | + | if !w.stashFocus { | |
| 90 | + | t.Fatalf("%d updates: the stash lost the focus", n) | |
| 91 | + | } | |
| 92 | + | // The view itself never went past the neighbor either. | |
| 93 | + | if view := float64(w.stashAt) + w.stashView.off; view < 1-0.1 { | |
| 94 | + | t.Fatalf("%d updates: the view is at %v, past the neighbor at 1", n, view) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // A slow short swipe settles back where it began. | |
| 100 | + | func TestStashSwipeShortSwipeReturns(t *testing.T) { | |
| 101 | + | s, w := newStashSwipe(t, nil) | |
| 102 | + | s.begin() | |
| 103 | + | s.move(-20) | |
| 104 | + | s.at += 2 * time.Second | |
| 105 | + | s.move(-30) | |
| 106 | + | s.at += 2 * time.Second | |
| 107 | + | s.end(false) | |
| 108 | + | if w.stashAt != 2 { | |
| 109 | + | t.Fatalf("stashAt %d after a slow short swipe, want 2", w.stashAt) | |
| 110 | + | } | |
| 111 | + | s.ic.now = s.ic.now.Add(5 * time.Second) | |
| 112 | + | s.c.animate(s.ic.now, nil) | |
| 113 | + | if w.stashView.off != 0 || w.stashView.motion.on { | |
| 114 | + | t.Fatalf("offset %v, spring %v: want settled", w.stashView.off, w.stashView.motion.on) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // A cancelled swipe returns to the selection. | |
| 119 | + | func TestStashSwipeCancelReturns(t *testing.T) { | |
| 120 | + | s, w := newStashSwipe(t, nil) | |
| 121 | + | s.begin() | |
| 122 | + | for range 20 { | |
| 123 | + | s.move(-40) | |
| 124 | + | } | |
| 125 | + | s.end(true) | |
| 126 | + | if w.stashAt != 2 { | |
| 127 | + | t.Fatalf("stashAt %d after a cancel", w.stashAt) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // Past the first or last window the view only stretches: the end of the | |
| 132 | + | // stash holds the selection. | |
| 133 | + | func TestStashSwipeEdgeHolds(t *testing.T) { | |
| 134 | + | s, w := newStashSwipe(t, nil) | |
| 135 | + | s.flick(20, 80) // right: past the last window | |
| 136 | + | if w.stashAt != 2 { | |
| 137 | + | t.Fatalf("stashAt %d past the end, want 2", w.stashAt) | |
| 138 | + | } | |
| 139 | + | s.flick(20, -80) | |
| 140 | + | s.flick(20, -80) | |
| 141 | + | s.ic.now = s.ic.now.Add(5 * time.Second) | |
| 142 | + | s.c.animate(s.ic.now, nil) | |
| 143 | + | s.flick(20, -80) // 4 is first now: past the start | |
| 144 | + | if w.stashAt != 0 { | |
| 145 | + | t.Fatalf("stashAt %d, want 0 at the start", w.stashAt) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Natural scroll moves the view the other way, as for columns. | |
| 150 | + | func TestStashSwipeNaturalScrollInverts(t *testing.T) { | |
| 151 | + | s, w := newStashSwipe(t, func(c *Core) { c.cfg.Touchpad.NaturalScroll = true }) | |
| 152 | + | s.flick(10, 80) | |
| 153 | + | if w.stashAt != 1 { | |
| 154 | + | t.Fatalf("stashAt %d with natural scroll, want 1", w.stashAt) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // With animations off a swipe takes the discrete path (nothing slides) and | |
| 159 | + | // still steps one window. | |
| 160 | + | func TestStashSwipeAnimationsOffStepsOnce(t *testing.T) { | |
| 161 | + | s, w := newStashSwipe(t, func(c *Core) { c.cfg.Animations.On = false }) | |
| 162 | + | s.flick(40, -80) | |
| 163 | + | if w.stashAt != 1 || w.stashView.off != 0 { | |
| 164 | + | t.Fatalf("stashAt %d, offset %v with animations off", w.stashAt, w.stashView.off) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // A bind during the swipe lets go of it: the rest does nothing. | |
| 169 | + | func TestStashSwipeDropsWhenSelectionChanges(t *testing.T) { | |
| 170 | + | s, w := newStashSwipe(t, nil) | |
| 171 | + | s.begin() | |
| 172 | + | s.move(-40) | |
| 173 | + | s.move(-40) | |
| 174 | + | s.c.applyAction(ActionFocusColumnLeft) | |
| 175 | + | s.move(-40) | |
| 176 | + | if g := s.c.swipe; g.mode != swipeDropped { | |
| 177 | + | t.Fatalf("mode %v, want dropped", g.mode) | |
| 178 | + | } | |
| 179 | + | at := w.stashAt | |
| 180 | + | s.end(false) | |
| 181 | + | if w.stashAt != at || w.stashView.off != 0 { | |
| 182 | + | t.Fatalf("stashAt %d then %d, offset %v: the dropped swipe moved the stash", at, w.stashAt, w.stashView.off) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // Fingers on the stash take a running landing slide where it is. | |
| 187 | + | func TestStashSwipeCatchesLandingSlide(t *testing.T) { | |
| 188 | + | s, w := newStashSwipe(t, nil) | |
| 189 | + | s.flick(10, -80) | |
| 190 | + | s.ic.now = s.ic.now.Add(30 * time.Millisecond) | |
| 191 | + | s.c.animate(s.ic.now, nil) | |
| 192 | + | mid := w.stashView.off | |
| 193 | + | if mid == 0 || !w.stashView.motion.on { | |
| 194 | + | t.Fatalf("setup: offset %v, spring %v", mid, w.stashView.motion.on) | |
| 195 | + | } | |
| 196 | + | s.begin() | |
| 197 | + | s.move(-20) | |
| 198 | + | s.move(-20) | |
| 199 | + | if w.stashView.motion.on { | |
| 200 | + | t.Fatal("the landing spring still runs under the fingers") | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // The stash never brings another workspace on screen: its landing must not | |
| 205 | + | // report a shown workspace, which would respawn the empty slots of a named | |
| 206 | + | // one. | |
| 207 | + | func TestStashSwipeLandingShowsNoWorkspace(t *testing.T) { | |
| 208 | + | s, w := newStashSwipe(t, nil) | |
| 209 | + | s.begin() | |
| 210 | + | for range 10 { | |
| 211 | + | s.move(-80) | |
| 212 | + | } | |
| 213 | + | if shown := s.c.swipeEnd(ports.SwipeEnd{Time: s.at}); shown { | |
| 214 | + | t.Fatal("a stash landing reported a workspace shown") | |
| 215 | + | } | |
| 216 | + | if w.stashAt != 1 { | |
| 217 | + | t.Fatalf("stashAt %d, want the landing on 1", w.stashAt) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // A native float with the focus over the stash keeps the swipe discrete: the | |
| 222 | + | // stash must not slide behind it and snap back. One that takes the focus | |
| 223 | + | // mid-swipe lets go of the stash. | |
| 224 | + | func TestStashSwipeUnderFocusedFloatIsDiscrete(t *testing.T) { | |
| 225 | + | s, w := newStashSwipe(t, nil) | |
| 226 | + | w.AddFloating(9, 100, 80) | |
| 227 | + | if !w.floatFocus { | |
| 228 | + | t.Fatal("setup: the float has no focus") | |
| 229 | + | } | |
| 230 | + | s.begin() | |
| 231 | + | s.move(-40) | |
| 232 | + | if g := s.c.swipe; g.mode != swipeDiscrete { | |
| 233 | + | t.Fatalf("mode %v, want discrete", g.mode) | |
| 234 | + | } | |
| 235 | + | if w.stashView.off != 0 { | |
| 236 | + | t.Fatalf("stash slid by %v under a focused float", w.stashView.off) | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestStashSwipeDropsWhenAFloatTakesTheFocus(t *testing.T) { | |
| 241 | + | s, w := newStashSwipe(t, nil) | |
| 242 | + | s.begin() | |
| 243 | + | s.move(-40) | |
| 244 | + | s.move(-40) | |
| 245 | + | w.AddFloating(9, 100, 80) | |
| 246 | + | s.move(-40) | |
| 247 | + | if g := s.c.swipe; g.mode != swipeDropped || w.stashView.off != 0 { | |
| 248 | + | t.Fatalf("mode %v, offset %v: want dropped and settled", g.mode, w.stashView.off) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // A session lock lets go of the swipe and settles the stash it moved. | |
| 253 | + | func TestStashSwipeSecurityLockSettlesTheStash(t *testing.T) { | |
| 254 | + | s, w := newStashSwipe(t, nil) | |
| 255 | + | s.begin() | |
| 256 | + | s.move(-40) | |
| 257 | + | s.move(-40) | |
| 258 | + | if w.stashView.off == 0 { | |
| 259 | + | t.Fatal("setup: the stash did not follow") | |
| 260 | + | } | |
| 261 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 262 | + | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}) | |
| 263 | + | s.c.ch.Security = gate | |
| 264 | + | if !s.c.syncSecurity() { | |
| 265 | + | t.Fatal("the lock was not taken") | |
| 266 | + | } | |
| 267 | + | if s.c.swipe != nil || w.stashView.off != 0 { | |
| 268 | + | t.Fatalf("swipe %v, offset %v after the lock", s.c.swipe, w.stashView.off) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // With the focus on another output when the fingers lift, the selection | |
| 273 | + | // stays and the view springs back. | |
| 274 | + | func TestStashSwipeLiftOnAnotherOutputSpringsBack(t *testing.T) { | |
| 275 | + | s, w := newStashSwipe(t, nil) | |
| 276 | + | s.c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |
| 277 | + | s.begin() | |
| 278 | + | s.move(-40) | |
| 279 | + | s.move(-80) | |
| 280 | + | s.move(-80) | |
| 281 | + | s.c.focusScreen = 1 | |
| 282 | + | s.end(false) | |
| 283 | + | if w.stashAt != 2 { | |
| 284 | + | t.Fatalf("stashAt %d, want the selection kept at 2", w.stashAt) | |
| 285 | + | } | |
| 286 | + | if !w.stashView.motion.on { | |
| 287 | + | t.Fatal("the view does not spring back") | |
| 288 | + | } | |
| 289 | + | s.ic.now = s.ic.now.Add(5 * time.Second) | |
| 290 | + | s.c.animate(s.ic.now, nil) | |
| 291 | + | if w.stashView.off != 0 { | |
| 292 | + | t.Fatalf("offset %v after the spring", w.stashView.off) | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | // A swipe whose screen is gone lets go of the stash it shifted. | |
| 297 | + | func TestStashSwipeLosingItsScreenSettlesTheStash(t *testing.T) { | |
| 298 | + | for _, onEnd := range []bool{false, true} { | |
| 299 | + | s, w := newStashSwipe(t, nil) | |
| 300 | + | s.begin() | |
| 301 | + | s.move(-40) | |
| 302 | + | s.move(-40) | |
| 303 | + | if w.stashView.off == 0 { | |
| 304 | + | t.Fatal("setup: the stash did not follow") | |
| 305 | + | } | |
| 306 | + | s.c.swipe.screen = &screen{} // its output unplugged | |
| 307 | + | if onEnd { | |
| 308 | + | s.end(false) | |
| 309 | + | } else { | |
| 310 | + | s.move(-40) | |
| 311 | + | } | |
| 312 | + | if w.stashView.off != 0 || s.c.swipe != nil { | |
| 313 | + | t.Fatalf("end=%v: offset %v, swipe %v: want settled and gone", onEnd, w.stashView.off, s.c.swipe) | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | // The overview shows the settled state: a stash landing spring stops. | |
| 319 | + | func TestOverviewStopsStashSlide(t *testing.T) { | |
| 320 | + | s, w := newStashSwipe(t, nil) | |
| 321 | + | s.flick(10, -80) | |
| 322 | + | if !w.stashView.motion.on { | |
| 323 | + | t.Fatal("setup: no landing spring") | |
| 324 | + | } | |
| 325 | + | s.c.cur().mon.ToggleOverview() | |
| 326 | + | if w.stashView.motion.on || w.stashView.off != 0 { | |
| 327 | + | t.Fatalf("spring %v, offset %v under the overview", w.stashView.motion.on, w.stashView.off) | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | // Quick chained navigations keep the view where it is drawn, however far it | |
| 332 | + | // is from the selection: nothing jumps, and the slide settles on the last. | |
| 333 | + | func TestStashNavigationChainsManyWithoutJump(t *testing.T) { | |
| 334 | + | c, ic, sc, _ := stashRig(t, true) | |
| 335 | + | w := sc.mon.Current() | |
| 336 | + | for id := WindowID(5); id <= 8; id++ { | |
| 337 | + | sc.mon.AddWindow(id) | |
| 338 | + | w.FocusID(id) | |
| 339 | + | c.applyAction(ActionToggleWindowStash) | |
| 340 | + | } | |
| 341 | + | settleShown(t, c) | |
| 342 | + | if len(w.Stash) != 7 { | |
| 343 | + | t.Fatalf("setup: %d stashed", len(w.Stash)) | |
| 344 | + | } | |
| 345 | + | prev := float64(w.stashAt) + w.stashView.off | |
| 346 | + | for i := range 5 { | |
| 347 | + | act(c, ic, ActionFocusColumnLeft) | |
| 348 | + | pos := float64(w.stashAt) + w.stashView.off | |
| 349 | + | if pos < prev-1e-6 || pos > prev+1e-6 { | |
| 350 | + | t.Fatalf("navigation %d: the drawn view jumped from %v to %v", i, prev, pos) | |
| 351 | + | } | |
| 352 | + | ic.now = ic.now.Add(10 * time.Millisecond) | |
| 353 | + | c.animate(ic.now, nil) | |
| 354 | + | prev = float64(w.stashAt) + w.stashView.off | |
| 355 | + | } | |
| 356 | + | // The view is still several windows from the selection: the window it | |
| 357 | + | // is on is drawn there, however far it is from stashAt. | |
| 358 | + | at := int(math.Round(float64(w.stashAt) + w.stashView.off)) | |
| 359 | + | if at-w.stashAt < 3 { | |
| 360 | + | t.Fatalf("setup: the view is %d windows from the selection", at-w.stashAt) | |
| 361 | + | } | |
| 362 | + | near := w.Stash[at].ID | |
| 363 | + | if got := sceneWindow(t, indicatorScene(t, c), near); got.Hidden || got.Rect.W == 0 { | |
| 364 | + | t.Fatalf("window %d, %d away from the selection, is not drawn under the view: %+v", near, at-w.stashAt, got) | |
| 365 | + | } | |
| 366 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 367 | + | c.animate(ic.now, nil) | |
| 368 | + | if w.stashView.off != 0 || w.stashView.motion.on { | |
| 369 | + | t.Fatalf("offset %v, spring %v: want settled", w.stashView.off, w.stashView.motion.on) | |
| 370 | + | } | |
| 371 | + | if got := sceneWindow(t, indicatorScene(t, c), near); !got.Hidden { | |
| 372 | + | t.Fatalf("window %d still drawn after the settle: %+v", near, got) | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // A captured workspace caught mid-slide draws the veil the screen does: | |
| 377 | + | // a share of Stash.Dim, not the full one and not none. | |
| 378 | + | func TestCaptureSceneDrawsTheSlidingVeil(t *testing.T) { | |
| 379 | + | s, w := newStashSwipe(t, nil) | |
| 380 | + | s.c.cfg.Stash.Dim = 0.5 | |
| 381 | + | s.begin() | |
| 382 | + | s.move(-40) | |
| 383 | + | s.move(-80) | |
| 384 | + | sc := s.c.screens[0] | |
| 385 | + | s.c.configures.cw.load(sc, w) | |
| 386 | + | scene := s.c.captureScene(1) | |
| 387 | + | if scene == nil { | |
| 388 | + | t.Fatal("no capture scene") | |
| 389 | + | } | |
| 390 | + | var partial bool | |
| 391 | + | for _, p := range s.c.configures.cw.placements { | |
| 392 | + | if p.Veil <= 0 || p.Veil >= 1 { | |
| 393 | + | continue | |
| 394 | + | } | |
| 395 | + | partial = true | |
| 396 | + | got := sceneWindow(t, *scene, p.ID).Dim | |
| 397 | + | if want := s.c.cfg.Stash.Dim * p.Veil; math.Abs(got-want) > 1e-9 { | |
| 398 | + | t.Fatalf("window %d captured with dim %v, want %v", p.ID, got, want) | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | if !partial { | |
| 402 | + | t.Fatal("setup: no window has a partial veil") | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | // An output unplugged under the swipe: the host adopts its workspaces, and | |
| 407 | + | // the stash they carry must not stay shifted. | |
| 408 | + | func TestStashSwipeUnpluggedOutputSettlesTheAdoptedStash(t *testing.T) { | |
| 409 | + | s, w := newStashSwipe(t, nil) | |
| 410 | + | s.c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |
| 411 | + | s.begin() | |
| 412 | + | s.move(-40) | |
| 413 | + | s.move(-80) | |
| 414 | + | if w.stashView.off == 0 { | |
| 415 | + | t.Fatal("setup: the stash did not follow") | |
| 416 | + | } | |
| 417 | + | s.c.removeScreen("A") | |
| 418 | + | if s.c.swipe != nil || w.stashView.off != 0 { | |
| 419 | + | t.Fatalf("swipe %v, offset %v after the unplug", s.c.swipe, w.stashView.off) | |
| 420 | + | } | |
| 421 | + | } | |
| @@ -0,0 +1,421 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // stashSwipe drives a three-finger swipe on the stash of stashRig (windows | |||
| 13 | + | // 4, 3, 2; 2 selected at the right end). | |||
| 14 | + | type stashSwipe struct { | |||
| 15 | + | t *testing.T | |||
| 16 | + | c *Core | |||
| 17 | + | ic *indicatorClock | |||
| 18 | + | at time.Duration | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func newStashSwipe(t *testing.T, edit func(*Core)) (*stashSwipe, *Workspace) { | |||
| 22 | + | t.Helper() | |||
| 23 | + | c, ic, sc, _ := stashRig(t, true) | |||
| 24 | + | if edit != nil { | |||
| 25 | + | edit(c) | |||
| 26 | + | } | |||
| 27 | + | return &stashSwipe{t: t, c: c, ic: ic}, sc.mon.Current() | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (s *stashSwipe) begin() { | |||
| 31 | + | s.at += time.Second | |||
| 32 | + | s.c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: s.at}) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (s *stashSwipe) move(dx float64) { | |||
| 36 | + | s.at += 8 * time.Millisecond | |||
| 37 | + | s.c.swipeUpdate(ports.SwipeUpdate{DX: dx, Time: s.at}) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (s *stashSwipe) end(cancelled bool) { | |||
| 41 | + | s.c.swipeEnd(ports.SwipeEnd{Cancelled: cancelled, Time: s.at}) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // flick is a quick swipe of n updates of dx, lifted at once. | |||
| 45 | + | func (s *stashSwipe) flick(n int, dx float64) { | |||
| 46 | + | s.begin() | |||
| 47 | + | for range n { | |||
| 48 | + | s.move(dx) | |||
| 49 | + | } | |||
| 50 | + | s.end(false) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // The selection is the right end of 4, 3, 2. Fingers moving right bring the | |||
| 54 | + | // left neighbor in (natural scroll off: the content follows the fingers | |||
| 55 | + | // opposite, so sideways left reaches the left neighbor like focus left). | |||
| 56 | + | ||||
| 57 | + | // The view follows the fingers: halfway to the neighbor it has moved half a | |||
| 58 | + | // window, its veil half, and nothing is selected yet. | |||
| 59 | + | func TestStashSwipeFollowsFingers(t *testing.T) { | |||
| 60 | + | s, w := newStashSwipe(t, nil) | |||
| 61 | + | s.begin() | |||
| 62 | + | s.move(-10) // under the decision threshold | |||
| 63 | + | if w.stashView.off != 0 { | |||
| 64 | + | t.Fatalf("moved %v before picking an axis", w.stashView.off) | |||
| 65 | + | } | |||
| 66 | + | s.move(-20) | |||
| 67 | + | if g := s.c.swipe; g == nil || g.mode != swipeStash { | |||
| 68 | + | t.Fatalf("swipe %+v, want a stash swipe", g) | |||
| 69 | + | } | |||
| 70 | + | before := w.stashView.off | |||
| 71 | + | s.move(-40) | |||
| 72 | + | if !(w.stashView.off < before) || w.stashAt != 2 { | |||
| 73 | + | t.Fatalf("offset %v then %v, stashAt %d: the view must follow and the selection stay", before, w.stashView.off, w.stashAt) | |||
| 74 | + | } | |||
| 75 | + | sc := sceneWindow(t, indicatorScene(t, s.c), 2) | |||
| 76 | + | if sc.Dim <= 0 || sc.Dim >= 0.5 { | |||
| 77 | + | t.Fatalf("selected window veil %v mid-swipe, want strictly between 0 and the stash dim", sc.Dim) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // A flick of any length lands on the neighbor at most: no window is skipped. | |||
| 82 | + | func TestStashSwipeNeverSkipsAWindow(t *testing.T) { | |||
| 83 | + | for _, n := range []int{3, 10, 40, 200} { | |||
| 84 | + | s, w := newStashSwipe(t, nil) | |||
| 85 | + | s.flick(n, -80) | |||
| 86 | + | if w.stashAt != 1 { | |||
| 87 | + | t.Fatalf("%d updates: stashAt %d, want 1 (one window at most)", n, w.stashAt) | |||
| 88 | + | } | |||
| 89 | + | if !w.stashFocus { | |||
| 90 | + | t.Fatalf("%d updates: the stash lost the focus", n) | |||
| 91 | + | } | |||
| 92 | + | // The view itself never went past the neighbor either. | |||
| 93 | + | if view := float64(w.stashAt) + w.stashView.off; view < 1-0.1 { | |||
| 94 | + | t.Fatalf("%d updates: the view is at %v, past the neighbor at 1", n, view) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // A slow short swipe settles back where it began. | |||
| 100 | + | func TestStashSwipeShortSwipeReturns(t *testing.T) { | |||
| 101 | + | s, w := newStashSwipe(t, nil) | |||
| 102 | + | s.begin() | |||
| 103 | + | s.move(-20) | |||
| 104 | + | s.at += 2 * time.Second | |||
| 105 | + | s.move(-30) | |||
| 106 | + | s.at += 2 * time.Second | |||
| 107 | + | s.end(false) | |||
| 108 | + | if w.stashAt != 2 { | |||
| 109 | + | t.Fatalf("stashAt %d after a slow short swipe, want 2", w.stashAt) | |||
| 110 | + | } | |||
| 111 | + | s.ic.now = s.ic.now.Add(5 * time.Second) | |||
| 112 | + | s.c.animate(s.ic.now, nil) | |||
| 113 | + | if w.stashView.off != 0 || w.stashView.motion.on { | |||
| 114 | + | t.Fatalf("offset %v, spring %v: want settled", w.stashView.off, w.stashView.motion.on) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // A cancelled swipe returns to the selection. | |||
| 119 | + | func TestStashSwipeCancelReturns(t *testing.T) { | |||
| 120 | + | s, w := newStashSwipe(t, nil) | |||
| 121 | + | s.begin() | |||
| 122 | + | for range 20 { | |||
| 123 | + | s.move(-40) | |||
| 124 | + | } | |||
| 125 | + | s.end(true) | |||
| 126 | + | if w.stashAt != 2 { | |||
| 127 | + | t.Fatalf("stashAt %d after a cancel", w.stashAt) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // Past the first or last window the view only stretches: the end of the | |||
| 132 | + | // stash holds the selection. | |||
| 133 | + | func TestStashSwipeEdgeHolds(t *testing.T) { | |||
| 134 | + | s, w := newStashSwipe(t, nil) | |||
| 135 | + | s.flick(20, 80) // right: past the last window | |||
| 136 | + | if w.stashAt != 2 { | |||
| 137 | + | t.Fatalf("stashAt %d past the end, want 2", w.stashAt) | |||
| 138 | + | } | |||
| 139 | + | s.flick(20, -80) | |||
| 140 | + | s.flick(20, -80) | |||
| 141 | + | s.ic.now = s.ic.now.Add(5 * time.Second) | |||
| 142 | + | s.c.animate(s.ic.now, nil) | |||
| 143 | + | s.flick(20, -80) // 4 is first now: past the start | |||
| 144 | + | if w.stashAt != 0 { | |||
| 145 | + | t.Fatalf("stashAt %d, want 0 at the start", w.stashAt) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Natural scroll moves the view the other way, as for columns. | |||
| 150 | + | func TestStashSwipeNaturalScrollInverts(t *testing.T) { | |||
| 151 | + | s, w := newStashSwipe(t, func(c *Core) { c.cfg.Touchpad.NaturalScroll = true }) | |||
| 152 | + | s.flick(10, 80) | |||
| 153 | + | if w.stashAt != 1 { | |||
| 154 | + | t.Fatalf("stashAt %d with natural scroll, want 1", w.stashAt) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // With animations off a swipe takes the discrete path (nothing slides) and | |||
| 159 | + | // still steps one window. | |||
| 160 | + | func TestStashSwipeAnimationsOffStepsOnce(t *testing.T) { | |||
| 161 | + | s, w := newStashSwipe(t, func(c *Core) { c.cfg.Animations.On = false }) | |||
| 162 | + | s.flick(40, -80) | |||
| 163 | + | if w.stashAt != 1 || w.stashView.off != 0 { | |||
| 164 | + | t.Fatalf("stashAt %d, offset %v with animations off", w.stashAt, w.stashView.off) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // A bind during the swipe lets go of it: the rest does nothing. | |||
| 169 | + | func TestStashSwipeDropsWhenSelectionChanges(t *testing.T) { | |||
| 170 | + | s, w := newStashSwipe(t, nil) | |||
| 171 | + | s.begin() | |||
| 172 | + | s.move(-40) | |||
| 173 | + | s.move(-40) | |||
| 174 | + | s.c.applyAction(ActionFocusColumnLeft) | |||
| 175 | + | s.move(-40) | |||
| 176 | + | if g := s.c.swipe; g.mode != swipeDropped { | |||
| 177 | + | t.Fatalf("mode %v, want dropped", g.mode) | |||
| 178 | + | } | |||
| 179 | + | at := w.stashAt | |||
| 180 | + | s.end(false) | |||
| 181 | + | if w.stashAt != at || w.stashView.off != 0 { | |||
| 182 | + | t.Fatalf("stashAt %d then %d, offset %v: the dropped swipe moved the stash", at, w.stashAt, w.stashView.off) | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // Fingers on the stash take a running landing slide where it is. | |||
| 187 | + | func TestStashSwipeCatchesLandingSlide(t *testing.T) { | |||
| 188 | + | s, w := newStashSwipe(t, nil) | |||
| 189 | + | s.flick(10, -80) | |||
| 190 | + | s.ic.now = s.ic.now.Add(30 * time.Millisecond) | |||
| 191 | + | s.c.animate(s.ic.now, nil) | |||
| 192 | + | mid := w.stashView.off | |||
| 193 | + | if mid == 0 || !w.stashView.motion.on { | |||
| 194 | + | t.Fatalf("setup: offset %v, spring %v", mid, w.stashView.motion.on) | |||
| 195 | + | } | |||
| 196 | + | s.begin() | |||
| 197 | + | s.move(-20) | |||
| 198 | + | s.move(-20) | |||
| 199 | + | if w.stashView.motion.on { | |||
| 200 | + | t.Fatal("the landing spring still runs under the fingers") | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // The stash never brings another workspace on screen: its landing must not | |||
| 205 | + | // report a shown workspace, which would respawn the empty slots of a named | |||
| 206 | + | // one. | |||
| 207 | + | func TestStashSwipeLandingShowsNoWorkspace(t *testing.T) { | |||
| 208 | + | s, w := newStashSwipe(t, nil) | |||
| 209 | + | s.begin() | |||
| 210 | + | for range 10 { | |||
| 211 | + | s.move(-80) | |||
| 212 | + | } | |||
| 213 | + | if shown := s.c.swipeEnd(ports.SwipeEnd{Time: s.at}); shown { | |||
| 214 | + | t.Fatal("a stash landing reported a workspace shown") | |||
| 215 | + | } | |||
| 216 | + | if w.stashAt != 1 { | |||
| 217 | + | t.Fatalf("stashAt %d, want the landing on 1", w.stashAt) | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // A native float with the focus over the stash keeps the swipe discrete: the | |||
| 222 | + | // stash must not slide behind it and snap back. One that takes the focus | |||
| 223 | + | // mid-swipe lets go of the stash. | |||
| 224 | + | func TestStashSwipeUnderFocusedFloatIsDiscrete(t *testing.T) { | |||
| 225 | + | s, w := newStashSwipe(t, nil) | |||
| 226 | + | w.AddFloating(9, 100, 80) | |||
| 227 | + | if !w.floatFocus { | |||
| 228 | + | t.Fatal("setup: the float has no focus") | |||
| 229 | + | } | |||
| 230 | + | s.begin() | |||
| 231 | + | s.move(-40) | |||
| 232 | + | if g := s.c.swipe; g.mode != swipeDiscrete { | |||
| 233 | + | t.Fatalf("mode %v, want discrete", g.mode) | |||
| 234 | + | } | |||
| 235 | + | if w.stashView.off != 0 { | |||
| 236 | + | t.Fatalf("stash slid by %v under a focused float", w.stashView.off) | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func TestStashSwipeDropsWhenAFloatTakesTheFocus(t *testing.T) { | |||
| 241 | + | s, w := newStashSwipe(t, nil) | |||
| 242 | + | s.begin() | |||
| 243 | + | s.move(-40) | |||
| 244 | + | s.move(-40) | |||
| 245 | + | w.AddFloating(9, 100, 80) | |||
| 246 | + | s.move(-40) | |||
| 247 | + | if g := s.c.swipe; g.mode != swipeDropped || w.stashView.off != 0 { | |||
| 248 | + | t.Fatalf("mode %v, offset %v: want dropped and settled", g.mode, w.stashView.off) | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // A session lock lets go of the swipe and settles the stash it moved. | |||
| 253 | + | func TestStashSwipeSecurityLockSettlesTheStash(t *testing.T) { | |||
| 254 | + | s, w := newStashSwipe(t, nil) | |||
| 255 | + | s.begin() | |||
| 256 | + | s.move(-40) | |||
| 257 | + | s.move(-40) | |||
| 258 | + | if w.stashView.off == 0 { | |||
| 259 | + | t.Fatal("setup: the stash did not follow") | |||
| 260 | + | } | |||
| 261 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 262 | + | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}) | |||
| 263 | + | s.c.ch.Security = gate | |||
| 264 | + | if !s.c.syncSecurity() { | |||
| 265 | + | t.Fatal("the lock was not taken") | |||
| 266 | + | } | |||
| 267 | + | if s.c.swipe != nil || w.stashView.off != 0 { | |||
| 268 | + | t.Fatalf("swipe %v, offset %v after the lock", s.c.swipe, w.stashView.off) | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // With the focus on another output when the fingers lift, the selection | |||
| 273 | + | // stays and the view springs back. | |||
| 274 | + | func TestStashSwipeLiftOnAnotherOutputSpringsBack(t *testing.T) { | |||
| 275 | + | s, w := newStashSwipe(t, nil) | |||
| 276 | + | s.c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |||
| 277 | + | s.begin() | |||
| 278 | + | s.move(-40) | |||
| 279 | + | s.move(-80) | |||
| 280 | + | s.move(-80) | |||
| 281 | + | s.c.focusScreen = 1 | |||
| 282 | + | s.end(false) | |||
| 283 | + | if w.stashAt != 2 { | |||
| 284 | + | t.Fatalf("stashAt %d, want the selection kept at 2", w.stashAt) | |||
| 285 | + | } | |||
| 286 | + | if !w.stashView.motion.on { | |||
| 287 | + | t.Fatal("the view does not spring back") | |||
| 288 | + | } | |||
| 289 | + | s.ic.now = s.ic.now.Add(5 * time.Second) | |||
| 290 | + | s.c.animate(s.ic.now, nil) | |||
| 291 | + | if w.stashView.off != 0 { | |||
| 292 | + | t.Fatalf("offset %v after the spring", w.stashView.off) | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | // A swipe whose screen is gone lets go of the stash it shifted. | |||
| 297 | + | func TestStashSwipeLosingItsScreenSettlesTheStash(t *testing.T) { | |||
| 298 | + | for _, onEnd := range []bool{false, true} { | |||
| 299 | + | s, w := newStashSwipe(t, nil) | |||
| 300 | + | s.begin() | |||
| 301 | + | s.move(-40) | |||
| 302 | + | s.move(-40) | |||
| 303 | + | if w.stashView.off == 0 { | |||
| 304 | + | t.Fatal("setup: the stash did not follow") | |||
| 305 | + | } | |||
| 306 | + | s.c.swipe.screen = &screen{} // its output unplugged | |||
| 307 | + | if onEnd { | |||
| 308 | + | s.end(false) | |||
| 309 | + | } else { | |||
| 310 | + | s.move(-40) | |||
| 311 | + | } | |||
| 312 | + | if w.stashView.off != 0 || s.c.swipe != nil { | |||
| 313 | + | t.Fatalf("end=%v: offset %v, swipe %v: want settled and gone", onEnd, w.stashView.off, s.c.swipe) | |||
| 314 | + | } | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | // The overview shows the settled state: a stash landing spring stops. | |||
| 319 | + | func TestOverviewStopsStashSlide(t *testing.T) { | |||
| 320 | + | s, w := newStashSwipe(t, nil) | |||
| 321 | + | s.flick(10, -80) | |||
| 322 | + | if !w.stashView.motion.on { | |||
| 323 | + | t.Fatal("setup: no landing spring") | |||
| 324 | + | } | |||
| 325 | + | s.c.cur().mon.ToggleOverview() | |||
| 326 | + | if w.stashView.motion.on || w.stashView.off != 0 { | |||
| 327 | + | t.Fatalf("spring %v, offset %v under the overview", w.stashView.motion.on, w.stashView.off) | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | // Quick chained navigations keep the view where it is drawn, however far it | |||
| 332 | + | // is from the selection: nothing jumps, and the slide settles on the last. | |||
| 333 | + | func TestStashNavigationChainsManyWithoutJump(t *testing.T) { | |||
| 334 | + | c, ic, sc, _ := stashRig(t, true) | |||
| 335 | + | w := sc.mon.Current() | |||
| 336 | + | for id := WindowID(5); id <= 8; id++ { | |||
| 337 | + | sc.mon.AddWindow(id) | |||
| 338 | + | w.FocusID(id) | |||
| 339 | + | c.applyAction(ActionToggleWindowStash) | |||
| 340 | + | } | |||
| 341 | + | settleShown(t, c) | |||
| 342 | + | if len(w.Stash) != 7 { | |||
| 343 | + | t.Fatalf("setup: %d stashed", len(w.Stash)) | |||
| 344 | + | } | |||
| 345 | + | prev := float64(w.stashAt) + w.stashView.off | |||
| 346 | + | for i := range 5 { | |||
| 347 | + | act(c, ic, ActionFocusColumnLeft) | |||
| 348 | + | pos := float64(w.stashAt) + w.stashView.off | |||
| 349 | + | if pos < prev-1e-6 || pos > prev+1e-6 { | |||
| 350 | + | t.Fatalf("navigation %d: the drawn view jumped from %v to %v", i, prev, pos) | |||
| 351 | + | } | |||
| 352 | + | ic.now = ic.now.Add(10 * time.Millisecond) | |||
| 353 | + | c.animate(ic.now, nil) | |||
| 354 | + | prev = float64(w.stashAt) + w.stashView.off | |||
| 355 | + | } | |||
| 356 | + | // The view is still several windows from the selection: the window it | |||
| 357 | + | // is on is drawn there, however far it is from stashAt. | |||
| 358 | + | at := int(math.Round(float64(w.stashAt) + w.stashView.off)) | |||
| 359 | + | if at-w.stashAt < 3 { | |||
| 360 | + | t.Fatalf("setup: the view is %d windows from the selection", at-w.stashAt) | |||
| 361 | + | } | |||
| 362 | + | near := w.Stash[at].ID | |||
| 363 | + | if got := sceneWindow(t, indicatorScene(t, c), near); got.Hidden || got.Rect.W == 0 { | |||
| 364 | + | t.Fatalf("window %d, %d away from the selection, is not drawn under the view: %+v", near, at-w.stashAt, got) | |||
| 365 | + | } | |||
| 366 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 367 | + | c.animate(ic.now, nil) | |||
| 368 | + | if w.stashView.off != 0 || w.stashView.motion.on { | |||
| 369 | + | t.Fatalf("offset %v, spring %v: want settled", w.stashView.off, w.stashView.motion.on) | |||
| 370 | + | } | |||
| 371 | + | if got := sceneWindow(t, indicatorScene(t, c), near); !got.Hidden { | |||
| 372 | + | t.Fatalf("window %d still drawn after the settle: %+v", near, got) | |||
| 373 | + | } | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | // A captured workspace caught mid-slide draws the veil the screen does: | |||
| 377 | + | // a share of Stash.Dim, not the full one and not none. | |||
| 378 | + | func TestCaptureSceneDrawsTheSlidingVeil(t *testing.T) { | |||
| 379 | + | s, w := newStashSwipe(t, nil) | |||
| 380 | + | s.c.cfg.Stash.Dim = 0.5 | |||
| 381 | + | s.begin() | |||
| 382 | + | s.move(-40) | |||
| 383 | + | s.move(-80) | |||
| 384 | + | sc := s.c.screens[0] | |||
| 385 | + | s.c.configures.cw.load(sc, w) | |||
| 386 | + | scene := s.c.captureScene(1) | |||
| 387 | + | if scene == nil { | |||
| 388 | + | t.Fatal("no capture scene") | |||
| 389 | + | } | |||
| 390 | + | var partial bool | |||
| 391 | + | for _, p := range s.c.configures.cw.placements { | |||
| 392 | + | if p.Veil <= 0 || p.Veil >= 1 { | |||
| 393 | + | continue | |||
| 394 | + | } | |||
| 395 | + | partial = true | |||
| 396 | + | got := sceneWindow(t, *scene, p.ID).Dim | |||
| 397 | + | if want := s.c.cfg.Stash.Dim * p.Veil; math.Abs(got-want) > 1e-9 { | |||
| 398 | + | t.Fatalf("window %d captured with dim %v, want %v", p.ID, got, want) | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 401 | + | if !partial { | |||
| 402 | + | t.Fatal("setup: no window has a partial veil") | |||
| 403 | + | } | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | // An output unplugged under the swipe: the host adopts its workspaces, and | |||
| 407 | + | // the stash they carry must not stay shifted. | |||
| 408 | + | func TestStashSwipeUnpluggedOutputSettlesTheAdoptedStash(t *testing.T) { | |||
| 409 | + | s, w := newStashSwipe(t, nil) | |||
| 410 | + | s.c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |||
| 411 | + | s.begin() | |||
| 412 | + | s.move(-40) | |||
| 413 | + | s.move(-80) | |||
| 414 | + | if w.stashView.off == 0 { | |||
| 415 | + | t.Fatal("setup: the stash did not follow") | |||
| 416 | + | } | |||
| 417 | + | s.c.removeScreen("A") | |||
| 418 | + | if s.c.swipe != nil || w.stashView.off != 0 { | |||
| 419 | + | t.Fatalf("swipe %v, offset %v after the unplug", s.c.swipe, w.stashView.off) | |||
| 420 | + | } | |||
| 421 | + | } | |||
M internal/core/transition.go +54 -27
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | // discrete swipe step): a snapshot of what each screen shows is taken before | |
| 13 | 13 | // the action and diffed after it. The state the action leaves is the | |
| 14 | 14 | // settled one; the transition only adds presentation springs over it | |
| 15 | - | // (Workspace.shift, Monitor.switchOff), starting from what was on screen. | |
| 15 | + | // (Workspace.view, stashView, Monitor.switchView), starting from what was on screen. | |
| 16 | 16 | // Nothing here runs for events that come from clients or outputs. | |
| 17 | 17 | // | |
| 18 | 18 | // A window's rect transition (presentation-only rect transition) is presentation too: configures keep | |
| @@ -37,8 +37,14 @@ type viewShot struct { | |||
| 37 | 37 | // one), not at their last frame. An action may stop them | |
| 38 | 38 | // (Monitor.Focus), so the transition reads them here. | |
| 39 | 39 | viewV, switchV float64 | |
| 40 | + | // stashShown, stashAt, stashLen and stashPos are whether the stash was | |
| 41 | + | // on screen, its selection, size and drawn position (selection plus | |
| 42 | + | // stashView.off, in stash windows); stashV is its slide's speed. | |
| 43 | + | stashShown bool | |
| 44 | + | stashAt, stashLen int | |
| 45 | + | stashPos, stashV float64 | |
| 40 | 46 | // 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 | |
| 47 | + | // position in them (index of the current one plus switchView.off); ok is | |
| 42 | 48 | // false when there is none (a shown stash workspace). | |
| 43 | 49 | list []*Workspace | |
| 44 | 50 | pos float64 | |
| @@ -66,9 +72,10 @@ type rectShot struct { | |||
| 66 | 72 | // speed, so an overview toggle carries it over (transitionOverview). | |
| 67 | 73 | fade, fadeV float64 | |
| 68 | 74 | // 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). | |
| 75 | + | // speed; veil is the veil the window's settled placement drew | |
| 76 | + | // (Stash.Dim times Placement.Veil, 0 for another window). An action | |
| 77 | + | // that changes it takes the veil from what was on screen to the new | |
| 78 | + | // one (transitionRects). | |
| 72 | 79 | dim, dimV, veil float64 | |
| 73 | 80 | } | |
| 74 | 81 | ||
| @@ -170,7 +177,7 @@ func (m *rectMotion) show(p *Placement) { | |||
| 170 | 177 | // A peek's Dim is an offset from its veil (publish adds Stash.Dim and | |
| 171 | 178 | // clamps the sum): it may go below 0, down to cancelling the veil. | |
| 172 | 179 | lo := 0.0 | |
| 173 | - | if p.Peek { | |
| 180 | + | if p.Veil > 0 { | |
| 174 | 181 | lo = -1 | |
| 175 | 182 | } | |
| 176 | 183 | p.Dim = max(lo, min(1, p.Dim+m.ddim)) | |
| @@ -614,13 +621,17 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||
| 614 | 621 | s.ws, s.overview = w, m.ov.open | |
| 615 | 622 | if !m.ov.open { | |
| 616 | 623 | // The overview does not scroll or slide: no camera. | |
| 617 | - | s.viewX, s.view = w.ViewX, float64(w.ViewX)+w.shift | |
| 624 | + | s.viewX, s.view = w.ViewX, float64(w.ViewX)+w.view.off | |
| 618 | 625 | } | |
| 619 | - | if w.motion.on { | |
| 620 | - | _, s.viewV = w.motion.sampleAt(now) | |
| 626 | + | if w.view.motion.on { | |
| 627 | + | _, s.viewV = w.view.motion.sampleAt(now) | |
| 621 | 628 | } | |
| 622 | - | if m.switchMotion.on { | |
| 623 | - | _, s.switchV = m.switchMotion.sampleAt(now) | |
| 629 | + | if m.switchView.motion.on { | |
| 630 | + | _, s.switchV = m.switchView.motion.sampleAt(now) | |
| 631 | + | } | |
| 632 | + | s.stashShown, s.stashAt, s.stashLen, s.stashPos = w.stashShown(), w.stashAt, len(w.Stash), float64(w.stashAt)+w.stashView.off | |
| 633 | + | if w.stashView.motion.on { | |
| 634 | + | _, s.stashV = w.stashView.motion.sampleAt(now) | |
| 624 | 635 | } | |
| 625 | 636 | // settledLayout and shown are index-aligned and differ only by | |
| 626 | 637 | // the offsets of sc.rects: the shot keeps both unrounded. The | |
| @@ -651,7 +662,7 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||
| 651 | 662 | } | |
| 652 | 663 | if i := indexOf(list, w); i >= 0 { | |
| 653 | 664 | s.list = append(s.list, list...) | |
| 654 | - | s.pos, s.ok = float64(i)+m.switchOff, true | |
| 665 | + | s.pos, s.ok = float64(i)+m.switchView.off, true | |
| 655 | 666 | } | |
| 656 | 667 | } | |
| 657 | 668 | out = append(out, s) | |
| @@ -701,7 +712,7 @@ func (c *Core) transition(before []viewShot, now time.Time) { | |||
| 701 | 712 | // following reports whether a swipe moves sc's view with the fingers. | |
| 702 | 713 | func (c *Core) following(sc *screen) bool { | |
| 703 | 714 | g := c.swipe | |
| 704 | - | return g != nil && g.screen == sc && (g.mode == swipeColumns || g.mode == swipeWorkspaces) | |
| 715 | + | return g != nil && g.screen == sc && (g.mode == swipeColumns || g.mode == swipeWorkspaces || g.mode == swipeStash) | |
| 705 | 716 | } | |
| 706 | 717 | ||
| 707 | 718 | // transitionCamera slides the columns or the monitor from what b showed. | |
| @@ -710,13 +721,20 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) { | |||
| 710 | 721 | w := m.Current() | |
| 711 | 722 | switch { | |
| 712 | 723 | case w == b.ws: | |
| 724 | + | if stashSlid(b, w) { | |
| 725 | + | // A stash navigation slides the view from where it was drawn: | |
| 726 | + | // the windows keep their settled places (transitionRects). | |
| 727 | + | w.stashView.off = b.stashPos - float64(w.stashAt) | |
| 728 | + | w.stashView.motion = c.spring(w.stashSpring(w.stashView.off, b.stashV), now) | |
| 729 | + | return | |
| 730 | + | } | |
| 713 | 731 | if w.ViewX == b.viewX || !w.slidable() { | |
| 714 | 732 | return | |
| 715 | 733 | } | |
| 716 | 734 | // A running landing slide was retargeted by scroll(): its velocity | |
| 717 | 735 | // 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) | |
| 736 | + | w.view.off = b.view - float64(w.ViewX) | |
| 737 | + | w.view.motion = c.spring(viewSpring(w.view.off, b.viewV), now) | |
| 720 | 738 | case b.ok && m.shown == nil && movedFrom(b, before) == nil: | |
| 721 | 739 | // A screen that received its workspace from another monitor does | |
| 722 | 740 | // not slide to it: a running slide's list (switchList) may still | |
| @@ -729,15 +747,21 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) { | |||
| 729 | 747 | off := b.pos - float64(j) | |
| 730 | 748 | // The snapshot's list is reused by the next action: the slide keeps | |
| 731 | 749 | // its own copy. | |
| 732 | - | m.switchOff, m.switchList = off, slices.Clone(b.list) | |
| 750 | + | m.switchView.off, m.switchList = off, slices.Clone(b.list) | |
| 733 | 751 | if off == 0 || m.framedSwitch() { | |
| 734 | 752 | m.stopSwitch() | |
| 735 | 753 | return | |
| 736 | 754 | } | |
| 737 | - | m.switchMotion = c.spring(m.switchSpring(off, b.switchV), now) | |
| 755 | + | m.switchView.motion = c.spring(m.switchSpring(off, b.switchV), now) | |
| 738 | 756 | } | |
| 739 | 757 | } | |
| 740 | 758 | ||
| 759 | + | // stashSlid reports whether an action moved the selection of the stash b | |
| 760 | + | // showed on w: a navigation, not a toggle, an unstash or a hide. | |
| 761 | + | func stashSlid(b *viewShot, w *Workspace) bool { | |
| 762 | + | return b.stashShown && w.stashAt != b.stashAt && len(w.Stash) == b.stashLen && w.stashShown() | |
| 763 | + | } | |
| 764 | + | ||
| 741 | 765 | // transitionRects starts or retargets the rect motions of the windows whose | |
| 742 | 766 | // layout the action changed. The new layout is measured with the camera as | |
| 743 | 767 | // transitionCamera left it (the view where it was on screen), so a window | |
| @@ -771,10 +795,12 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) { | |||
| 771 | 795 | old := &from.rects[i] | |
| 772 | 796 | oldRect := Rect{X: old.rect.X + dx, Y: old.rect.Y + dy, W: old.rect.W, H: old.rect.H} | |
| 773 | 797 | // 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. | |
| 798 | + | // toggle) changes its veil: the offset takes the one drawn to the | |
| 799 | + | // new one. A stash slide moves the veil with the view, so the veil | |
| 800 | + | // of a navigation matches. Otherwise a running dim motion goes on | |
| 801 | + | // as it is. | |
| 776 | 802 | dimOff := old.dim + old.veil - c.peekDim(p) | |
| 777 | - | dimMoves := old.veil != c.peekDim(p) | |
| 803 | + | dimMoves := math.Abs(old.veil-c.peekDim(p)) > veilEps | |
| 778 | 804 | if oldRect == p.Rect && !dimMoves { | |
| 779 | 805 | // The settled rect did not change: a running motion goes on | |
| 780 | 806 | // as it is, and there is none to start. | |
| @@ -808,8 +834,8 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) { | |||
| 808 | 834 | ||
| 809 | 835 | // transitionStash animates the stash windows an action shows or hides on | |
| 810 | 836 | // 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 | |
| 837 | + | // that closes the stash; a navigation slides the view instead, see | |
| 838 | + | // stashSlid): one drawn after and not before appears (appearAt), one | |
| 813 | 839 | // drawn before and hidden after leaves from its last settled placement | |
| 814 | 840 | // (leave). A window that is gone from the layout (unstashed, moved) or | |
| 815 | 841 | // fullscreen is not touched: its rect motion, if any, is transitionRects's. | |
| @@ -819,7 +845,7 @@ func (c *Core) transitionStash(b *viewShot, now time.Time) { | |||
| 819 | 845 | sc := b.sc | |
| 820 | 846 | m := sc.mon | |
| 821 | 847 | w := m.Current() | |
| 822 | - | if w != b.ws || len(w.Stash) == 0 || c.security.Protected || w.cover() != 0 { | |
| 848 | + | if w != b.ws || len(w.Stash) == 0 || c.security.Protected || w.cover() != 0 || stashSlid(b, w) { | |
| 823 | 849 | return | |
| 824 | 850 | } | |
| 825 | 851 | layout := m.Layout() | |
| @@ -841,14 +867,15 @@ func (c *Core) transitionStash(b *viewShot, now time.Time) { | |||
| 841 | 867 | } | |
| 842 | 868 | } | |
| 843 | 869 | ||
| 870 | + | // veilEps absorbs the float drift between a slide's drawn veil and the | |
| 871 | + | // settled one: a veil that differs by less is the same. | |
| 872 | + | const veilEps = 1e-9 | |
| 873 | + | ||
| 844 | 874 | // peekDim is the veil the settled placement p draws: the configured stash | |
| 845 | 875 | // dim for a peek, none for another window. The placement's Dim is an | |
| 846 | 876 | // offset from it. | |
| 847 | 877 | func (c *Core) peekDim(p Placement) float64 { | |
| 848 | - | if p.Peek { | |
| 849 | - | return c.cfg.Stash.Dim | |
| 850 | - | } | |
| 851 | - | return 0 | |
| 878 | + | return c.cfg.Stash.Dim * p.Veil | |
| 852 | 879 | } | |
| 853 | 880 | ||
| 854 | 881 | // transitionOverview starts the motions of an action that opened or closed | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | // discrete swipe step): a snapshot of what each screen shows is taken before | 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 | 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 | 14 | // settled one; the transition only adds presentation springs over it | ||
| 15 | - | // (Workspace.shift, Monitor.switchOff), starting from what was on screen. | 15 | + | // (Workspace.view, stashView, Monitor.switchView), starting from what was on screen. |
| 16 | // Nothing here runs for events that come from clients or outputs. | 16 | // Nothing here runs for events that come from clients or outputs. | ||
| 17 | // | 17 | // | ||
| 18 | // A window's rect transition (presentation-only rect transition) is presentation too: configures keep | 18 | // A window's rect transition (presentation-only rect transition) is presentation too: configures keep | ||
| @@ -37,8 +37,14 @@ type viewShot struct { | |||||
| 37 | // one), not at their last frame. An action may stop them | 37 | // one), not at their last frame. An action may stop them | ||
| 38 | // (Monitor.Focus), so the transition reads them here. | 38 | // (Monitor.Focus), so the transition reads them here. | ||
| 39 | viewV, switchV float64 | 39 | viewV, switchV float64 | ||
| 40 | + | // stashShown, stashAt, stashLen and stashPos are whether the stash was | |||
| 41 | + | // on screen, its selection, size and drawn position (selection plus | |||
| 42 | + | // stashView.off, in stash windows); stashV is its slide's speed. | |||
| 43 | + | stashShown bool | |||
| 44 | + | stashAt, stashLen int | |||
| 45 | + | stashPos, stashV float64 | |||
| 40 | // list and pos are the numbered workspaces and the monitor's fractional | 46 | // 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 | 47 | + | // position in them (index of the current one plus switchView.off); ok is |
| 42 | // false when there is none (a shown stash workspace). | 48 | // false when there is none (a shown stash workspace). | ||
| 43 | list []*Workspace | 49 | list []*Workspace | ||
| 44 | pos float64 | 50 | pos float64 | ||
| @@ -66,9 +72,10 @@ type rectShot struct { | |||||
| 66 | // speed, so an overview toggle carries it over (transitionOverview). | 72 | // speed, so an overview toggle carries it over (transitionOverview). | ||
| 67 | fade, fadeV float64 | 73 | fade, fadeV float64 | ||
| 68 | // dim and dimV are the dim offset of a running dim motion and its | 74 | // 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 | 75 | + | // speed; veil is the veil the window's settled placement drew |
| 70 | - | // (Stash.Dim, 0 for another window). A stash navigation takes the | 76 | + | // (Stash.Dim times Placement.Veil, 0 for another window). An action |
| 71 | - | // veil from what was on screen to the new one (transitionRects). | 77 | + | // that changes it takes the veil from what was on screen to the new |
| 78 | + | // one (transitionRects). | |||
| 72 | dim, dimV, veil float64 | 79 | dim, dimV, veil float64 | ||
| 73 | } | 80 | } | ||
| 74 | 81 | ||||
| @@ -170,7 +177,7 @@ func (m *rectMotion) show(p *Placement) { | |||||
| 170 | // A peek's Dim is an offset from its veil (publish adds Stash.Dim and | 177 | // 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. | 178 | // clamps the sum): it may go below 0, down to cancelling the veil. | ||
| 172 | lo := 0.0 | 179 | lo := 0.0 | ||
| 173 | - | if p.Peek { | 180 | + | if p.Veil > 0 { |
| 174 | lo = -1 | 181 | lo = -1 | ||
| 175 | } | 182 | } | ||
| 176 | p.Dim = max(lo, min(1, p.Dim+m.ddim)) | 183 | p.Dim = max(lo, min(1, p.Dim+m.ddim)) | ||
| @@ -614,13 +621,17 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||||
| 614 | s.ws, s.overview = w, m.ov.open | 621 | s.ws, s.overview = w, m.ov.open | ||
| 615 | if !m.ov.open { | 622 | if !m.ov.open { | ||
| 616 | // The overview does not scroll or slide: no camera. | 623 | // The overview does not scroll or slide: no camera. | ||
| 617 | - | s.viewX, s.view = w.ViewX, float64(w.ViewX)+w.shift | 624 | + | s.viewX, s.view = w.ViewX, float64(w.ViewX)+w.view.off |
| 618 | } | 625 | } | ||
| 619 | - | if w.motion.on { | 626 | + | if w.view.motion.on { |
| 620 | - | _, s.viewV = w.motion.sampleAt(now) | 627 | + | _, s.viewV = w.view.motion.sampleAt(now) |
| 621 | } | 628 | } | ||
| 622 | - | if m.switchMotion.on { | 629 | + | if m.switchView.motion.on { |
| 623 | - | _, s.switchV = m.switchMotion.sampleAt(now) | 630 | + | _, s.switchV = m.switchView.motion.sampleAt(now) |
| 631 | + | } | |||
| 632 | + | s.stashShown, s.stashAt, s.stashLen, s.stashPos = w.stashShown(), w.stashAt, len(w.Stash), float64(w.stashAt)+w.stashView.off | |||
| 633 | + | if w.stashView.motion.on { | |||
| 634 | + | _, s.stashV = w.stashView.motion.sampleAt(now) | |||
| 624 | } | 635 | } | ||
| 625 | // settledLayout and shown are index-aligned and differ only by | 636 | // settledLayout and shown are index-aligned and differ only by | ||
| 626 | // the offsets of sc.rects: the shot keeps both unrounded. The | 637 | // the offsets of sc.rects: the shot keeps both unrounded. The | ||
| @@ -651,7 +662,7 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||||
| 651 | } | 662 | } | ||
| 652 | if i := indexOf(list, w); i >= 0 { | 663 | if i := indexOf(list, w); i >= 0 { | ||
| 653 | s.list = append(s.list, list...) | 664 | s.list = append(s.list, list...) | ||
| 654 | - | s.pos, s.ok = float64(i)+m.switchOff, true | 665 | + | s.pos, s.ok = float64(i)+m.switchView.off, true |
| 655 | } | 666 | } | ||
| 656 | } | 667 | } | ||
| 657 | out = append(out, s) | 668 | out = append(out, s) | ||
| @@ -701,7 +712,7 @@ func (c *Core) transition(before []viewShot, now time.Time) { | |||||
| 701 | // following reports whether a swipe moves sc's view with the fingers. | 712 | // following reports whether a swipe moves sc's view with the fingers. | ||
| 702 | func (c *Core) following(sc *screen) bool { | 713 | func (c *Core) following(sc *screen) bool { | ||
| 703 | g := c.swipe | 714 | g := c.swipe | ||
| 704 | - | return g != nil && g.screen == sc && (g.mode == swipeColumns || g.mode == swipeWorkspaces) | 715 | + | return g != nil && g.screen == sc && (g.mode == swipeColumns || g.mode == swipeWorkspaces || g.mode == swipeStash) |
| 705 | } | 716 | } | ||
| 706 | 717 | ||||
| 707 | // transitionCamera slides the columns or the monitor from what b showed. | 718 | // transitionCamera slides the columns or the monitor from what b showed. | ||
| @@ -710,13 +721,20 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) { | |||||
| 710 | w := m.Current() | 721 | w := m.Current() | ||
| 711 | switch { | 722 | switch { | ||
| 712 | case w == b.ws: | 723 | case w == b.ws: | ||
| 724 | + | if stashSlid(b, w) { | |||
| 725 | + | // A stash navigation slides the view from where it was drawn: | |||
| 726 | + | // the windows keep their settled places (transitionRects). | |||
| 727 | + | w.stashView.off = b.stashPos - float64(w.stashAt) | |||
| 728 | + | w.stashView.motion = c.spring(w.stashSpring(w.stashView.off, b.stashV), now) | |||
| 729 | + | return | |||
| 730 | + | } | |||
| 713 | if w.ViewX == b.viewX || !w.slidable() { | 731 | if w.ViewX == b.viewX || !w.slidable() { | ||
| 714 | return | 732 | return | ||
| 715 | } | 733 | } | ||
| 716 | // A running landing slide was retargeted by scroll(): its velocity | 734 | // A running landing slide was retargeted by scroll(): its velocity | ||
| 717 | // carries over, and the spring starts now like every action's. | 735 | // carries over, and the spring starts now like every action's. | ||
| 718 | - | w.shift = b.view - float64(w.ViewX) | 736 | + | w.view.off = b.view - float64(w.ViewX) |
| 719 | - | w.motion = c.spring(viewSpring(w.shift, b.viewV), now) | 737 | + | w.view.motion = c.spring(viewSpring(w.view.off, b.viewV), now) |
| 720 | case b.ok && m.shown == nil && movedFrom(b, before) == nil: | 738 | case b.ok && m.shown == nil && movedFrom(b, before) == nil: | ||
| 721 | // A screen that received its workspace from another monitor does | 739 | // A screen that received its workspace from another monitor does | ||
| 722 | // not slide to it: a running slide's list (switchList) may still | 740 | // not slide to it: a running slide's list (switchList) may still | ||
| @@ -729,15 +747,21 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) { | |||||
| 729 | off := b.pos - float64(j) | 747 | off := b.pos - float64(j) | ||
| 730 | // The snapshot's list is reused by the next action: the slide keeps | 748 | // The snapshot's list is reused by the next action: the slide keeps | ||
| 731 | // its own copy. | 749 | // its own copy. | ||
| 732 | - | m.switchOff, m.switchList = off, slices.Clone(b.list) | 750 | + | m.switchView.off, m.switchList = off, slices.Clone(b.list) |
| 733 | if off == 0 || m.framedSwitch() { | 751 | if off == 0 || m.framedSwitch() { | ||
| 734 | m.stopSwitch() | 752 | m.stopSwitch() | ||
| 735 | return | 753 | return | ||
| 736 | } | 754 | } | ||
| 737 | - | m.switchMotion = c.spring(m.switchSpring(off, b.switchV), now) | 755 | + | m.switchView.motion = c.spring(m.switchSpring(off, b.switchV), now) |
| 738 | } | 756 | } | ||
| 739 | } | 757 | } | ||
| 740 | 758 | ||||
| 759 | + | // stashSlid reports whether an action moved the selection of the stash b | |||
| 760 | + | // showed on w: a navigation, not a toggle, an unstash or a hide. | |||
| 761 | + | func stashSlid(b *viewShot, w *Workspace) bool { | |||
| 762 | + | return b.stashShown && w.stashAt != b.stashAt && len(w.Stash) == b.stashLen && w.stashShown() | |||
| 763 | + | } | |||
| 764 | + | ||||
| 741 | // transitionRects starts or retargets the rect motions of the windows whose | 765 | // 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 | 766 | // 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 | 767 | // transitionCamera left it (the view where it was on screen), so a window | ||
| @@ -771,10 +795,12 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) { | |||||
| 771 | old := &from.rects[i] | 795 | 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} | 796 | 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 | 797 | // A window that becomes a peek or stops being one (a stash | ||
| 774 | - | // navigation) changes its veil: the offset takes the one drawn to | 798 | + | // toggle) changes its veil: the offset takes the one drawn to the |
| 775 | - | // the new one. Otherwise a running dim motion goes on as it is. | 799 | + | // new one. A stash slide moves the veil with the view, so the veil |
| 800 | + | // of a navigation matches. Otherwise a running dim motion goes on | |||
| 801 | + | // as it is. | |||
| 776 | dimOff := old.dim + old.veil - c.peekDim(p) | 802 | dimOff := old.dim + old.veil - c.peekDim(p) | ||
| 777 | - | dimMoves := old.veil != c.peekDim(p) | 803 | + | dimMoves := math.Abs(old.veil-c.peekDim(p)) > veilEps |
| 778 | if oldRect == p.Rect && !dimMoves { | 804 | if oldRect == p.Rect && !dimMoves { | ||
| 779 | // The settled rect did not change: a running motion goes on | 805 | // The settled rect did not change: a running motion goes on | ||
| 780 | // as it is, and there is none to start. | 806 | // as it is, and there is none to start. | ||
| @@ -808,8 +834,8 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) { | |||||
| 808 | 834 | ||||
| 809 | // transitionStash animates the stash windows an action shows or hides on | 835 | // 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 | 836 | // 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 | 837 | + | // that closes the stash; a navigation slides the view instead, see |
| 812 | - | // sends it off): one drawn after and not before appears (appearAt), one | 838 | + | // stashSlid): one drawn after and not before appears (appearAt), one |
| 813 | // drawn before and hidden after leaves from its last settled placement | 839 | // drawn before and hidden after leaves from its last settled placement | ||
| 814 | // (leave). A window that is gone from the layout (unstashed, moved) or | 840 | // (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. | 841 | // fullscreen is not touched: its rect motion, if any, is transitionRects's. | ||
| @@ -819,7 +845,7 @@ func (c *Core) transitionStash(b *viewShot, now time.Time) { | |||||
| 819 | sc := b.sc | 845 | sc := b.sc | ||
| 820 | m := sc.mon | 846 | m := sc.mon | ||
| 821 | w := m.Current() | 847 | w := m.Current() | ||
| 822 | - | if w != b.ws || len(w.Stash) == 0 || c.security.Protected || w.cover() != 0 { | 848 | + | if w != b.ws || len(w.Stash) == 0 || c.security.Protected || w.cover() != 0 || stashSlid(b, w) { |
| 823 | return | 849 | return | ||
| 824 | } | 850 | } | ||
| 825 | layout := m.Layout() | 851 | layout := m.Layout() | ||
| @@ -841,14 +867,15 @@ func (c *Core) transitionStash(b *viewShot, now time.Time) { | |||||
| 841 | } | 867 | } | ||
| 842 | } | 868 | } | ||
| 843 | 869 | ||||
| 870 | + | // veilEps absorbs the float drift between a slide's drawn veil and the | |||
| 871 | + | // settled one: a veil that differs by less is the same. | |||
| 872 | + | const veilEps = 1e-9 | |||
| 873 | + | ||||
| 844 | // peekDim is the veil the settled placement p draws: the configured stash | 874 | // 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 | 875 | // dim for a peek, none for another window. The placement's Dim is an | ||
| 846 | // offset from it. | 876 | // offset from it. | ||
| 847 | func (c *Core) peekDim(p Placement) float64 { | 877 | func (c *Core) peekDim(p Placement) float64 { | ||
| 848 | - | if p.Peek { | 878 | + | return c.cfg.Stash.Dim * p.Veil |
| 849 | - | return c.cfg.Stash.Dim | |||
| 850 | - | } | |||
| 851 | - | return 0 | |||
| 852 | } | 879 | } | ||
| 853 | 880 | ||||
| 854 | // transitionOverview starts the motions of an action that opened or closed | 881 | // transitionOverview starts the motions of an action that opened or closed | ||
M internal/core/transition_chain_internal_test.go +10 -10
| @@ -107,7 +107,7 @@ func chainCases() []chainCase { | |||
| 107 | 107 | } | |
| 108 | 108 | }, []Action{ActionFocusColumnLeft, ActionFocusColumnLeft, ActionFocusColumnLeft}, | |
| 109 | 109 | []Action{ActionFocusColumnRight, ActionFocusColumnRight, ActionFocusColumnRight}, | |
| 110 | - | func(c *Core) motion { return c.cur().mon.Current().motion }}, | |
| 110 | + | func(c *Core) motion { return c.cur().mon.Current().view.motion }}, | |
| 111 | 111 | {"workspace switch", func(c *Core) { | |
| 112 | 112 | m := c.cur().mon | |
| 113 | 113 | m.AddWindow(1) | |
| @@ -115,7 +115,7 @@ func chainCases() []chainCase { | |||
| 115 | 115 | m.AddWindow(2) | |
| 116 | 116 | m.Focus(0) | |
| 117 | 117 | }, []Action{ActionFocusWorkspaceDown}, []Action{ActionFocusWorkspaceDown}, | |
| 118 | - | func(c *Core) motion { return c.cur().mon.switchMotion }}, | |
| 118 | + | func(c *Core) motion { return c.cur().mon.switchView.motion }}, | |
| 119 | 119 | {"rect", func(c *Core) { | |
| 120 | 120 | m := c.cur().mon | |
| 121 | 121 | m.AddWindow(1) | |
| @@ -138,7 +138,7 @@ func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) { | |||
| 138 | 138 | settleShown(t, c) | |
| 139 | 139 | for _, sc := range []*screen{a, b} { | |
| 140 | 140 | w := sc.mon.Current() | |
| 141 | - | w.shift, w.motion = 100, c.spring(viewSpring(100, 0), ic.now) | |
| 141 | + | w.view.off, w.view.motion = 100, c.spring(viewSpring(100, 0), ic.now) | |
| 142 | 142 | } | |
| 143 | 143 | // The one frame timer is made on the first arm and reset afterwards: | |
| 144 | 144 | // a re-arm shows as a Reset on the clock. | |
| @@ -154,7 +154,7 @@ func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) { | |||
| 154 | 154 | } | |
| 155 | 155 | ||
| 156 | 156 | // With nothing else animating the flip's own publish decides. | |
| 157 | - | b.mon.Current().stopSlide() | |
| 157 | + | b.mon.Current().view.stop() | |
| 158 | 158 | if err := c.step(context.Background(), a); err != nil { | |
| 159 | 159 | t.Fatal(err) | |
| 160 | 160 | } | |
| @@ -207,7 +207,7 @@ func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) { | |||
| 207 | 207 | } | |
| 208 | 208 | settleShown(t, c) | |
| 209 | 209 | w := sc.mon.Current() | |
| 210 | - | w.shift = 25 | |
| 210 | + | w.view.off = 25 | |
| 211 | 211 | c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns} | |
| 212 | 212 | before := c.snapshot(ic.now) | |
| 213 | 213 | viewX := w.ViewX | |
| @@ -218,8 +218,8 @@ func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) { | |||
| 218 | 218 | t.Fatal("the focus did not move the view: the case checks nothing") | |
| 219 | 219 | } | |
| 220 | 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) | |
| 221 | + | if w.view.motion.on || w.view.off != 25 { | |
| 222 | + | t.Fatalf("camera motion %v, shift %v: the transition took over the fingers' view", w.view.motion.on, w.view.off) | |
| 223 | 223 | } | |
| 224 | 224 | } | |
| 225 | 225 | ||
| @@ -243,11 +243,11 @@ func TestNoSwitchMotionForSizedWorkspace(t *testing.T) { | |||
| 243 | 243 | before := c.snapshot(ic.now) | |
| 244 | 244 | m.Focus(1) | |
| 245 | 245 | c.transition(before, ic.now) | |
| 246 | - | if got := m.switchMotion.on; got == tc.sized { | |
| 246 | + | if got := m.switchView.motion.on; got == tc.sized { | |
| 247 | 247 | t.Fatalf("switch motion %v with a sized workspace %v", got, tc.sized) | |
| 248 | 248 | } | |
| 249 | - | if tc.sized && m.switchOff != 0 { | |
| 250 | - | t.Fatalf("switchOff %v left for a framed switch", m.switchOff) | |
| 249 | + | if tc.sized && m.switchView.off != 0 { | |
| 250 | + | t.Fatalf("switchOff %v left for a framed switch", m.switchView.off) | |
| 251 | 251 | } | |
| 252 | 252 | }) | |
| 253 | 253 | } | |
| @@ -107,7 +107,7 @@ func chainCases() []chainCase { | |||||
| 107 | } | 107 | } | ||
| 108 | }, []Action{ActionFocusColumnLeft, ActionFocusColumnLeft, ActionFocusColumnLeft}, | 108 | }, []Action{ActionFocusColumnLeft, ActionFocusColumnLeft, ActionFocusColumnLeft}, | ||
| 109 | []Action{ActionFocusColumnRight, ActionFocusColumnRight, ActionFocusColumnRight}, | 109 | []Action{ActionFocusColumnRight, ActionFocusColumnRight, ActionFocusColumnRight}, | ||
| 110 | - | func(c *Core) motion { return c.cur().mon.Current().motion }}, | 110 | + | func(c *Core) motion { return c.cur().mon.Current().view.motion }}, |
| 111 | {"workspace switch", func(c *Core) { | 111 | {"workspace switch", func(c *Core) { | ||
| 112 | m := c.cur().mon | 112 | m := c.cur().mon | ||
| 113 | m.AddWindow(1) | 113 | m.AddWindow(1) | ||
| @@ -115,7 +115,7 @@ func chainCases() []chainCase { | |||||
| 115 | m.AddWindow(2) | 115 | m.AddWindow(2) | ||
| 116 | m.Focus(0) | 116 | m.Focus(0) | ||
| 117 | }, []Action{ActionFocusWorkspaceDown}, []Action{ActionFocusWorkspaceDown}, | 117 | }, []Action{ActionFocusWorkspaceDown}, []Action{ActionFocusWorkspaceDown}, | ||
| 118 | - | func(c *Core) motion { return c.cur().mon.switchMotion }}, | 118 | + | func(c *Core) motion { return c.cur().mon.switchView.motion }}, |
| 119 | {"rect", func(c *Core) { | 119 | {"rect", func(c *Core) { | ||
| 120 | m := c.cur().mon | 120 | m := c.cur().mon | ||
| 121 | m.AddWindow(1) | 121 | m.AddWindow(1) | ||
| @@ -138,7 +138,7 @@ func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) { | |||||
| 138 | settleShown(t, c) | 138 | settleShown(t, c) | ||
| 139 | for _, sc := range []*screen{a, b} { | 139 | for _, sc := range []*screen{a, b} { | ||
| 140 | w := sc.mon.Current() | 140 | w := sc.mon.Current() | ||
| 141 | - | w.shift, w.motion = 100, c.spring(viewSpring(100, 0), ic.now) | 141 | + | w.view.off, w.view.motion = 100, c.spring(viewSpring(100, 0), ic.now) |
| 142 | } | 142 | } | ||
| 143 | // The one frame timer is made on the first arm and reset afterwards: | 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. | 144 | // a re-arm shows as a Reset on the clock. | ||
| @@ -154,7 +154,7 @@ func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) { | |||||
| 154 | } | 154 | } | ||
| 155 | 155 | ||||
| 156 | // With nothing else animating the flip's own publish decides. | 156 | // With nothing else animating the flip's own publish decides. | ||
| 157 | - | b.mon.Current().stopSlide() | 157 | + | b.mon.Current().view.stop() |
| 158 | if err := c.step(context.Background(), a); err != nil { | 158 | if err := c.step(context.Background(), a); err != nil { | ||
| 159 | t.Fatal(err) | 159 | t.Fatal(err) | ||
| 160 | } | 160 | } | ||
| @@ -207,7 +207,7 @@ func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) { | |||||
| 207 | } | 207 | } | ||
| 208 | settleShown(t, c) | 208 | settleShown(t, c) | ||
| 209 | w := sc.mon.Current() | 209 | w := sc.mon.Current() | ||
| 210 | - | w.shift = 25 | 210 | + | w.view.off = 25 |
| 211 | c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns} | 211 | c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns} | ||
| 212 | before := c.snapshot(ic.now) | 212 | before := c.snapshot(ic.now) | ||
| 213 | viewX := w.ViewX | 213 | viewX := w.ViewX | ||
| @@ -218,8 +218,8 @@ func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) { | |||||
| 218 | t.Fatal("the focus did not move the view: the case checks nothing") | 218 | t.Fatal("the focus did not move the view: the case checks nothing") | ||
| 219 | } | 219 | } | ||
| 220 | c.transition(before, ic.now) | 220 | c.transition(before, ic.now) | ||
| 221 | - | if w.motion.on || w.shift != 25 { | 221 | + | if w.view.motion.on || w.view.off != 25 { |
| 222 | - | t.Fatalf("camera motion %v, shift %v: the transition took over the fingers' view", w.motion.on, w.shift) | 222 | + | t.Fatalf("camera motion %v, shift %v: the transition took over the fingers' view", w.view.motion.on, w.view.off) |
| 223 | } | 223 | } | ||
| 224 | } | 224 | } | ||
| 225 | 225 | ||||
| @@ -243,11 +243,11 @@ func TestNoSwitchMotionForSizedWorkspace(t *testing.T) { | |||||
| 243 | before := c.snapshot(ic.now) | 243 | before := c.snapshot(ic.now) | ||
| 244 | m.Focus(1) | 244 | m.Focus(1) | ||
| 245 | c.transition(before, ic.now) | 245 | c.transition(before, ic.now) | ||
| 246 | - | if got := m.switchMotion.on; got == tc.sized { | 246 | + | if got := m.switchView.motion.on; got == tc.sized { |
| 247 | t.Fatalf("switch motion %v with a sized workspace %v", got, tc.sized) | 247 | t.Fatalf("switch motion %v with a sized workspace %v", got, tc.sized) | ||
| 248 | } | 248 | } | ||
| 249 | - | if tc.sized && m.switchOff != 0 { | 249 | + | if tc.sized && m.switchView.off != 0 { |
| 250 | - | t.Fatalf("switchOff %v left for a framed switch", m.switchOff) | 250 | + | t.Fatalf("switchOff %v left for a framed switch", m.switchView.off) |
| 251 | } | 251 | } | ||
| 252 | }) | 252 | }) | ||
| 253 | } | 253 | } | ||
M internal/core/transition_overview_internal_test.go +2 -2
| @@ -335,8 +335,8 @@ func TestOverviewStartsNoCameraSlide(t *testing.T) { | |||
| 335 | 335 | ic.now = ic.now.Add(5 * time.Second) | |
| 336 | 336 | noCamera := func(when string) { | |
| 337 | 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) | |
| 338 | + | if m.switchView.motion.on || m.Current().view.motion.on { | |
| 339 | + | t.Fatalf("%s: a camera spring runs (switch %v, view %v)", when, m.switchView.motion.on, m.Current().view.motion.on) | |
| 340 | 340 | } | |
| 341 | 341 | } | |
| 342 | 342 | noCamera("before") | |
| @@ -335,8 +335,8 @@ func TestOverviewStartsNoCameraSlide(t *testing.T) { | |||||
| 335 | ic.now = ic.now.Add(5 * time.Second) | 335 | ic.now = ic.now.Add(5 * time.Second) | ||
| 336 | noCamera := func(when string) { | 336 | noCamera := func(when string) { | ||
| 337 | t.Helper() | 337 | t.Helper() | ||
| 338 | - | if m.switchMotion.on || m.Current().motion.on { | 338 | + | if m.switchView.motion.on || m.Current().view.motion.on { |
| 339 | - | t.Fatalf("%s: a camera spring runs (switch %v, view %v)", when, m.switchMotion.on, m.Current().motion.on) | 339 | + | t.Fatalf("%s: a camera spring runs (switch %v, view %v)", when, m.switchView.motion.on, m.Current().view.motion.on) |
| 340 | } | 340 | } | ||
| 341 | } | 341 | } | ||
| 342 | noCamera("before") | 342 | noCamera("before") | ||
M internal/core/transition_stash_internal_test.go +31 -27
| @@ -267,10 +267,11 @@ func TestOverviewPileScalesFromStash(t *testing.T) { | |||
| 267 | 267 | } | |
| 268 | 268 | } | |
| 269 | 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) { | |
| 270 | + | // A navigation slides the stash view: the windows keep the places they | |
| 271 | + | // were drawn at and move with one spring, with no rect motion of their own. | |
| 272 | + | // What the view brings in is off screen until it reaches the margin; what | |
| 273 | + | // it sends off goes once the slide ends. | |
| 274 | + | func TestStashNavigationSlides(t *testing.T) { | |
| 274 | 275 | c, ic, sc, cmds := stashRig(t, true) | |
| 275 | 276 | w := sc.mon.Current() | |
| 276 | 277 | w.AddWindow(5) | |
| @@ -280,32 +281,36 @@ func TestStashNavigationAppearsAndLeaves(t *testing.T) { | |||
| 280 | 281 | t.Fatalf("setup: stash %+v at %d", w.Stash, w.stashAt) | |
| 281 | 282 | } | |
| 282 | 283 | settleShown(t, c) | |
| 283 | - | act(c, ic, ActionFocusColumnLeft) // 2 selected, 3 comes in, 5 stays a peek | |
| 284 | + | centre := sceneWindow(t, indicatorScene(t, c), 5).Rect | |
| 285 | + | act(c, ic, ActionFocusColumnLeft) // 2 selected, 3 comes in, 5 becomes a peek | |
| 284 | 286 | 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 | + | if got := sceneWindow(t, s, 5); got.Hidden || got.Rect != centre || got.Dim != 0 { | |
| 288 | + | t.Fatalf("window 5 jumped: %+v, want %+v unveiled", got, centre) | |
| 287 | 289 | } | |
| 288 | - | if w := sceneWindow(t, s, 5); w.Hidden || w.Fade != 0 { | |
| 289 | - | t.Fatalf("window 5 stays a peek: %+v", w) | |
| 290 | + | if got := sceneWindow(t, s, 3); !got.Hidden { | |
| 291 | + | t.Fatalf("window 3 is on screen before the slide: %+v", got) | |
| 292 | + | } | |
| 293 | + | if len(sc.rects) != 0 || !w.stashView.motion.on { | |
| 294 | + | t.Fatalf("%d rect motions, slide %v: the view slides alone", len(sc.rects), w.stashView.motion.on) | |
| 295 | + | } | |
| 296 | + | if got := frame(t, c, ic, 40*time.Millisecond); !(sceneWindow(t, got, 5).Dim > 0) { | |
| 297 | + | t.Fatal("window 5 has no veil mid-slide") | |
| 290 | 298 | } | |
| 291 | 299 | 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) | |
| 300 | + | if got := sceneWindow(t, settledAll, 3); got.Hidden || got.Dim != 0.5 { | |
| 301 | + | t.Fatalf("window 3 settled %+v", got) | |
| 294 | 302 | } | |
| 295 | 303 | drainConfigures(cmds) | |
| 296 | 304 | act(c, ic, ActionFocusColumnLeft) // 3 selected, 4 comes in, 5 goes | |
| 305 | + | frame(t, c, ic, 5*time.Second) | |
| 297 | 306 | 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) | |
| 307 | + | if got := sceneWindow(t, s, 5); !got.Hidden { | |
| 308 | + | t.Fatalf("window 5 still drawn after the settle: %+v", got) | |
| 303 | 309 | } | |
| 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) | |
| 310 | + | if got := sceneWindow(t, s, 4); got.Hidden { | |
| 311 | + | t.Fatalf("window 4 not at the margin: %+v", got) | |
| 307 | 312 | } | |
| 308 | - | if c.animating() { | |
| 313 | + | if c.animating() || w.stashView.off != 0 { | |
| 309 | 314 | t.Fatal("still animating") | |
| 310 | 315 | } | |
| 311 | 316 | } | |
| @@ -381,18 +386,17 @@ func TestStashShowDuringHideDoesNotJump(t *testing.T) { | |||
| 381 | 386 | } | |
| 382 | 387 | } | |
| 383 | 388 | ||
| 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) | |
| 389 | + | // A second navigation during the slide goes on from the view as drawn: no | |
| 390 | + | // window jumps, and the one sent off goes once the slide ends. | |
| 391 | + | func TestStashNavigationChainsFromSlide(t *testing.T) { | |
| 392 | + | c, ic, _, _ := stashRig(t, true) | |
| 388 | 393 | // 2 selected, 3 peeks left. Left: 3 centred, 2 peeks right, 4 enters. | |
| 389 | 394 | act(c, ic, ActionFocusColumnLeft) | |
| 390 | 395 | indicatorScene(t, c) | |
| 391 | 396 | s := frame(t, c, ic, 30*time.Millisecond) | |
| 392 | 397 | 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") | |
| 398 | + | if !c.cur().mon.Current().stashView.motion.on { | |
| 399 | + | t.Fatal("setup: the slide already ended") | |
| 396 | 400 | } | |
| 397 | 401 | // Left again: 4 centred, 3 peeks right, 2 goes off. | |
| 398 | 402 | act(c, ic, ActionFocusColumnLeft) | |
| @@ -267,10 +267,11 @@ func TestOverviewPileScalesFromStash(t *testing.T) { | |||||
| 267 | } | 267 | } | ||
| 268 | } | 268 | } | ||
| 269 | 269 | ||||
| 270 | - | // A navigation that brings a window to a margin makes it appear, one that | 270 | + | // A navigation slides the stash view: the windows keep the places they |
| 271 | - | // sends a peek off screen makes it leave; the others only change their veil | 271 | + | // were drawn at and move with one spring, with no rect motion of their own. |
| 272 | - | // and rect. | 272 | + | // What the view brings in is off screen until it reaches the margin; what |
| 273 | - | func TestStashNavigationAppearsAndLeaves(t *testing.T) { | 273 | + | // it sends off goes once the slide ends. |
| 274 | + | func TestStashNavigationSlides(t *testing.T) { | |||
| 274 | c, ic, sc, cmds := stashRig(t, true) | 275 | c, ic, sc, cmds := stashRig(t, true) | ||
| 275 | w := sc.mon.Current() | 276 | w := sc.mon.Current() | ||
| 276 | w.AddWindow(5) | 277 | w.AddWindow(5) | ||
| @@ -280,32 +281,36 @@ func TestStashNavigationAppearsAndLeaves(t *testing.T) { | |||||
| 280 | t.Fatalf("setup: stash %+v at %d", w.Stash, w.stashAt) | 281 | t.Fatalf("setup: stash %+v at %d", w.Stash, w.stashAt) | ||
| 281 | } | 282 | } | ||
| 282 | settleShown(t, c) | 283 | settleShown(t, c) | ||
| 283 | - | act(c, ic, ActionFocusColumnLeft) // 2 selected, 3 comes in, 5 stays a peek | 284 | + | centre := sceneWindow(t, indicatorScene(t, c), 5).Rect |
| 285 | + | act(c, ic, ActionFocusColumnLeft) // 2 selected, 3 comes in, 5 becomes a peek | |||
| 284 | s := indicatorScene(t, c) | 286 | s := indicatorScene(t, c) | ||
| 285 | - | if w := sceneWindow(t, s, 3); w.Hidden || w.Fade != 1 { | 287 | + | if got := sceneWindow(t, s, 5); got.Hidden || got.Rect != centre || got.Dim != 0 { |
| 286 | - | t.Fatalf("window 3 comes to the margin: %+v, want invisible at first", w) | 288 | + | t.Fatalf("window 5 jumped: %+v, want %+v unveiled", got, centre) |
| 287 | } | 289 | } | ||
| 288 | - | if w := sceneWindow(t, s, 5); w.Hidden || w.Fade != 0 { | 290 | + | if got := sceneWindow(t, s, 3); !got.Hidden { |
| 289 | - | t.Fatalf("window 5 stays a peek: %+v", w) | 291 | + | t.Fatalf("window 3 is on screen before the slide: %+v", got) |
| 292 | + | } | |||
| 293 | + | if len(sc.rects) != 0 || !w.stashView.motion.on { | |||
| 294 | + | t.Fatalf("%d rect motions, slide %v: the view slides alone", len(sc.rects), w.stashView.motion.on) | |||
| 295 | + | } | |||
| 296 | + | if got := frame(t, c, ic, 40*time.Millisecond); !(sceneWindow(t, got, 5).Dim > 0) { | |||
| 297 | + | t.Fatal("window 5 has no veil mid-slide") | |||
| 290 | } | 298 | } | ||
| 291 | settledAll := frame(t, c, ic, 5*time.Second) | 299 | settledAll := frame(t, c, ic, 5*time.Second) | ||
| 292 | - | if w := sceneWindow(t, settledAll, 3); w.Fade != 0 || w.Hidden { | 300 | + | if got := sceneWindow(t, settledAll, 3); got.Hidden || got.Dim != 0.5 { |
| 293 | - | t.Fatalf("window 3 settled %+v", w) | 301 | + | t.Fatalf("window 3 settled %+v", got) |
| 294 | } | 302 | } | ||
| 295 | drainConfigures(cmds) | 303 | drainConfigures(cmds) | ||
| 296 | act(c, ic, ActionFocusColumnLeft) // 3 selected, 4 comes in, 5 goes | 304 | act(c, ic, ActionFocusColumnLeft) // 3 selected, 4 comes in, 5 goes | ||
| 305 | + | frame(t, c, ic, 5*time.Second) | |||
| 297 | s = indicatorScene(t, c) | 306 | s = indicatorScene(t, c) | ||
| 298 | - | if w := sceneWindow(t, s, 4); w.Hidden || w.Fade != 1 { | 307 | + | if got := sceneWindow(t, s, 5); !got.Hidden { |
| 299 | - | t.Fatalf("window 4 comes to the margin: %+v", w) | 308 | + | t.Fatalf("window 5 still drawn after the settle: %+v", got) |
| 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 | } | 309 | } | ||
| 304 | - | s = frame(t, c, ic, 5*time.Second) | 310 | + | if got := sceneWindow(t, s, 4); got.Hidden { |
| 305 | - | if w := sceneWindow(t, s, 5); !w.Hidden { | 311 | + | t.Fatalf("window 4 not at the margin: %+v", got) |
| 306 | - | t.Fatalf("window 5 still drawn after the settle: %+v", w) | |||
| 307 | } | 312 | } | ||
| 308 | - | if c.animating() { | 313 | + | if c.animating() || w.stashView.off != 0 { |
| 309 | t.Fatal("still animating") | 314 | t.Fatal("still animating") | ||
| 310 | } | 315 | } | ||
| 311 | } | 316 | } | ||
| @@ -381,18 +386,17 @@ func TestStashShowDuringHideDoesNotJump(t *testing.T) { | |||||
| 381 | } | 386 | } | ||
| 382 | } | 387 | } | ||
| 383 | 388 | ||||
| 384 | - | // A peek sliding to a margin that a fast second navigation sends off screen | 389 | + | // A second navigation during the slide goes on from the view as drawn: no |
| 385 | - | // leaves from where it was drawn, mid-slide, not from its settled rect. | 390 | + | // window jumps, and the one sent off goes once the slide ends. |
| 386 | - | func TestStashNavigationLeavesFromSlide(t *testing.T) { | 391 | + | func TestStashNavigationChainsFromSlide(t *testing.T) { |
| 387 | - | c, ic, sc, _ := stashRig(t, true) | 392 | + | c, ic, _, _ := stashRig(t, true) |
| 388 | // 2 selected, 3 peeks left. Left: 3 centred, 2 peeks right, 4 enters. | 393 | // 2 selected, 3 peeks left. Left: 3 centred, 2 peeks right, 4 enters. | ||
| 389 | act(c, ic, ActionFocusColumnLeft) | 394 | act(c, ic, ActionFocusColumnLeft) | ||
| 390 | indicatorScene(t, c) | 395 | indicatorScene(t, c) | ||
| 391 | s := frame(t, c, ic, 30*time.Millisecond) | 396 | s := frame(t, c, ic, 30*time.Millisecond) | ||
| 392 | last := sceneWindow(t, s, 2) | 397 | last := sceneWindow(t, s, 2) | ||
| 393 | - | settled := settledOf(t, sc, 2) | 398 | + | if !c.cur().mon.Current().stashView.motion.on { |
| 394 | - | if last.Rect == settled.Rect { | 399 | + | t.Fatal("setup: the slide already ended") |
| 395 | - | t.Fatal("setup: window 2 already at rest") | |||
| 396 | } | 400 | } | ||
| 397 | // Left again: 4 centred, 3 peeks right, 2 goes off. | 401 | // Left again: 4 centred, 3 peeks right, 2 goes off. | ||
| 398 | act(c, ic, ActionFocusColumnLeft) | 402 | act(c, ic, ActionFocusColumnLeft) | ||
M internal/core/workspace_size_internal_test.go +3 -3
| @@ -28,7 +28,7 @@ func TestSizedTransitionsStaySettledAndOverviewSpansMonitor(t *testing.T) { | |||
| 28 | 28 | m.Workspaces = append(m.Workspaces, w) | |
| 29 | 29 | m.Active = len(m.Workspaces) - 1 | |
| 30 | 30 | m.shown = nil | |
| 31 | - | m.switchOff = -0.4 | |
| 31 | + | m.switchView.off = -0.4 | |
| 32 | 32 | if got := m.Frame(); got != m.Current().Output || !m.framedSwitch() { | |
| 33 | 33 | t.Fatalf("sliding frame %+v", got) | |
| 34 | 34 | } | |
| @@ -38,7 +38,7 @@ func TestSizedTransitionsStaySettledAndOverviewSpansMonitor(t *testing.T) { | |||
| 38 | 38 | if len(during) != len(before) || during[0].Rect != before[0].Rect { | |
| 39 | 39 | t.Fatalf("sized workspace animated outside frame: %+v", during) | |
| 40 | 40 | } | |
| 41 | - | m.switchOff = 0 | |
| 41 | + | m.switchView.off = 0 | |
| 42 | 42 | if got := m.Frame(); got != m.Current().Output { | |
| 43 | 43 | t.Fatalf("settled frame %+v", got) | |
| 44 | 44 | } | |
| @@ -55,7 +55,7 @@ func TestSizedNeighborDisablesSlideButInheritedNeighborDoesNot(t *testing.T) { | |||
| 55 | 55 | m.AddWindow(1) | |
| 56 | 56 | m.Workspaces[1].SetSize(100, 100) | |
| 57 | 57 | before := m.Layout() | |
| 58 | - | m.switchOff = 0.4 | |
| 58 | + | m.switchView.off = 0.4 | |
| 59 | 59 | if !m.framedSwitch() || m.Layout()[0].Rect != before[0].Rect { | |
| 60 | 60 | t.Fatal("slide toward sized neighbor escaped viewport") | |
| 61 | 61 | } | |
| @@ -28,7 +28,7 @@ func TestSizedTransitionsStaySettledAndOverviewSpansMonitor(t *testing.T) { | |||||
| 28 | m.Workspaces = append(m.Workspaces, w) | 28 | m.Workspaces = append(m.Workspaces, w) | ||
| 29 | m.Active = len(m.Workspaces) - 1 | 29 | m.Active = len(m.Workspaces) - 1 | ||
| 30 | m.shown = nil | 30 | m.shown = nil | ||
| 31 | - | m.switchOff = -0.4 | 31 | + | m.switchView.off = -0.4 |
| 32 | if got := m.Frame(); got != m.Current().Output || !m.framedSwitch() { | 32 | if got := m.Frame(); got != m.Current().Output || !m.framedSwitch() { | ||
| 33 | t.Fatalf("sliding frame %+v", got) | 33 | t.Fatalf("sliding frame %+v", got) | ||
| 34 | } | 34 | } | ||
| @@ -38,7 +38,7 @@ func TestSizedTransitionsStaySettledAndOverviewSpansMonitor(t *testing.T) { | |||||
| 38 | if len(during) != len(before) || during[0].Rect != before[0].Rect { | 38 | if len(during) != len(before) || during[0].Rect != before[0].Rect { | ||
| 39 | t.Fatalf("sized workspace animated outside frame: %+v", during) | 39 | t.Fatalf("sized workspace animated outside frame: %+v", during) | ||
| 40 | } | 40 | } | ||
| 41 | - | m.switchOff = 0 | 41 | + | m.switchView.off = 0 |
| 42 | if got := m.Frame(); got != m.Current().Output { | 42 | if got := m.Frame(); got != m.Current().Output { | ||
| 43 | t.Fatalf("settled frame %+v", got) | 43 | t.Fatalf("settled frame %+v", got) | ||
| 44 | } | 44 | } | ||
| @@ -55,7 +55,7 @@ func TestSizedNeighborDisablesSlideButInheritedNeighborDoesNot(t *testing.T) { | |||||
| 55 | m.AddWindow(1) | 55 | m.AddWindow(1) | ||
| 56 | m.Workspaces[1].SetSize(100, 100) | 56 | m.Workspaces[1].SetSize(100, 100) | ||
| 57 | before := m.Layout() | 57 | before := m.Layout() | ||
| 58 | - | m.switchOff = 0.4 | 58 | + | m.switchView.off = 0.4 |
| 59 | if !m.framedSwitch() || m.Layout()[0].Rect != before[0].Rect { | 59 | if !m.framedSwitch() || m.Layout()[0].Rect != before[0].Rect { | ||
| 60 | t.Fatal("slide toward sized neighbor escaped viewport") | 60 | t.Fatal("slide toward sized neighbor escaped viewport") | ||
| 61 | } | 61 | } | ||