detached ccce658

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 UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.5.0
28 files · +860 -196
M Makefile +1 -1
@@ -60,7 +60,7 @@ arch:
6060 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
6161 # Fast, mandatory guards for tiled content publications and SHM copies.
6262 perf-check:
63- CGO_ENABLED=0 go test ./internal/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
6464 check: vet test arch fakes-check log-check perf-check
6565
6666 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -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=163+ 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-check64 check: vet test arch fakes-check log-check perf-check
65 65
66 # On-demand SDR/HDR colour check with examples/testpattern on a headless66 # On-demand SDR/HDR colour check with examples/testpattern on a headless
M 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
147147
148148 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`.
149149
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.
151151
152152 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.
153153
@@ -222,7 +222,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
222222
223223 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.
224224
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).
226226
227227 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.
228228
@@ -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) {
1818 m.AddWindow(2)
1919
2020 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
2527 sc.rects = make(map[WindowID]rectMotion, 20)
2628 for i := range 20 {
2729 sp := viewSpring(50, 0)
@@ -57,7 +59,7 @@ func TestAnimateAllocations(t *testing.T) {
5759 t.Errorf("animate(%s) allocs = %v, want 0", name, n)
5860 }
5961 }
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))
6264 }
6365 }
@@ -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 = 10022+ 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 = 10024+ 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) {
134134 movingRects(t, c, ic, sc)
135135 // A workspace switch spring runs too.
136136 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() {
139139 t.Fatal("no workspace switch to interrupt")
140140 }
141141 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)
144144 }
145145 if err := c.publish(context.Background()); err != nil {
146146 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.mon136 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) {
88 m := c.cur().mon
99 m.AddWindow(1)
1010 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)
1313 c.armFrame()
1414 if !c.animating() || c.frameC == nil {
1515 t.Fatal("no running spring to stop")
1616 }
1717 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)
2020 }
2121 }
@@ -8,14 +8,14 @@ func TestStopAnimationsSettlesEverySpring(t *testing.T) {
8 m := c.cur().mon8 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 {
161161 if !p.Hidden {
162162 w.Rect = rebase(p.Rect)
163163 }
164- if p.Peek {
165- w.Dim = c.cfg.Stash.Dim
166- }
164+ w.Dim = c.peekDim(p)
167165 s.Windows = append(s.Windows, w)
168166 }
169167 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) {
193193 c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}}
194194 c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
195195 c.popupOrder = append(c.popupOrder, 10)
196- ws.motion = newMotion(viewSpring(10, 0), time.Time{}, 1)
196+ ws.view.motion = newMotion(viewSpring(10, 0), time.Time{}, 1)
197197 ctx := context.Background()
198198 if err := c.publish(ctx); err != nil {
199199 t.Fatal(err)
@@ -207,7 +207,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
207207 if v, ok := c.configures.sent[1]; !ok || !v.Captured || v.Visible || v.Activated || v.Width <= 0 {
208208 t.Fatalf("window during the slide %+v (sent %v)", v, ok)
209209 }
210- ws.motion = motion{}
210+ ws.view.motion = motion{}
211211 if err := c.publish(ctx); err != nil {
212212 t.Fatal(err)
213213 }
@@ -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 {
578578 if view == w.ViewX {
579579 return false
580580 }
581- w.stopSlide()
581+ w.view.stop()
582582 w.ViewX = view
583583 return true
584584 }
@@ -578,7 +578,7 @@ func (w *Workspace) scrollBy(dir int) bool {
578 if view == w.ViewX {578 if view == w.ViewX {
579 return false579 return false
580 }580 }
581- w.stopSlide()581+ w.view.stop()
582 w.ViewX = view582 w.ViewX = view
583 return true583 return true
584 }584 }
M internal/core/floating_test.go +25 -0
@@ -114,6 +114,31 @@ func TestStashStrip(t *testing.T) {
114114 }
115115 }
116116
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+
117142 // Hiding the stash is the way out: tiles get the focus, a new stashed
118143 // window shows it again, and the native floats stay as they are.
119144 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 stashed142 // 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) {
4242 }
4343 // A camera transition runs on B.
4444 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)
4747
4848 ic.now = ic.now.Add(pulseRise / 2)
4949 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 = 10045+ 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 (
1212 // up or down it slides between workspaces. Each swipe moves one step at
1313 // most (snapswipe.go): the next column edge or the next workspace. When
1414 // 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.
1820
1921 const (
2022 // swipeDecide is the touchpad distance before the swipe picks its axis
@@ -26,6 +28,9 @@ const (
2628 // workspaceSwipeMovement is the touchpad distance that slides one
2729 // workspace.
2830 workspaceSwipeMovement = 300.0
31+ // stashSwipeMovement is the touchpad distance that slides the stash by
32+ // one window.
33+ stashSwipeMovement = 300.0
2934 )
3035
3136 type swipeMode uint8
@@ -40,6 +45,10 @@ const (
4045 // the overview closed, down shows or hides the stash, and up hides
4146 // it when it is shown, else opens the overview.
4247 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
4352 // swipeDropped ignores the rest of a swipe whose workspace changed.
4453 swipeDropped
4554 )
@@ -66,6 +75,10 @@ type swipeGesture struct {
6675 // list is the numbered workspaces when a workspace slide began: the
6776 // slide indexes it, so it ends if the list changes.
6877 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
6982 // opens is the monitor's overviewOpens when the swipe picked its
7083 // mode: a slide the overview opened over is dropped, even once the
7184 // overview closed again.
@@ -86,6 +99,15 @@ func (g *swipeGesture) columnsChanged(m *Monitor) bool {
8699 return m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || !slices.Equal(g.ws.snapPoints(), g.points)
87100 }
88101
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+
89111 // swipeSign turns finger movement into view movement: natural scroll moves
90112 // the content with the fingers.
91113 func (c *Core) swipeSign() float64 {
@@ -110,7 +132,12 @@ func (c *Core) swipeBegin(b ports.SwipeBegin) bool {
110132 // swipeUpdate moves the swipe; it reports whether the scene changed.
111133 func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
112134 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()
114141 c.swipe = nil
115142 return false
116143 }
@@ -137,11 +164,11 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
137164 switch g.mode {
138165 case swipeColumns:
139166 if g.columnsChanged(m) {
140- g.ws.stopSlide()
167+ g.ws.view.stop()
141168 g.mode = swipeDropped
142169 return true
143170 }
144- g.ws.shift = g.snap.pos() - float64(g.ws.ViewX)
171+ g.ws.view.off = g.snap.pos() - float64(g.ws.ViewX)
145172 return true
146173 case swipeWorkspaces:
147174 if g.listChanged(m) {
@@ -149,7 +176,15 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
149176 g.mode = swipeDropped
150177 return true
151178 }
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)
153188 return true
154189 }
155190 return false
@@ -173,10 +208,18 @@ func (c *Core) decide(g *swipeGesture) {
173208 // With animations off nothing slides either: the swipe runs a
174209 // focus action when the fingers lift.
175210 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)
176219 case g.horizontal && w.slidable():
177220 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)))
180223 case !g.horizontal && m.shown == nil:
181224 // A landing slide measured in an older list lands at once first.
182225 if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) {
@@ -184,8 +227,8 @@ func (c *Core) decide(g *swipeGesture) {
184227 }
185228 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
186229 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)
189232 default:
190233 g.mode, g.snap = swipeDiscrete, newStepSwipe()
191234 }
@@ -195,6 +238,9 @@ func (c *Core) decide(g *swipeGesture) {
195238 // screen.
196239 func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
197240 g := c.swipe
241+ if g != nil && !c.hasScreen(g.screen) {
242+ c.dropSwipe()
243+ }
198244 c.swipe = nil
199245 if g == nil || !c.hasScreen(g.screen) {
200246 return false
@@ -205,10 +251,10 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
205251 case swipeColumns:
206252 w := g.ws
207253 if g.columnsChanged(m) {
208- w.stopSlide()
254+ w.view.stop()
209255 return false
210256 }
211- shown := float64(w.ViewX) + w.shift
257+ shown := float64(w.ViewX) + w.view.off
212258 target, velocity := g.snap.end(e.Cancelled, e.Time)
213259 if !e.Cancelled {
214260 view := int(math.Round(target))
@@ -219,18 +265,18 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
219265 c.focusScreen = c.screenIndex(g.screen.name())
220266 c.keyboard.takeBack()
221267 }
222- w.shift = shown - float64(w.ViewX)
268+ w.view.off = shown - float64(w.ViewX)
223269 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)
225271 } else {
226- w.stopSlide()
272+ w.view.stop()
227273 }
228274 case swipeWorkspaces:
229275 if g.listChanged(m) {
230276 m.stopSwitch()
231277 return false
232278 }
233- cur := float64(m.Active) + m.switchOff
279+ cur := float64(m.Active) + m.switchView.off
234280 target, velocity := g.snap.end(e.Cancelled, e.Time)
235281 idx := m.Active
236282 if !e.Cancelled {
@@ -247,12 +293,42 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
247293 c.focusScreen = c.screenIndex(g.screen.name())
248294 c.keyboard.takeBack()
249295 }
250- m.switchOff, m.switchList = off, g.list
296+ m.switchView.off, m.switchList = off, g.list
251297 if c.animOn() {
252- m.switchMotion = c.spring(m.switchSpring(off, velocity), now)
298+ m.switchView.motion = c.spring(m.switchSpring(off, velocity), now)
253299 } else {
254300 m.stopSwitch()
255301 }
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
256332 case swipeOverview:
257333 step := g.snap.step(e.Cancelled, e.Time)
258334 if step == 0 {
@@ -340,9 +416,11 @@ func (c *Core) dropSwipe() {
340416 }
341417 switch g.mode {
342418 case swipeColumns:
343- g.ws.stopSlide()
419+ g.ws.view.stop()
344420 case swipeWorkspaces:
345421 g.screen.mon.stopSwitch()
422+ case swipeStash:
423+ g.ws.stashView.stop()
346424 }
347425 g.mode = swipeDropped
348426 }
@@ -379,12 +457,15 @@ func (c *Core) stopAnimations() {
379457
380458 // stopAnimations settles the screen's running springs where they are going.
381459 func (s *screen) stopAnimations() {
382- if s.mon.switchMotion.on {
460+ if s.mon.switchView.motion.on {
383461 s.mon.stopSwitch()
384462 }
385463 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()
388469 }
389470 }
390471 s.stopRects()
@@ -398,21 +479,15 @@ func (c *Core) animate(now time.Time, only *screen) {
398479 continue
399480 }
400481 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
406486 }
407487 }
408488 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)
416491 }
417492 for id, rm := range sc.rects {
418493 rm.advance(now)
@@ -445,11 +520,11 @@ func (c *Core) animating() bool {
445520 }
446521
447522 func (m *Monitor) springing() bool {
448- if m.switchMotion.on {
523+ if m.switchView.motion.on {
449524 return true
450525 }
451526 for w := range m.all() {
452- if w.motion.on {
527+ if w.view.motion.on || w.stashView.motion.on {
453528 return true
454529 }
455530 }
@@ -12,9 +12,11 @@ import (
12 // up or down it slides between workspaces. Each swipe moves one step at12 // 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. When13 // 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 settles14 // 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 the15+// on the closest, and a spring takes the view there (motion.go). Sideways
16-// view cannot slide (fixed overflow, the stash, a float, a fullscreen16+// 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 axis22 // swipeDecide is the touchpad distance before the swipe picks its axis
@@ -26,6 +28,9 @@ const (
26 // workspaceSwipeMovement is the touchpad distance that slides one28 // workspaceSwipeMovement is the touchpad distance that slides one
27 // workspace.29 // workspace.
28 workspaceSwipeMovement = 300.030 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 uint836 type swipeMode uint8
@@ -40,6 +45,10 @@ const (
40 // the overview closed, down shows or hides the stash, and up hides45 // 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 swipeOverview47 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 swipeDropped53 swipeDropped
45 )54 )
@@ -66,6 +75,10 @@ type swipeGesture struct {
66 // list is the numbered workspaces when a workspace slide began: the75 // 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 []*Workspace77 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 its82 // opens is the monitor's overviewOpens when the swipe picked its
70 // mode: a slide the overview opened over is dropped, even once the83 // 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 moves111 // 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.swipe134 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 = nil141 c.swipe = nil
115 return false142 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 = swipeDropped168 g.mode = swipeDropped
142 return true169 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 true172 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 = swipeDropped176 g.mode = swipeDropped
150 return true177 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 true188 return true
154 }189 }
155 return false190 return false
@@ -173,10 +208,18 @@ func (c *Core) decide(g *swipeGesture) {
173 // With animations off nothing slides either: the swipe runs a208 // 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{}, nil230+ 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.swipe240 g := c.swipe
241+ if g != nil && !c.hasScreen(g.screen) {
242+ c.dropSwipe()
243+ }
198 c.swipe = nil244 c.swipe = nil
199 if g == nil || !c.hasScreen(g.screen) {245 if g == nil || !c.hasScreen(g.screen) {
200 return false246 return false
@@ -205,10 +251,10 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
205 case swipeColumns:251 case swipeColumns:
206 w := g.ws252 w := g.ws
207 if g.columnsChanged(m) {253 if g.columnsChanged(m) {
208- w.stopSlide()254+ w.view.stop()
209 return false255 return false
210 }256 }
211- shown := float64(w.ViewX) + w.shift257+ 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 false277 return false
232 }278 }
233- cur := float64(m.Active) + m.switchOff279+ 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.Active281 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.list296+ 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 = swipeDropped425 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 continue479 continue
399 }480 }
400 m := sc.mon481 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 = v484+ 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 true524 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 true528 return true
454 }529 }
455 }530 }
M internal/core/layout.go +16 -7
@@ -115,6 +115,10 @@ type Placement struct {
115115 // Peek is a stashed window peeking in beside the selected one, or an
116116 // overview preview of a neighbor workspace: dimmed.
117117 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
118122 // Preview is the scale of an overview preview: its buffer is drawn
119123 // that much smaller in Rect, the client keeps its size. 0 otherwise.
120124 Preview float64
@@ -128,7 +132,7 @@ type Placement struct {
128132 Leaving bool
129133 // Fade, Dim and Zoom are set on shown layouts only (refreshShown),
130134 // 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
132136 // when a scale motion runs (Preview times the drawn/settled width, 1
133137 // when the content is drawn at its size; 0 without one). Preview
134138 // itself stays the settled one: input, popups and the renderer's paint
@@ -136,6 +140,9 @@ type Placement struct {
136140 Fade, Dim, Zoom float64
137141 }
138142
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+
139146 // Overflow says what happens past MaxColumns columns.
140147 type Overflow string
141148
@@ -163,10 +170,8 @@ type Workspace struct {
163170 maximized []WindowID
164171 Focus int
165172 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
170175 // Output is the effective viewport in monitor coordinates: the whole
171176 // monitor unless the workspace has a size override, then a centered
172177 // rectangle no larger than the monitor. Fullscreen fills it.
@@ -194,8 +199,12 @@ type Workspace struct {
194199 // Stash holds the windows set aside by toggle-window-stash, left to
195200 // right (stash.go); stashAt is the selected one. stashFocus is set
196201 // 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
199208 stashFocus bool
200209 stashHidden bool
201210 // 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 an115 // 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 bool117 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 drawn122 // 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 float64124 Preview float64
@@ -128,7 +132,7 @@ type Placement struct {
128 Leaving bool132 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 opacity134 // 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 drawn135+ // 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, 1136 // when a scale motion runs (Preview times the drawn/settled width, 1
133 // when the content is drawn at its size; 0 without one). Preview137 // when the content is drawn at its size; 0 without one). Preview
134 // itself stays the settled one: input, popups and the renderer's paint138 // itself stays the settled one: input, popups and the renderer's paint
@@ -136,6 +140,9 @@ type Placement struct {
136 Fade, Dim, Zoom float64140 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 string147 type Overflow string
141 148
@@ -163,10 +170,8 @@ type Workspace struct {
163 maximized []WindowID170 maximized []WindowID
164 Focus int171 Focus int
165 ViewX int172 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 whole175 // Output is the effective viewport in monitor coordinates: the whole
171 // monitor unless the workspace has a size override, then a centered176 // 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 to199 // Stash holds the windows set aside by toggle-window-stash, left to
195 // right (stash.go); stashAt is the selected one. stashFocus is set200 // 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 []Float202+ Stash []Float
198- stashAt int203+ 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 bool208 stashFocus bool
200 stashHidden bool209 stashHidden bool
201 // stashWidth is the width of a stashed window (0: the default) and210 // 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 {
7272 m.Apply(ActionFocusWorkspaceDown)
7373 m.AddWindow(20)
7474 m.Apply(ActionFocusWorkspaceUp)
75- m.switchOff = 0.4
75+ m.switchView.off = 0.4
7676 v["slide"] = m
7777
7878 // The variants must hold what they are named for, on the workspace on
@@ -182,9 +182,9 @@ func TestLayoutResultsDoNotAlias(t *testing.T) {
182182 }
183183 _ = m.Layout()
184184 _ = m.Current().Layout()
185- m.switchOff = 0.3
185+ m.switchView.off = 0.3
186186 _ = m.Layout()
187- m.switchOff = 0
187+ m.switchView.off = 0
188188
189189 if !slices.Equal(first, kept) {
190190 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) {
250250 var e []expect
251251 for _, sc := range c.screens {
252252 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})
254254 }
255255 }
256256 return e
@@ -299,8 +299,8 @@ func TestPublishedScenesAreNeverMutated(t *testing.T) {
299299 continue
300300 }
301301 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
304304 startRectMotions(c, sc, t0)
305305 }
306306 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.475+ m.switchView.off = 0.4
76 v["slide"] = m76 v["slide"] = m
77 77
78 // The variants must hold what they are named for, on the workspace on78 // 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.3185+ m.switchView.off = 0.3
186 _ = m.Layout()186 _ = m.Layout()
187- m.switchOff = 0187+ 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 []expect250 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 e256 return e
@@ -299,8 +299,8 @@ func TestPublishedScenesAreNeverMutated(t *testing.T) {
299 continue299 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 = 100303+ 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 {
3434 followMove bool
3535 // stashCapture puts new windows in the shown stash (stash.capture).
3636 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
4240 // 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
4442 // workspace's place in it.
4543 switchList []*Workspace
4644 // ov holds the overview selection and its Escape snapshot.
@@ -34,13 +34,11 @@ type Monitor struct {
34 followMove bool34 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 bool36 stashCapture bool
37- // switchOff slides the view between numbered workspaces: the view is37+ // switchView slides the view between numbered workspaces: the view is
38- // at Active+switchOff while a swipe follows the fingers or its spring38+ // 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 (the40 // switchList is the numbered list a landing slide measures from (the
43- // one its swipe began on); switchOff is then from the current41+ // 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 []*Workspace43 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) {
433433
434434 func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) {
435435 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()
440440 if v <= 0 {
441441 t.Fatalf("velocity %v", v)
442442 }
443443 w.ViewX = 40
444444 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)
447447 }
448- if got := w.motion.velocity(); got != v {
448+ if got := w.view.motion.velocity(); got != v {
449449 t.Fatalf("velocity %v after retarget, want %v", got, v)
450450 }
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 {
452452 t.Fatalf("spring velocity %v, want %v in spring time", got, 2*v)
453453 }
454454 var idle Workspace
455455 idle.ViewX = 5
456456 idle.retarget(0)
457- if idle.motion.on || idle.shift != 0 {
457+ if idle.view.motion.on || idle.view.off != 0 {
458458 t.Fatal("retarget without a slide must not start one")
459459 }
460460 }
@@ -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 = 40443 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 Workspace454 var idle Workspace
455 idle.ViewX = 5455 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() {
7575 // A slide in progress lands at once: the overview lays out the
7676 // settled state.
7777 m.stopSwitch()
78- m.each(func(w *Workspace) { w.stopSlide() })
78+ m.each(func(w *Workspace) { w.view.stop(); w.stashView.stop() })
7979 m.ov.open, m.ov.from, m.ov.back = true, w, m.back
8080 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 0, 0, false, false
8181 m.overviewOpens++
@@ -558,7 +558,9 @@ func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool,
558558 if !p.Hidden {
559559 p.Rect.X += dx
560560 p.Rect.Y += dy
561- p.Peek = p.Peek || peek || dim
561+ if p.Peek || peek || dim {
562+ p.peeking(1)
563+ }
562564 p.Focused = p.Focused && !peek && !dim && lit
563565 }
564566 out = append(out, p)
@@ -797,7 +799,10 @@ func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, spa
797799 r := tiles[i].Rect
798800 x0, y0 := scale(r.X), scale(r.Y)
799801 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+ }
801806 tiles[i].Focused = tiles[i].Focused && !dim && lit
802807 }
803808 return tiles
@@ -75,7 +75,7 @@ func (m *Monitor) ToggleOverview() {
75 // A slide in progress lands at once: the overview lays out the75 // 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.back79 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, false80 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 += dx559 p.Rect.X += dx
560 p.Rect.Y += dy560 p.Rect.Y += dy
561- p.Peek = p.Peek || peek || dim561+ if p.Peek || peek || dim {
562+ p.peeking(1)
563+ }
562 p.Focused = p.Focused && !peek && !dim && lit564 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].Rect799 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, dim802+ tiles[i].Preview = z
803+ if dim {
804+ tiles[i].peeking(1)
805+ }
801 tiles[i].Focused = tiles[i].Focused && !dim && lit806 tiles[i].Focused = tiles[i].Focused && !dim && lit
802 }807 }
803 return tiles808 return tiles
M internal/core/publish_alloc_test.go +43 -4
@@ -106,8 +106,8 @@ func checkRunning(t *testing.T, c *Core, when string) {
106106 if sc.name() == "" {
107107 continue
108108 }
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))
111111 }
112112 }
113113 }
@@ -123,8 +123,8 @@ func TestPublishAllocations(t *testing.T) {
123123 continue
124124 }
125125 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
128128 startRectMotions(c, sc, t0)
129129 }
130130 for _, tc := range []struct {
@@ -146,6 +146,45 @@ func TestPublishAllocations(t *testing.T) {
146146 }
147147 }
148148
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+
149188 // TestPublishOverviewAllocations pins the cost of publishing while the
150189 // overview opens: its cards fly from the full rects (scale and fade motions
151190 // 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 continue107 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 continue123 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 = 100127+ 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 the188 // TestPublishOverviewAllocations pins the cost of publishing while the
150 // overview opens: its cards fly from the full rects (scale and fade motions189 // 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 {
140140 // settled reports whether no swipe or slide moves the monitor's layout
141141 // (screen.settled adds the rect motions).
142142 func (m *Monitor) settled() bool {
143- if m.switchMotion.on || m.switchOff != 0 {
143+ if m.switchView.busy() {
144144 return false
145145 }
146146 for w := range m.all() {
147- if w.motion.on || w.shift != 0 {
147+ if w.view.busy() || w.stashView.busy() {
148148 return false
149149 }
150150 }
@@ -140,11 +140,11 @@ func (c *Core) advancePulse(now time.Time) float64 {
140 // settled reports whether no swipe or slide moves the monitor's layout140 // 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 false144 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 false148 return false
149 }149 }
150 }150 }
M internal/core/pulse_test.go +5 -5
@@ -194,10 +194,10 @@ func TestFocusPulseWaitsForSlide(t *testing.T) {
194194 alone bool
195195 }{
196196 {"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)
198198 }, 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},
201201 {"rect motion, beside another", func(_ *testing.T, c *Core, now time.Time) {
202202 sc := c.cur()
203203 sc.rects = map[WindowID]rectMotion{1: {x: c.spring(viewSpring(40, 0), now), dx: 40}}
@@ -214,7 +214,7 @@ func TestFocusPulseWaitsForSlide(t *testing.T) {
214214 c.applyAction(ActionFocusColumnLeft)
215215 }
216216 c.transition(before, now)
217- if !m.Current().motion.on || m.settled() {
217+ if !m.Current().view.motion.on || m.settled() {
218218 t.Fatal("no camera transition after the focus moved the view")
219219 }
220220 }, false},
@@ -231,7 +231,7 @@ func TestFocusPulseWaitsForSlide(t *testing.T) {
231231 t.Fatal("pulse decided during a slide")
232232 }
233233 m.stopSwitch()
234- m.Current().stopSlide()
234+ m.Current().view.stop()
235235 c.cur().stopRects()
236236 if tc.alone {
237237 m.Current().ToggleFullWidth()
@@ -194,10 +194,10 @@ func TestFocusPulseWaitsForSlide(t *testing.T) {
194 alone bool194 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) {
351351 return
352352 }
353353 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+ }
354359 focused := c.cur()
355360 c.screens = slices.Delete(c.screens, i, i+1)
356361 c.focusScreen = max(slices.Index(c.screens, focused), 0)
@@ -351,6 +351,11 @@ func (c *Core) removeScreen(name string) {
351 return351 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 (
66 "time"
77 )
88
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 }
1339
1440 // slidable reports whether a swipe can scroll the columns: only scroll
1541 // overflow, with the focus on the columns and no fullscreen window.
@@ -17,20 +43,18 @@ func (w *Workspace) slidable() bool {
1743 return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat()
1844 }
1945
20-func (w *Workspace) stopSlide() { w.shift, w.motion = 0, motion{} }
21-
2246 // retarget keeps the view where it is on screen when ViewX moved from
2347 // before during a landing slide: the slide heads for the new view.
2448 // Without a landing slide the view moves at once, as it always has.
2549 func (w *Workspace) retarget(before int) {
26- if w.ViewX == before || !w.motion.on {
50+ if w.ViewX == before || !w.view.motion.on {
2751 return
2852 }
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)
3155 }
3256
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)) }
3458
3559 // switchSpring is the workspace slide's spring from off: it snaps once it
3660 // 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) {
116140 return focus
117141 }
118142
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 }
120145
121146 // framedSwitch keeps transitions involving a sized workspace settled. A
122147 // monitor-wide animation has no per-workspace crop; it must not expose
@@ -133,7 +158,7 @@ func (m *Monitor) framedSwitch() bool {
133158 if base < 0 {
134159 return false
135160 }
136- pos := float64(base) + m.switchOff
161+ pos := float64(base) + m.switchView.off
137162 for _, i := range [2]int{int(math.Floor(pos)), int(math.Ceil(pos))} {
138163 if i >= 0 && i < len(list) && m.has(list[i]) && list[i].Output != m.Output() {
139164 return true
@@ -147,7 +172,7 @@ func (m *Monitor) framedSwitch() bool {
147172 // A landing slide places them as the list was when the swipe began
148173 // (switchList): an empty workspace dropped since shows as background.
149174 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() {
151176 return cur
152177 }
153178 list := m.Workspaces
@@ -159,7 +184,7 @@ func (m *Monitor) slideLayout(cur []Placement) []Placement {
159184 return cur
160185 }
161186 h := float64(m.template.Output.H)
162- pos := float64(base) + m.switchOff
187+ pos := float64(base) + m.switchView.off
163188 offset := func(p []Placement, k int) []Placement {
164189 dy := int(math.Round((float64(k) - pos) * h))
165190 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) and9+// Slides move what is on screen smoothly: a workspace's columns (view), its
10-// the monitor between workspaces (switchOff). Both are pure presentation10+// stash (stashView) and the monitor between workspaces (switchView). All
11-// over the settled state (ViewX, Focus, Active): the layout ends where the11+// 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 scroll40 // 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 from46 // 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 return51 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 it59 // 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 target60 // 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 focus140 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. A146 // framedSwitch keeps transitions involving a sized workspace settled. A
122 // monitor-wide animation has no per-workspace crop; it must not expose147 // 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 false159 return false
135 }160 }
136- pos := float64(base) + m.switchOff161+ 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 true164 return true
@@ -147,7 +172,7 @@ func (m *Monitor) framedSwitch() bool {
147 // A landing slide places them as the list was when the swipe began172 // 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 cur176 return cur
152 }177 }
153 list := m.Workspaces178 list := m.Workspaces
@@ -159,7 +184,7 @@ func (m *Monitor) slideLayout(cur []Placement) []Placement {
159 return cur184 return cur
160 }185 }
161 h := float64(m.template.Output.H)186 h := float64(m.template.Output.H)
162- pos := float64(base) + m.switchOff187+ 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
231231 u := w.Usable
232232 center := w.stashRect()
233233 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)))
237241 for i, f := range w.Stash {
238242 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}
239245 switch {
240246 case f.ID == cover:
241247 p.Rect, p.Fullscreen, p.Inset = w.Output, true, 0
242248 case w.stashHidden || cover != 0 && !w.stashOverCover():
243249 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))
250259 default:
251260 p.Hidden = true
252261 }
@@ -254,3 +263,25 @@ func (w *Workspace) appendStash(out []Placement, focusedID, cover WindowID) []Pl
254263 }
255264 return out
256265 }
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.Usable231 u := w.Usable
232 center := w.stashRect()232 center := w.stashRect()
233 fw, fh := center.W, center.H233 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 / 100235+ // 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, 0247 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 = true249 p.Hidden = true
244- case i == w.stashAt:250+ case d == 0:
245- p.Rect = center251+ // 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}, true253+ 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}, true255+ // 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 = true260 p.Hidden = true
252 }261 }
@@ -254,3 +263,25 @@ func (w *Workspace) appendStash(out []Placement, focusedID, cover WindowID) []Pl
254 }263 }
255 return out264 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 (
1212 // discrete swipe step): a snapshot of what each screen shows is taken before
1313 // the action and diffed after it. The state the action leaves is the
1414 // 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.
1616 // Nothing here runs for events that come from clients or outputs.
1717 //
1818 // A window's rect transition (presentation-only rect transition) is presentation too: configures keep
@@ -37,8 +37,14 @@ type viewShot struct {
3737 // one), not at their last frame. An action may stop them
3838 // (Monitor.Focus), so the transition reads them here.
3939 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
4046 // 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
4248 // false when there is none (a shown stash workspace).
4349 list []*Workspace
4450 pos float64
@@ -66,9 +72,10 @@ type rectShot struct {
6672 // speed, so an overview toggle carries it over (transitionOverview).
6773 fade, fadeV float64
6874 // 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).
7279 dim, dimV, veil float64
7380 }
7481
@@ -170,7 +177,7 @@ func (m *rectMotion) show(p *Placement) {
170177 // A peek's Dim is an offset from its veil (publish adds Stash.Dim and
171178 // clamps the sum): it may go below 0, down to cancelling the veil.
172179 lo := 0.0
173- if p.Peek {
180+ if p.Veil > 0 {
174181 lo = -1
175182 }
176183 p.Dim = max(lo, min(1, p.Dim+m.ddim))
@@ -614,13 +621,17 @@ func (c *Core) snapshot(now time.Time) []viewShot {
614621 s.ws, s.overview = w, m.ov.open
615622 if !m.ov.open {
616623 // 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
618625 }
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)
621628 }
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)
624635 }
625636 // settledLayout and shown are index-aligned and differ only by
626637 // the offsets of sc.rects: the shot keeps both unrounded. The
@@ -651,7 +662,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
651662 }
652663 if i := indexOf(list, w); i >= 0 {
653664 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
655666 }
656667 }
657668 out = append(out, s)
@@ -701,7 +712,7 @@ func (c *Core) transition(before []viewShot, now time.Time) {
701712 // following reports whether a swipe moves sc's view with the fingers.
702713 func (c *Core) following(sc *screen) bool {
703714 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)
705716 }
706717
707718 // 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) {
710721 w := m.Current()
711722 switch {
712723 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+ }
713731 if w.ViewX == b.viewX || !w.slidable() {
714732 return
715733 }
716734 // A running landing slide was retargeted by scroll(): its velocity
717735 // 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)
720738 case b.ok && m.shown == nil && movedFrom(b, before) == nil:
721739 // A screen that received its workspace from another monitor does
722740 // 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) {
729747 off := b.pos - float64(j)
730748 // The snapshot's list is reused by the next action: the slide keeps
731749 // its own copy.
732- m.switchOff, m.switchList = off, slices.Clone(b.list)
750+ m.switchView.off, m.switchList = off, slices.Clone(b.list)
733751 if off == 0 || m.framedSwitch() {
734752 m.stopSwitch()
735753 return
736754 }
737- m.switchMotion = c.spring(m.switchSpring(off, b.switchV), now)
755+ m.switchView.motion = c.spring(m.switchSpring(off, b.switchV), now)
738756 }
739757 }
740758
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+
741765 // transitionRects starts or retargets the rect motions of the windows whose
742766 // layout the action changed. The new layout is measured with the camera as
743767 // 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) {
771795 old := &from.rects[i]
772796 oldRect := Rect{X: old.rect.X + dx, Y: old.rect.Y + dy, W: old.rect.W, H: old.rect.H}
773797 // 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.
776802 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
778804 if oldRect == p.Rect && !dimMoves {
779805 // The settled rect did not change: a running motion goes on
780806 // as it is, and there is none to start.
@@ -808,8 +834,8 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) {
808834
809835 // transitionStash animates the stash windows an action shows or hides on
810836 // 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
813839 // drawn before and hidden after leaves from its last settled placement
814840 // (leave). A window that is gone from the layout (unstashed, moved) or
815841 // 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) {
819845 sc := b.sc
820846 m := sc.mon
821847 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) {
823849 return
824850 }
825851 layout := m.Layout()
@@ -841,14 +867,15 @@ func (c *Core) transitionStash(b *viewShot, now time.Time) {
841867 }
842868 }
843869
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+
844874 // peekDim is the veil the settled placement p draws: the configured stash
845875 // dim for a peek, none for another window. The placement's Dim is an
846876 // offset from it.
847877 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
852879 }
853880
854881 // 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 before12 // 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 the13 // the action and diffed after it. The state the action leaves is the
14 // settled one; the transition only adds presentation springs over it14 // 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 keep18 // 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 them37 // 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 float6439 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 fractional46 // list and pos are the numbered workspaces and the monitor's fractional
41- // position in them (index of the current one plus switchOff); ok is47+ // 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 []*Workspace49 list []*Workspace
44 pos float6450 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 float6473 fade, fadeV float64
68 // dim and dimV are the dim offset of a running dim motion and its74 // 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 drew75+ // speed; veil is the veil the window's settled placement drew
70- // (Stash.Dim, 0 for another window). A stash navigation takes the76+ // (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 float6479 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 and177 // 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.0179 lo := 0.0
173- if p.Peek {180+ if p.Veil > 0 {
174 lo = -1181 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.open621 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.shift624+ 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 by636 // settledLayout and shown are index-aligned and differ only by
626 // the offsets of sc.rects: the shot keeps both unrounded. The637 // 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, true665+ 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.swipe714 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 return732 return
715 }733 }
716 // A running landing slide was retargeted by scroll(): its velocity734 // 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 does739 // A screen that received its workspace from another monitor does
722 // not slide to it: a running slide's list (switchList) may still740 // 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 keeps748 // 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 return753 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 whose765 // transitionRects starts or retargets the rect motions of the windows whose
742 // layout the action changed. The new layout is measured with the camera as766 // 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 window767 // 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 stash797 // A window that becomes a peek or stops being one (a stash
774- // navigation) changes its veil: the offset takes the one drawn to798+ // 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 on805 // 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 on835 // 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 click836 // 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 or837+// that closes the stash; a navigation slides the view instead, see
812-// sends it off): one drawn after and not before appears (appearAt), one838+// stashSlid): one drawn after and not before appears (appearAt), one
813 // drawn before and hidden after leaves from its last settled placement839 // drawn before and hidden after leaves from its last settled placement
814 // (leave). A window that is gone from the layout (unstashed, moved) or840 // (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.sc845 sc := b.sc
820 m := sc.mon846 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 return849 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 stash874 // 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 an875 // 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 closed881 // 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 {
107107 }
108108 }, []Action{ActionFocusColumnLeft, ActionFocusColumnLeft, ActionFocusColumnLeft},
109109 []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 }},
111111 {"workspace switch", func(c *Core) {
112112 m := c.cur().mon
113113 m.AddWindow(1)
@@ -115,7 +115,7 @@ func chainCases() []chainCase {
115115 m.AddWindow(2)
116116 m.Focus(0)
117117 }, []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 }},
119119 {"rect", func(c *Core) {
120120 m := c.cur().mon
121121 m.AddWindow(1)
@@ -138,7 +138,7 @@ func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) {
138138 settleShown(t, c)
139139 for _, sc := range []*screen{a, b} {
140140 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)
142142 }
143143 // The one frame timer is made on the first arm and reset afterwards:
144144 // a re-arm shows as a Reset on the clock.
@@ -154,7 +154,7 @@ func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) {
154154 }
155155
156156 // With nothing else animating the flip's own publish decides.
157- b.mon.Current().stopSlide()
157+ b.mon.Current().view.stop()
158158 if err := c.step(context.Background(), a); err != nil {
159159 t.Fatal(err)
160160 }
@@ -207,7 +207,7 @@ func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) {
207207 }
208208 settleShown(t, c)
209209 w := sc.mon.Current()
210- w.shift = 25
210+ w.view.off = 25
211211 c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns}
212212 before := c.snapshot(ic.now)
213213 viewX := w.ViewX
@@ -218,8 +218,8 @@ func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) {
218218 t.Fatal("the focus did not move the view: the case checks nothing")
219219 }
220220 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)
223223 }
224224 }
225225
@@ -243,11 +243,11 @@ func TestNoSwitchMotionForSizedWorkspace(t *testing.T) {
243243 before := c.snapshot(ic.now)
244244 m.Focus(1)
245245 c.transition(before, ic.now)
246- if got := m.switchMotion.on; got == tc.sized {
246+ if got := m.switchView.motion.on; got == tc.sized {
247247 t.Fatalf("switch motion %v with a sized workspace %v", got, tc.sized)
248248 }
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)
251251 }
252252 })
253253 }
@@ -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().mon112 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().mon120 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 = 25210+ 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.ViewX213 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) {
335335 ic.now = ic.now.Add(5 * time.Second)
336336 noCamera := func(when string) {
337337 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)
340340 }
341341 }
342342 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) {
267267 }
268268 }
269269
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) {
274275 c, ic, sc, cmds := stashRig(t, true)
275276 w := sc.mon.Current()
276277 w.AddWindow(5)
@@ -280,32 +281,36 @@ func TestStashNavigationAppearsAndLeaves(t *testing.T) {
280281 t.Fatalf("setup: stash %+v at %d", w.Stash, w.stashAt)
281282 }
282283 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
284286 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)
287289 }
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")
290298 }
291299 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)
294302 }
295303 drainConfigures(cmds)
296304 act(c, ic, ActionFocusColumnLeft) // 3 selected, 4 comes in, 5 goes
305+ frame(t, c, ic, 5*time.Second)
297306 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)
303309 }
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)
307312 }
308- if c.animating() {
313+ if c.animating() || w.stashView.off != 0 {
309314 t.Fatal("still animating")
310315 }
311316 }
@@ -381,18 +386,17 @@ func TestStashShowDuringHideDoesNotJump(t *testing.T) {
381386 }
382387 }
383388
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)
388393 // 2 selected, 3 peeks left. Left: 3 centred, 2 peeks right, 4 enters.
389394 act(c, ic, ActionFocusColumnLeft)
390395 indicatorScene(t, c)
391396 s := frame(t, c, ic, 30*time.Millisecond)
392397 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")
396400 }
397401 // Left again: 4 centred, 3 peeks right, 2 goes off.
398402 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 that270+// 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 veil271+// 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 peek284+ 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 goes304 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 screen389+// 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) {
2828 m.Workspaces = append(m.Workspaces, w)
2929 m.Active = len(m.Workspaces) - 1
3030 m.shown = nil
31- m.switchOff = -0.4
31+ m.switchView.off = -0.4
3232 if got := m.Frame(); got != m.Current().Output || !m.framedSwitch() {
3333 t.Fatalf("sliding frame %+v", got)
3434 }
@@ -38,7 +38,7 @@ func TestSizedTransitionsStaySettledAndOverviewSpansMonitor(t *testing.T) {
3838 if len(during) != len(before) || during[0].Rect != before[0].Rect {
3939 t.Fatalf("sized workspace animated outside frame: %+v", during)
4040 }
41- m.switchOff = 0
41+ m.switchView.off = 0
4242 if got := m.Frame(); got != m.Current().Output {
4343 t.Fatalf("settled frame %+v", got)
4444 }
@@ -55,7 +55,7 @@ func TestSizedNeighborDisablesSlideButInheritedNeighborDoesNot(t *testing.T) {
5555 m.AddWindow(1)
5656 m.Workspaces[1].SetSize(100, 100)
5757 before := m.Layout()
58- m.switchOff = 0.4
58+ m.switchView.off = 0.4
5959 if !m.framedSwitch() || m.Layout()[0].Rect != before[0].Rect {
6060 t.Fatal("slide toward sized neighbor escaped viewport")
6161 }
@@ -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) - 129 m.Active = len(m.Workspaces) - 1
30 m.shown = nil30 m.shown = nil
31- m.switchOff = -0.431+ 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 = 041+ 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.458+ 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 }

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close