detached 7ef9454

#29 feat(core): fullscreen cascade band and monitor move

state
merged · a5e31f9 · merge commit
branch
feat/cascade-fullscreen-band → main
opened
bnema ·
updated

Cascade layout:

  • A window entering fullscreen (toggle-fullscreen or a client request) gets its own band right below its band; the other columns keep their band. A window stacked in a column is expelled first. Leaving fullscreen keeps the column in its band.
  • A client re-requesting fullscreen does not split bands again.
  • restoreBand reaches remembered columns past a single-column fullscreen band.

All layouts:

  • move-column-left/right on the fullscreen window (tiled or floating) sends it to the neighbor monitor, where it arrives focused and fullscreen. No neighbor: no-op. Floats it demoted on the source workspace are promoted again.

Checks: make check, make race, make mocks-check, make perf-check, staticcheck ./... green. Headless E2E (2 outputs, max-columns 3, cascade): 3 columns -> Super+Shift+f on the third: columns 1-2 stay in the upper band, 3 alone in the band below at 800x500 -> Super+Shift+Right: 3 on HEADLESS-2 at 800x500, focused.

Needs hardware validation (game on tty, direct scanout/VRR kept while switching focus).

CommitSubjectAuthorDateSignatureStatus
7ef9454docs(core): describe what the ownBand mark changes in restoreBandbriceGPG Verified
f716fc9fix(core): look past only fullscreen bands when restoring columnsbriceGPG Verified
c55955bfix(core): restore every remembered column across a fullscreen bandbriceGPG Verified
aa552bffix(core): harden the fullscreen cascade bandbriceGPG Verified
c73000cfeat(core): move a fullscreen float to the neighbor monitorbriceGPG Verified
dbd9830feat(core): give a fullscreen cascade window its own bandbriceGPG Verified

Checks 1

16 files · +466 -500
1/1
M .mockery.yml +0 -10
@@ -87,16 +87,6 @@ packages:
8787 dir: internal/adapters/wayland
8888 pkgname: wayland
8989 filename: acquire_poller_mock_test.go
90- acquireSource:
91- config:
92- dir: internal/adapters/wayland
93- pkgname: wayland
94- filename: acquire_source_mock_test.go
95- acquireRemover:
96- config:
97- dir: internal/adapters/wayland
98- pkgname: wayland
99- filename: acquire_remover_mock_test.go
10090 syncobjDevice:
10191 config:
10292 dir: internal/adapters/wayland
@@ -87,16 +87,6 @@ packages:
87 dir: internal/adapters/wayland87 dir: internal/adapters/wayland
88 pkgname: wayland88 pkgname: wayland
89 filename: acquire_poller_mock_test.go89 filename: acquire_poller_mock_test.go
90- acquireSource:
91- config:
92- dir: internal/adapters/wayland
93- pkgname: wayland
94- filename: acquire_source_mock_test.go
95- acquireRemover:
96- config:
97- dir: internal/adapters/wayland
98- pkgname: wayland
99- filename: acquire_remover_mock_test.go
100 syncobjDevice:90 syncobjDevice:
101 config:91 config:
102 dir: internal/adapters/wayland92 dir: internal/adapters/wayland
M docs/config.md +2 -0
@@ -307,6 +307,8 @@ The default layout. Columns sit side by side in bands of up to `max-columns`, st
307307 - A new window joins the focused band when it has room, else it gets a new band right below.
308308 - `cycle-column-width` in a full band makes room: the rightmost column goes to the band below (from the last column, the leftmost goes to the band above). The end of the cycle brings it back.
309309 - `maximize-column` gives the column a band of its own: the columns on its left go to the band above, the ones on its right to the band below. Maximizing again forms the band back.
310+- A fullscreen window gets a band of its own right below the band it was in (expelled into its own column first when it shares one), so the other columns stay put and focus changes among them leave the fullscreen output alone. Leaving fullscreen keeps the column in that band.
311+- `move-column-left/right` on a fullscreen window (tiled or floating) sends it to the neighbor monitor, where it stays fullscreen and focused; with no neighbor monitor nothing moves.
310312 - A band left empty goes away. A smaller `max-columns` moves the extra columns of a band to a new band below it.
311313
312314 ### Column widths
@@ -307,6 +307,8 @@ The default layout. Columns sit side by side in bands of up to `max-columns`, st
307 - A new window joins the focused band when it has room, else it gets a new band right below.307 - A new window joins the focused band when it has room, else it gets a new band right below.
308 - `cycle-column-width` in a full band makes room: the rightmost column goes to the band below (from the last column, the leftmost goes to the band above). The end of the cycle brings it back.308 - `cycle-column-width` in a full band makes room: the rightmost column goes to the band below (from the last column, the leftmost goes to the band above). The end of the cycle brings it back.
309 - `maximize-column` gives the column a band of its own: the columns on its left go to the band above, the ones on its right to the band below. Maximizing again forms the band back.309 - `maximize-column` gives the column a band of its own: the columns on its left go to the band above, the ones on its right to the band below. Maximizing again forms the band back.
310+- A fullscreen window gets a band of its own right below the band it was in (expelled into its own column first when it shares one), so the other columns stay put and focus changes among them leave the fullscreen output alone. Leaving fullscreen keeps the column in that band.
311+- `move-column-left/right` on a fullscreen window (tiled or floating) sends it to the neighbor monitor, where it stays fullscreen and focused; with no neighbor monitor nothing moves.
310 - A band left empty goes away. A smaller `max-columns` moves the extra columns of a band to a new band below it.312 - A band left empty goes away. A smaller `max-columns` moves the extra columns of a band to a new band below it.
311 313
312 ### Column widths314 ### Column widths
M internal/adapters/wayland/acquire_failure_test.go +4 -0
@@ -197,6 +197,10 @@ func TestAcquirePollTerminalFailureRetiresActiveAndAdded(t *testing.T) {
197197 if !sw.stopped() {
198198 t.Fatal("waiter not terminal")
199199 }
200+ dev := newMocksyncobjDevice(t) // no device access permitted after terminal failure
201+ if w, err := sw.watch(syncPoint{tl: &timeline{dev: dev, handle: 7}, point: 1}); err == nil || !w.failed {
202+ t.Fatal("new explicit watch accepted")
203+ }
200204 var waits [4]*syncWait
201205 if err := sw.watchImplicit(&ports.DMABuf{}, &waits); err == nil {
202206 t.Fatal("new implicit watch accepted")
@@ -197,6 +197,10 @@ func TestAcquirePollTerminalFailureRetiresActiveAndAdded(t *testing.T) {
197 if !sw.stopped() {197 if !sw.stopped() {
198 t.Fatal("waiter not terminal")198 t.Fatal("waiter not terminal")
199 }199 }
200+ dev := newMocksyncobjDevice(t) // no device access permitted after terminal failure
201+ if w, err := sw.watch(syncPoint{tl: &timeline{dev: dev, handle: 7}, point: 1}); err == nil || !w.failed {
202+ t.Fatal("new explicit watch accepted")
203+ }
200 var waits [4]*syncWait204 var waits [4]*syncWait
201 if err := sw.watchImplicit(&ports.DMABuf{}, &waits); err == nil {205 if err := sw.watchImplicit(&ports.DMABuf{}, &waits); err == nil {
202 t.Fatal("new implicit watch accepted")206 t.Fatal("new implicit watch accepted")
D internal/adapters/wayland/acquire_remover_mock_test.go +0 -78
@@ -1,78 +0,0 @@
1-// Code generated by mockery; DO NOT EDIT.
2-// github.com/vektra/mockery
3-// template: testify
4-
5-package wayland
6-
7-import (
8- mock "github.com/stretchr/testify/mock"
9-)
10-
11-// newMockacquireRemover creates a new instance of mockacquireRemover. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12-// The first argument is typically a *testing.T value.
13-func newMockacquireRemover(t interface {
14- mock.TestingT
15- Cleanup(func())
16-}) *mockacquireRemover {
17- if helper, ok := t.(interface{ Helper() }); ok {
18- helper.Helper()
19- }
20-
21- mock := &mockacquireRemover{}
22- mock.Mock.Test(t)
23-
24- t.Cleanup(func() {
25- if helper, ok := t.(interface{ Helper() }); ok {
26- helper.Helper()
27- }
28- mock.AssertExpectations(t)
29- })
30-
31- return mock
32-}
33-
34-// mockacquireRemover is an autogenerated mock type for the acquireRemover type
35-type mockacquireRemover struct {
36- mock.Mock
37-}
38-
39-type mockacquireRemover_Expecter struct {
40- mock *mock.Mock
41-}
42-
43-func (_m *mockacquireRemover) EXPECT() *mockacquireRemover_Expecter {
44- return &mockacquireRemover_Expecter{mock: &_m.Mock}
45-}
46-
47-// Remove provides a mock function for the type mockacquireRemover
48-func (_mock *mockacquireRemover) Remove() {
49- _mock.Called()
50- return
51-}
52-
53-// mockacquireRemover_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
54-type mockacquireRemover_Remove_Call struct {
55- *mock.Call
56-}
57-
58-// Remove is a helper method to define mock.On call
59-func (_e *mockacquireRemover_Expecter) Remove() *mockacquireRemover_Remove_Call {
60- return &mockacquireRemover_Remove_Call{Call: _e.mock.On("Remove")}
61-}
62-
63-func (_c *mockacquireRemover_Remove_Call) Run(run func()) *mockacquireRemover_Remove_Call {
64- _c.Call.Run(func(args mock.Arguments) {
65- run()
66- })
67- return _c
68-}
69-
70-func (_c *mockacquireRemover_Remove_Call) Return() *mockacquireRemover_Remove_Call {
71- _c.Call.Return()
72- return _c
73-}
74-
75-func (_c *mockacquireRemover_Remove_Call) RunAndReturn(run func()) *mockacquireRemover_Remove_Call {
76- _c.Run(run)
77- return _c
78-}
@@ -1,78 +0,0 @@
1-// Code generated by mockery; DO NOT EDIT.
2-// github.com/vektra/mockery
3-// template: testify
4-
5-package wayland
6-
7-import (
8- mock "github.com/stretchr/testify/mock"
9-)
10-
11-// newMockacquireRemover creates a new instance of mockacquireRemover. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12-// The first argument is typically a *testing.T value.
13-func newMockacquireRemover(t interface {
14- mock.TestingT
15- Cleanup(func())
16-}) *mockacquireRemover {
17- if helper, ok := t.(interface{ Helper() }); ok {
18- helper.Helper()
19- }
20-
21- mock := &mockacquireRemover{}
22- mock.Mock.Test(t)
23-
24- t.Cleanup(func() {
25- if helper, ok := t.(interface{ Helper() }); ok {
26- helper.Helper()
27- }
28- mock.AssertExpectations(t)
29- })
30-
31- return mock
32-}
33-
34-// mockacquireRemover is an autogenerated mock type for the acquireRemover type
35-type mockacquireRemover struct {
36- mock.Mock
37-}
38-
39-type mockacquireRemover_Expecter struct {
40- mock *mock.Mock
41-}
42-
43-func (_m *mockacquireRemover) EXPECT() *mockacquireRemover_Expecter {
44- return &mockacquireRemover_Expecter{mock: &_m.Mock}
45-}
46-
47-// Remove provides a mock function for the type mockacquireRemover
48-func (_mock *mockacquireRemover) Remove() {
49- _mock.Called()
50- return
51-}
52-
53-// mockacquireRemover_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
54-type mockacquireRemover_Remove_Call struct {
55- *mock.Call
56-}
57-
58-// Remove is a helper method to define mock.On call
59-func (_e *mockacquireRemover_Expecter) Remove() *mockacquireRemover_Remove_Call {
60- return &mockacquireRemover_Remove_Call{Call: _e.mock.On("Remove")}
61-}
62-
63-func (_c *mockacquireRemover_Remove_Call) Run(run func()) *mockacquireRemover_Remove_Call {
64- _c.Call.Run(func(args mock.Arguments) {
65- run()
66- })
67- return _c
68-}
69-
70-func (_c *mockacquireRemover_Remove_Call) Return() *mockacquireRemover_Remove_Call {
71- _c.Call.Return()
72- return _c
73-}
74-
75-func (_c *mockacquireRemover_Remove_Call) RunAndReturn(run func()) *mockacquireRemover_Remove_Call {
76- _c.Run(run)
77- return _c
78-}
D internal/adapters/wayland/acquire_source_mock_test.go +0 -113
@@ -1,113 +0,0 @@
1-// Code generated by mockery; DO NOT EDIT.
2-// github.com/vektra/mockery
3-// template: testify
4-
5-package wayland
6-
7-import (
8- mock "github.com/stretchr/testify/mock"
9-)
10-
11-// newMockacquireSource creates a new instance of mockacquireSource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12-// The first argument is typically a *testing.T value.
13-func newMockacquireSource(t interface {
14- mock.TestingT
15- Cleanup(func())
16-}) *mockacquireSource {
17- if helper, ok := t.(interface{ Helper() }); ok {
18- helper.Helper()
19- }
20-
21- mock := &mockacquireSource{}
22- mock.Mock.Test(t)
23-
24- t.Cleanup(func() {
25- if helper, ok := t.(interface{ Helper() }); ok {
26- helper.Helper()
27- }
28- mock.AssertExpectations(t)
29- })
30-
31- return mock
32-}
33-
34-// mockacquireSource is an autogenerated mock type for the acquireSource type
35-type mockacquireSource struct {
36- mock.Mock
37-}
38-
39-type mockacquireSource_Expecter struct {
40- mock *mock.Mock
41-}
42-
43-func (_m *mockacquireSource) EXPECT() *mockacquireSource_Expecter {
44- return &mockacquireSource_Expecter{mock: &_m.Mock}
45-}
46-
47-// add provides a mock function for the type mockacquireSource
48-func (_mock *mockacquireSource) add(fd int, onReadable func()) (acquireRemover, error) {
49- ret := _mock.Called(fd, onReadable)
50-
51- if len(ret) == 0 {
52- panic("no return value specified for add")
53- }
54-
55- var r0 acquireRemover
56- var r1 error
57- if returnFunc, ok := ret.Get(0).(func(int, func()) (acquireRemover, error)); ok {
58- return returnFunc(fd, onReadable)
59- }
60- if returnFunc, ok := ret.Get(0).(func(int, func()) acquireRemover); ok {
61- r0 = returnFunc(fd, onReadable)
62- } else {
63- if ret.Get(0) != nil {
64- r0 = ret.Get(0).(acquireRemover)
65- }
66- }
67- if returnFunc, ok := ret.Get(1).(func(int, func()) error); ok {
68- r1 = returnFunc(fd, onReadable)
69- } else {
70- r1 = ret.Error(1)
71- }
72- return r0, r1
73-}
74-
75-// mockacquireSource_add_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'add'
76-type mockacquireSource_add_Call struct {
77- *mock.Call
78-}
79-
80-// add is a helper method to define mock.On call
81-// - fd int
82-// - onReadable func()
83-func (_e *mockacquireSource_Expecter) add(fd any, onReadable any) *mockacquireSource_add_Call {
84- return &mockacquireSource_add_Call{Call: _e.mock.On("add", fd, onReadable)}
85-}
86-
87-func (_c *mockacquireSource_add_Call) Run(run func(fd int, onReadable func())) *mockacquireSource_add_Call {
88- _c.Call.Run(func(args mock.Arguments) {
89- var arg0 int
90- if args[0] != nil {
91- arg0 = args[0].(int)
92- }
93- var arg1 func()
94- if args[1] != nil {
95- arg1 = args[1].(func())
96- }
97- run(
98- arg0,
99- arg1,
100- )
101- })
102- return _c
103-}
104-
105-func (_c *mockacquireSource_add_Call) Return(acquireRemoverMoqParam acquireRemover, err error) *mockacquireSource_add_Call {
106- _c.Call.Return(acquireRemoverMoqParam, err)
107- return _c
108-}
109-
110-func (_c *mockacquireSource_add_Call) RunAndReturn(run func(fd int, onReadable func()) (acquireRemover, error)) *mockacquireSource_add_Call {
111- _c.Call.Return(run)
112- return _c
113-}
@@ -1,113 +0,0 @@
1-// Code generated by mockery; DO NOT EDIT.
2-// github.com/vektra/mockery
3-// template: testify
4-
5-package wayland
6-
7-import (
8- mock "github.com/stretchr/testify/mock"
9-)
10-
11-// newMockacquireSource creates a new instance of mockacquireSource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12-// The first argument is typically a *testing.T value.
13-func newMockacquireSource(t interface {
14- mock.TestingT
15- Cleanup(func())
16-}) *mockacquireSource {
17- if helper, ok := t.(interface{ Helper() }); ok {
18- helper.Helper()
19- }
20-
21- mock := &mockacquireSource{}
22- mock.Mock.Test(t)
23-
24- t.Cleanup(func() {
25- if helper, ok := t.(interface{ Helper() }); ok {
26- helper.Helper()
27- }
28- mock.AssertExpectations(t)
29- })
30-
31- return mock
32-}
33-
34-// mockacquireSource is an autogenerated mock type for the acquireSource type
35-type mockacquireSource struct {
36- mock.Mock
37-}
38-
39-type mockacquireSource_Expecter struct {
40- mock *mock.Mock
41-}
42-
43-func (_m *mockacquireSource) EXPECT() *mockacquireSource_Expecter {
44- return &mockacquireSource_Expecter{mock: &_m.Mock}
45-}
46-
47-// add provides a mock function for the type mockacquireSource
48-func (_mock *mockacquireSource) add(fd int, onReadable func()) (acquireRemover, error) {
49- ret := _mock.Called(fd, onReadable)
50-
51- if len(ret) == 0 {
52- panic("no return value specified for add")
53- }
54-
55- var r0 acquireRemover
56- var r1 error
57- if returnFunc, ok := ret.Get(0).(func(int, func()) (acquireRemover, error)); ok {
58- return returnFunc(fd, onReadable)
59- }
60- if returnFunc, ok := ret.Get(0).(func(int, func()) acquireRemover); ok {
61- r0 = returnFunc(fd, onReadable)
62- } else {
63- if ret.Get(0) != nil {
64- r0 = ret.Get(0).(acquireRemover)
65- }
66- }
67- if returnFunc, ok := ret.Get(1).(func(int, func()) error); ok {
68- r1 = returnFunc(fd, onReadable)
69- } else {
70- r1 = ret.Error(1)
71- }
72- return r0, r1
73-}
74-
75-// mockacquireSource_add_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'add'
76-type mockacquireSource_add_Call struct {
77- *mock.Call
78-}
79-
80-// add is a helper method to define mock.On call
81-// - fd int
82-// - onReadable func()
83-func (_e *mockacquireSource_Expecter) add(fd any, onReadable any) *mockacquireSource_add_Call {
84- return &mockacquireSource_add_Call{Call: _e.mock.On("add", fd, onReadable)}
85-}
86-
87-func (_c *mockacquireSource_add_Call) Run(run func(fd int, onReadable func())) *mockacquireSource_add_Call {
88- _c.Call.Run(func(args mock.Arguments) {
89- var arg0 int
90- if args[0] != nil {
91- arg0 = args[0].(int)
92- }
93- var arg1 func()
94- if args[1] != nil {
95- arg1 = args[1].(func())
96- }
97- run(
98- arg0,
99- arg1,
100- )
101- })
102- return _c
103-}
104-
105-func (_c *mockacquireSource_add_Call) Return(acquireRemoverMoqParam acquireRemover, err error) *mockacquireSource_add_Call {
106- _c.Call.Return(acquireRemoverMoqParam, err)
107- return _c
108-}
109-
110-func (_c *mockacquireSource_add_Call) RunAndReturn(run func(fd int, onReadable func()) (acquireRemover, error)) *mockacquireSource_add_Call {
111- _c.Call.Return(run)
112- return _c
113-}
M internal/adapters/wayland/server.go +0 -2
@@ -459,7 +459,6 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
459459 s.tokens = map[string]activationToken{}
460460 s.foreignExports = map[string]*foreignExport{}
461461 s.syncWait.log = log
462- s.syncWait.src, s.syncWait.pace = displayAcquireSource{d}, s.acquireSignalled
463462 s.cleanup = func() {
464463 if s.paceReady {
465464 unix.Close(s.paceFD)
@@ -586,7 +585,6 @@ func (s *Server) Run(ctx context.Context) error {
586585 }
587586 s.castInventoryChanged()
588587 err := s.display.Run(ctx)
589- s.syncWait.releasePending()
590588 s.stopSecurityContexts()
591589 wg.Wait()
592590 return err
@@ -459,7 +459,6 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
459 s.tokens = map[string]activationToken{}459 s.tokens = map[string]activationToken{}
460 s.foreignExports = map[string]*foreignExport{}460 s.foreignExports = map[string]*foreignExport{}
461 s.syncWait.log = log461 s.syncWait.log = log
462- s.syncWait.src, s.syncWait.pace = displayAcquireSource{d}, s.acquireSignalled
463 s.cleanup = func() {462 s.cleanup = func() {
464 if s.paceReady {463 if s.paceReady {
465 unix.Close(s.paceFD)464 unix.Close(s.paceFD)
@@ -586,7 +585,6 @@ func (s *Server) Run(ctx context.Context) error {
586 }585 }
587 s.castInventoryChanged()586 s.castInventoryChanged()
588 err := s.display.Run(ctx)587 err := s.display.Run(ctx)
589- s.syncWait.releasePending()
590 s.stopSecurityContexts()588 s.stopSecurityContexts()
591 wg.Wait()589 wg.Wait()
592 return err590 return err
M internal/adapters/wayland/session_lock_surface_test.go +2 -2
@@ -470,8 +470,8 @@ func TestLockSurfaceDestroyRetiresFencedChildDependencies(t *testing.T) {
470470 if destroy == "surface" {
471471 remaining, refs = 0, 1 // parent destruction detaches the child
472472 }
473- if len(root.queue) != 0 || len(child.queue) != remaining || !dep.retired || !parent.retired || dep.refs != refs || parent.refs != 1 || !wait.done || wait.efd >= 0 || tl.uses != 0 {
474- t.Errorf("dependency teardown: child=%d dep refs=%d parent refs=%d uses=%d done=%v", len(child.queue), dep.refs, parent.refs, tl.uses, wait.done)
473+ if len(root.queue) != 0 || len(child.queue) != remaining || !dep.retired || !parent.retired || dep.refs != refs || parent.refs != 1 || !wait.cancelled.Load() || tl.uses != 0 {
474+ t.Errorf("dependency teardown: child=%d dep refs=%d parent refs=%d uses=%d cancelled=%v", len(child.queue), dep.refs, parent.refs, tl.uses, wait.cancelled.Load())
475475 }
476476 dep.applyGraph()
477477 parent.applyGraph()
@@ -470,8 +470,8 @@ func TestLockSurfaceDestroyRetiresFencedChildDependencies(t *testing.T) {
470 if destroy == "surface" {470 if destroy == "surface" {
471 remaining, refs = 0, 1 // parent destruction detaches the child471 remaining, refs = 0, 1 // parent destruction detaches the child
472 }472 }
473- if len(root.queue) != 0 || len(child.queue) != remaining || !dep.retired || !parent.retired || dep.refs != refs || parent.refs != 1 || !wait.done || wait.efd >= 0 || tl.uses != 0 {473+ if len(root.queue) != 0 || len(child.queue) != remaining || !dep.retired || !parent.retired || dep.refs != refs || parent.refs != 1 || !wait.cancelled.Load() || tl.uses != 0 {
474- t.Errorf("dependency teardown: child=%d dep refs=%d parent refs=%d uses=%d done=%v", len(child.queue), dep.refs, parent.refs, tl.uses, wait.done)474+ t.Errorf("dependency teardown: child=%d dep refs=%d parent refs=%d uses=%d cancelled=%v", len(child.queue), dep.refs, parent.refs, tl.uses, wait.cancelled.Load())
475 }475 }
476 dep.applyGraph()476 dep.applyGraph()
477 parent.applyGraph()477 parent.applyGraph()
M internal/adapters/wayland/syncobj.go +18 -114
@@ -191,6 +191,9 @@ func (s *surface) pendingNotDMABuf() bool {
191191 return !dma
192192 }
193193
194+// syncWaiter watches acquire points: each gets an eventfd the kernel
195+// writes once the point is signalled, and the pacer is woken to apply the
196+// commits that waited for it.
194197 type acquirePoller interface {
195198 poll([]unix.PollFd, int) (int, error)
196199 }
@@ -201,38 +204,7 @@ func (p unixAcquirePoller) poll(fds []unix.PollFd, timeout int) (int, error) {
201204 return p.waiter.Poll(fds, timeout)
202205 }
203206
204-// acquireSource registers an acquire eventfd with the display loop: onReadable
205-// runs on the display goroutine while fd is readable. Display goroutine only.
206-type acquireSource interface {
207- add(fd int, onReadable func()) (acquireRemover, error)
208-}
209-
210-// acquireRemover unregisters a source added by acquireSource.
211-type acquireRemover interface {
212- Remove()
213-}
214-
215-type displayAcquireSource struct{ d *server.Display }
216-
217-func (s displayAcquireSource) add(fd int, onReadable func()) (acquireRemover, error) {
218- src, err := s.d.AddFD(fd, onReadable)
219- if err != nil {
220- return nil, err
221- }
222- return src, nil
223-}
224-
225-// syncWaiter watches acquire points: each gets an eventfd the kernel
226-// writes once the point is signalled, and the pacer is woken to apply the
227-// commits that waited for it. Explicit waits are display-loop sources; the
228-// goroutine (run) with its pipe polls implicit dma-buf fences only.
229207 type syncWaiter struct {
230- // src and pace serve explicit waits, which run on the display loop:
231- // src registers the eventfd there, pace applies what the fired wait
232- // unblocked (display goroutine only).
233- src acquireSource
234- pace func()
235- pending map[*syncWait]struct{} // explicit waits still registered (display goroutine, then Run's exit)
236208 poller acquirePoller
237209 waiter *fdwait.Poller
238210 terminal bool // polling ended in failure; protected by mu
@@ -246,12 +218,9 @@ type syncWaiter struct {
246218 pollError sync.Once
247219 }
248220
249-// syncWait is one acquire point a queued commit waits for. An explicit wait
250-// lives on the display loop only; an implicit one is polled by run.
221+// syncWait is one acquire point a queued commit waits for.
251222 type syncWait struct {
252223 efd int
253- src acquireRemover // explicit: the display-loop registration, nil once removed
254- explicit bool
255224 done bool // the eventfd fired or the wait was cancelled (display goroutine)
256225 failed bool // observation failed; done must not authorize publication
257226 cancelled atomic.Bool // run stops polling it and closes efd
@@ -271,65 +240,30 @@ func newSyncWaiter(wake func()) (*syncWaiter, error) {
271240 return &syncWaiter{poller: unixAcquirePoller{p}, waiter: p, ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w, log: zerowrap.Default()}, nil
272241 }
273242
274-// watch registers an eventfd for p on the display loop; the wait is ready once
275-// it fires. Display goroutine only.
243+// watch registers an eventfd for p; the wait is ready once it fires.
276244 func (sw *syncWaiter) watch(p syncPoint) (*syncWait, error) {
245+ if sw.stopped() {
246+ return &syncWait{efd: -1, done: true, failed: true}, unix.EIO
247+ }
277248 ready, err := p.tl.dev.signalled(p.tl.handle, p.point)
278249 if err != nil || ready {
279- return &syncWait{efd: -1, explicit: true, done: true, failed: err != nil}, err
250+ return &syncWait{efd: -1, done: true, failed: err != nil}, err
280251 }
281252 efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
282253 if err != nil {
283- return &syncWait{efd: -1, explicit: true, done: true, failed: true}, err
254+ return &syncWait{efd: -1, done: true, failed: true}, err
284255 }
285256 if err := p.tl.dev.eventfd(p.tl.handle, p.point, efd); err != nil {
286257 unix.Close(efd)
287- return &syncWait{efd: -1, explicit: true, done: true, failed: true}, err
258+ return &syncWait{efd: -1, done: true, failed: true}, err
288259 }
289- w := &syncWait{efd: efd, explicit: true}
290- src, err := sw.src.add(efd, func() { sw.onAcquire(w) })
291- if err != nil {
292- unix.Close(efd)
293- return &syncWait{efd: -1, explicit: true, done: true, failed: true}, err
294- }
295- w.src = src
296- if sw.pending == nil {
297- sw.pending = map[*syncWait]struct{}{}
260+ w := sw.add(efd)
261+ if w.failed {
262+ return w, unix.EIO
298263 }
299- sw.pending[w] = struct{}{}
300264 return w, nil
301265 }
302266
303-// onAcquire is the eventfd's callback, on the display goroutine.
304-func (sw *syncWaiter) onAcquire(w *syncWait) {
305- if w.done {
306- return
307- }
308- var b [8]byte
309- if _, err := unix.Read(w.efd, b[:]); err != nil {
310- if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EINTR) {
311- return // not signalled yet: keep waiting
312- }
313- w.failed = true
314- }
315- sw.finish(w)
316- sw.pace()
317-}
318-
319-// finish unregisters then closes an explicit wait's eventfd and marks it done.
320-func (sw *syncWaiter) finish(w *syncWait) {
321- delete(sw.pending, w)
322- if w.src != nil {
323- w.src.Remove()
324- w.src = nil
325- }
326- if w.efd >= 0 {
327- unix.Close(w.efd)
328- w.efd = -1
329- }
330- w.done = true
331-}
332-
333267 // watchImplicit waits for a dma-buf's exclusive fences without blocking dispatch.
334268 func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error {
335269 if sw.stopped() {
@@ -402,9 +336,6 @@ func (sw *syncWaiter) add(fd int) *syncWait {
402336
403337 // fired reports whether w's point is signalled, and forgets w once it has.
404338 func (sw *syncWaiter) fired(w *syncWait) bool {
405- if w.explicit {
406- return w.done // display goroutine only
407- }
408339 sw.mu.Lock()
409340 defer sw.mu.Unlock()
410341 if ready, ok := sw.ready[w]; ok {
@@ -416,14 +347,7 @@ func (sw *syncWaiter) fired(w *syncWait) bool {
416347
417348 // cancel stops watching w (the commit was dropped).
418349 func (sw *syncWaiter) cancel(w *syncWait) {
419- if w == nil {
420- return
421- }
422- if w.explicit {
423- sw.finish(w)
424- return
425- }
426- if w.efd < 0 {
350+ if w == nil || w.efd < 0 {
427351 return
428352 }
429353 w.cancelled.Store(true)
@@ -431,7 +355,7 @@ func (sw *syncWaiter) cancel(w *syncWait) {
431355 delete(sw.ready, w)
432356 sw.mu.Unlock()
433357 w.done = true
434- _, _ = sw.pipeW.Write([]byte{0}) // run closes its fence fd
358+ _, _ = sw.pipeW.Write([]byte{0}) // run closes its eventfd
435359 }
436360
437361 func (sw *syncWaiter) stopped() bool {
@@ -456,12 +380,12 @@ func (sw *syncWaiter) fail(waits []*syncWait) {
456380 sw.wake()
457381 }
458382
459-// run polls the implicit dma-buf fence descriptors; it owns and closes them.
383+// run polls every watched eventfd; it owns and closes them.
460384 func (sw *syncWaiter) run(ctx context.Context) {
461385 defer sw.pipeR.Close()
462386 defer sw.waiter.Close()
463387 pipeFD := int(sw.pipeR.Fd())
464- // The pipe lives as long as the poller; the fence fds come and go. The
388+ // The pipe lives as long as the poller; the eventfds come and go. The
465389 // poll sleeps until a fence, a new or cancelled wait (the pipe), or the
466390 // end of ctx.
467391 var waits []*syncWait
@@ -669,23 +593,3 @@ func (s *surface) applySync(cs *commitSync) {
669593 s.server.dropTimeline(cs.acquire.tl)
670594 s.hold = syncHold{acquire: f, release: cs.release}
671595 }
672-
673-// acquireSignalled applies what a fired acquire point unblocked: like a
674-// commit, it ticks inline instead of posting a tick to the display loop.
675-func (s *Server) acquireSignalled() {
676- s.paceDepth++
677- s.wakePacer()
678- s.paceDepth--
679- if s.paceDepth == 0 && s.paceDirty {
680- s.paceDirty = false
681- s.paceNow()
682- }
683-}
684-
685-// releasePending unregisters and closes the explicit waits still pending at
686-// shutdown. Called once the display loop has stopped.
687-func (sw *syncWaiter) releasePending() {
688- for w := range sw.pending {
689- sw.finish(w)
690- }
691-}
@@ -191,6 +191,9 @@ func (s *surface) pendingNotDMABuf() bool {
191 return !dma191 return !dma
192 }192 }
193 193
194+// syncWaiter watches acquire points: each gets an eventfd the kernel
195+// writes once the point is signalled, and the pacer is woken to apply the
196+// commits that waited for it.
194 type acquirePoller interface {197 type acquirePoller interface {
195 poll([]unix.PollFd, int) (int, error)198 poll([]unix.PollFd, int) (int, error)
196 }199 }
@@ -201,38 +204,7 @@ func (p unixAcquirePoller) poll(fds []unix.PollFd, timeout int) (int, error) {
201 return p.waiter.Poll(fds, timeout)204 return p.waiter.Poll(fds, timeout)
202 }205 }
203 206
204-// acquireSource registers an acquire eventfd with the display loop: onReadable
205-// runs on the display goroutine while fd is readable. Display goroutine only.
206-type acquireSource interface {
207- add(fd int, onReadable func()) (acquireRemover, error)
208-}
209-
210-// acquireRemover unregisters a source added by acquireSource.
211-type acquireRemover interface {
212- Remove()
213-}
214-
215-type displayAcquireSource struct{ d *server.Display }
216-
217-func (s displayAcquireSource) add(fd int, onReadable func()) (acquireRemover, error) {
218- src, err := s.d.AddFD(fd, onReadable)
219- if err != nil {
220- return nil, err
221- }
222- return src, nil
223-}
224-
225-// syncWaiter watches acquire points: each gets an eventfd the kernel
226-// writes once the point is signalled, and the pacer is woken to apply the
227-// commits that waited for it. Explicit waits are display-loop sources; the
228-// goroutine (run) with its pipe polls implicit dma-buf fences only.
229 type syncWaiter struct {207 type syncWaiter struct {
230- // src and pace serve explicit waits, which run on the display loop:
231- // src registers the eventfd there, pace applies what the fired wait
232- // unblocked (display goroutine only).
233- src acquireSource
234- pace func()
235- pending map[*syncWait]struct{} // explicit waits still registered (display goroutine, then Run's exit)
236 poller acquirePoller208 poller acquirePoller
237 waiter *fdwait.Poller209 waiter *fdwait.Poller
238 terminal bool // polling ended in failure; protected by mu210 terminal bool // polling ended in failure; protected by mu
@@ -246,12 +218,9 @@ type syncWaiter struct {
246 pollError sync.Once218 pollError sync.Once
247 }219 }
248 220
249-// syncWait is one acquire point a queued commit waits for. An explicit wait221+// syncWait is one acquire point a queued commit waits for.
250-// lives on the display loop only; an implicit one is polled by run.
251 type syncWait struct {222 type syncWait struct {
252 efd int223 efd int
253- src acquireRemover // explicit: the display-loop registration, nil once removed
254- explicit bool
255 done bool // the eventfd fired or the wait was cancelled (display goroutine)224 done bool // the eventfd fired or the wait was cancelled (display goroutine)
256 failed bool // observation failed; done must not authorize publication225 failed bool // observation failed; done must not authorize publication
257 cancelled atomic.Bool // run stops polling it and closes efd226 cancelled atomic.Bool // run stops polling it and closes efd
@@ -271,65 +240,30 @@ func newSyncWaiter(wake func()) (*syncWaiter, error) {
271 return &syncWaiter{poller: unixAcquirePoller{p}, waiter: p, ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w, log: zerowrap.Default()}, nil240 return &syncWaiter{poller: unixAcquirePoller{p}, waiter: p, ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w, log: zerowrap.Default()}, nil
272 }241 }
273 242
274-// watch registers an eventfd for p on the display loop; the wait is ready once243+// watch registers an eventfd for p; the wait is ready once it fires.
275-// it fires. Display goroutine only.
276 func (sw *syncWaiter) watch(p syncPoint) (*syncWait, error) {244 func (sw *syncWaiter) watch(p syncPoint) (*syncWait, error) {
245+ if sw.stopped() {
246+ return &syncWait{efd: -1, done: true, failed: true}, unix.EIO
247+ }
277 ready, err := p.tl.dev.signalled(p.tl.handle, p.point)248 ready, err := p.tl.dev.signalled(p.tl.handle, p.point)
278 if err != nil || ready {249 if err != nil || ready {
279- return &syncWait{efd: -1, explicit: true, done: true, failed: err != nil}, err250+ return &syncWait{efd: -1, done: true, failed: err != nil}, err
280 }251 }
281 efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)252 efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
282 if err != nil {253 if err != nil {
283- return &syncWait{efd: -1, explicit: true, done: true, failed: true}, err254+ return &syncWait{efd: -1, done: true, failed: true}, err
284 }255 }
285 if err := p.tl.dev.eventfd(p.tl.handle, p.point, efd); err != nil {256 if err := p.tl.dev.eventfd(p.tl.handle, p.point, efd); err != nil {
286 unix.Close(efd)257 unix.Close(efd)
287- return &syncWait{efd: -1, explicit: true, done: true, failed: true}, err258+ return &syncWait{efd: -1, done: true, failed: true}, err
288 }259 }
289- w := &syncWait{efd: efd, explicit: true}260+ w := sw.add(efd)
290- src, err := sw.src.add(efd, func() { sw.onAcquire(w) })261+ if w.failed {
291- if err != nil {262+ return w, unix.EIO
292- unix.Close(efd)
293- return &syncWait{efd: -1, explicit: true, done: true, failed: true}, err
294- }
295- w.src = src
296- if sw.pending == nil {
297- sw.pending = map[*syncWait]struct{}{}
298 }263 }
299- sw.pending[w] = struct{}{}
300 return w, nil264 return w, nil
301 }265 }
302 266
303-// onAcquire is the eventfd's callback, on the display goroutine.
304-func (sw *syncWaiter) onAcquire(w *syncWait) {
305- if w.done {
306- return
307- }
308- var b [8]byte
309- if _, err := unix.Read(w.efd, b[:]); err != nil {
310- if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EINTR) {
311- return // not signalled yet: keep waiting
312- }
313- w.failed = true
314- }
315- sw.finish(w)
316- sw.pace()
317-}
318-
319-// finish unregisters then closes an explicit wait's eventfd and marks it done.
320-func (sw *syncWaiter) finish(w *syncWait) {
321- delete(sw.pending, w)
322- if w.src != nil {
323- w.src.Remove()
324- w.src = nil
325- }
326- if w.efd >= 0 {
327- unix.Close(w.efd)
328- w.efd = -1
329- }
330- w.done = true
331-}
332-
333 // watchImplicit waits for a dma-buf's exclusive fences without blocking dispatch.267 // watchImplicit waits for a dma-buf's exclusive fences without blocking dispatch.
334 func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error {268 func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error {
335 if sw.stopped() {269 if sw.stopped() {
@@ -402,9 +336,6 @@ func (sw *syncWaiter) add(fd int) *syncWait {
402 336
403 // fired reports whether w's point is signalled, and forgets w once it has.337 // fired reports whether w's point is signalled, and forgets w once it has.
404 func (sw *syncWaiter) fired(w *syncWait) bool {338 func (sw *syncWaiter) fired(w *syncWait) bool {
405- if w.explicit {
406- return w.done // display goroutine only
407- }
408 sw.mu.Lock()339 sw.mu.Lock()
409 defer sw.mu.Unlock()340 defer sw.mu.Unlock()
410 if ready, ok := sw.ready[w]; ok {341 if ready, ok := sw.ready[w]; ok {
@@ -416,14 +347,7 @@ func (sw *syncWaiter) fired(w *syncWait) bool {
416 347
417 // cancel stops watching w (the commit was dropped).348 // cancel stops watching w (the commit was dropped).
418 func (sw *syncWaiter) cancel(w *syncWait) {349 func (sw *syncWaiter) cancel(w *syncWait) {
419- if w == nil {350+ if w == nil || w.efd < 0 {
420- return
421- }
422- if w.explicit {
423- sw.finish(w)
424- return
425- }
426- if w.efd < 0 {
427 return351 return
428 }352 }
429 w.cancelled.Store(true)353 w.cancelled.Store(true)
@@ -431,7 +355,7 @@ func (sw *syncWaiter) cancel(w *syncWait) {
431 delete(sw.ready, w)355 delete(sw.ready, w)
432 sw.mu.Unlock()356 sw.mu.Unlock()
433 w.done = true357 w.done = true
434- _, _ = sw.pipeW.Write([]byte{0}) // run closes its fence fd358+ _, _ = sw.pipeW.Write([]byte{0}) // run closes its eventfd
435 }359 }
436 360
437 func (sw *syncWaiter) stopped() bool {361 func (sw *syncWaiter) stopped() bool {
@@ -456,12 +380,12 @@ func (sw *syncWaiter) fail(waits []*syncWait) {
456 sw.wake()380 sw.wake()
457 }381 }
458 382
459-// run polls the implicit dma-buf fence descriptors; it owns and closes them.383+// run polls every watched eventfd; it owns and closes them.
460 func (sw *syncWaiter) run(ctx context.Context) {384 func (sw *syncWaiter) run(ctx context.Context) {
461 defer sw.pipeR.Close()385 defer sw.pipeR.Close()
462 defer sw.waiter.Close()386 defer sw.waiter.Close()
463 pipeFD := int(sw.pipeR.Fd())387 pipeFD := int(sw.pipeR.Fd())
464- // The pipe lives as long as the poller; the fence fds come and go. The388+ // The pipe lives as long as the poller; the eventfds come and go. The
465 // poll sleeps until a fence, a new or cancelled wait (the pipe), or the389 // poll sleeps until a fence, a new or cancelled wait (the pipe), or the
466 // end of ctx.390 // end of ctx.
467 var waits []*syncWait391 var waits []*syncWait
@@ -669,23 +593,3 @@ func (s *surface) applySync(cs *commitSync) {
669 s.server.dropTimeline(cs.acquire.tl)593 s.server.dropTimeline(cs.acquire.tl)
670 s.hold = syncHold{acquire: f, release: cs.release}594 s.hold = syncHold{acquire: f, release: cs.release}
671 }595 }
672-
673-// acquireSignalled applies what a fired acquire point unblocked: like a
674-// commit, it ticks inline instead of posting a tick to the display loop.
675-func (s *Server) acquireSignalled() {
676- s.paceDepth++
677- s.wakePacer()
678- s.paceDepth--
679- if s.paceDepth == 0 && s.paceDirty {
680- s.paceDirty = false
681- s.paceNow()
682- }
683-}
684-
685-// releasePending unregisters and closes the explicit waits still pending at
686-// shutdown. Called once the display loop has stopped.
687-func (sw *syncWaiter) releasePending() {
688- for w := range sw.pending {
689- sw.finish(w)
690- }
691-}
D internal/adapters/wayland/syncwait_explicit_test.go +0 -161
@@ -1,161 +0,0 @@
1-package wayland
2-
3-import (
4- "testing"
5- "time"
6-
7- "github.com/stretchr/testify/mock"
8- "golang.org/x/sys/unix"
9-)
10-
11-// explicitWaiter is a waiter whose explicit waits register with a mocked
12-// display-loop source; the callback and fd are captured.
13-type explicitWaiter struct {
14- sw *syncWaiter
15- src *mockacquireSource
16- rm *mockacquireRemover
17- paced int
18- fd int
19- fire func()
20- point syncPoint
21-}
22-
23-func newExplicitWaiter(t *testing.T, addErr error) *explicitWaiter {
24- t.Helper()
25- e := &explicitWaiter{src: newMockacquireSource(t), rm: newMockacquireRemover(t)}
26- dev := newMocksyncobjDevice(t)
27- dev.EXPECT().signalled(uint32(7), uint64(4)).Return(false, nil).Once()
28- dev.EXPECT().eventfd(uint32(7), uint64(4), mock.Anything).Return(nil).Once()
29- e.point = syncPoint{tl: &timeline{dev: dev, handle: 7}, point: 4}
30- sw, err := newSyncWaiter(func() { t.Error("explicit wait posted a tick") })
31- if err != nil {
32- t.Fatal(err)
33- }
34- t.Cleanup(func() { sw.discard(); sw.close() })
35- sw.src, sw.pace = e.src, func() { e.paced++ }
36- e.sw = sw
37- e.src.EXPECT().add(mock.Anything, mock.Anything).RunAndReturn(func(fd int, f func()) (acquireRemover, error) {
38- e.fd, e.fire = fd, f
39- if addErr != nil {
40- return nil, addErr
41- }
42- return e.rm, nil
43- }).Once()
44- return e
45-}
46-
47-func fdClosed(fd int) bool {
48- _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
49- return err == unix.EBADF
50-}
51-
52-// A fired eventfd removes the source, closes the fd and wakes the pacer
53-// inline, never through a posted tick.
54-func TestExplicitWaitFiresOnDisplayLoop(t *testing.T) {
55- e := newExplicitWaiter(t, nil)
56- w, err := e.sw.watch(e.point)
57- if err != nil || e.sw.fired(w) {
58- t.Fatalf("watch: %v fired=%v", err, e.sw.fired(w))
59- }
60- e.rm.EXPECT().Remove().Once()
61- one := [8]byte{1}
62- if _, err := unix.Write(e.fd, one[:]); err != nil {
63- t.Fatal(err)
64- }
65- fd := e.fd
66- e.fire()
67- if !e.sw.fired(w) || w.failed || e.paced != 1 || !fdClosed(fd) {
68- t.Fatalf("fired=%v failed=%v paced=%d closed=%v", e.sw.fired(w), w.failed, e.paced, fdClosed(fd))
69- }
70- e.sw.cancel(w) // applySync cancels after the fire: nothing more to release
71-}
72-
73-// Cancelling before the fire removes the source, then closes the fd.
74-func TestExplicitWaitCancelRemovesThenCloses(t *testing.T) {
75- e := newExplicitWaiter(t, nil)
76- w, err := e.sw.watch(e.point)
77- if err != nil {
78- t.Fatal(err)
79- }
80- e.rm.EXPECT().Remove().RunAndReturn(func() {
81- if fdClosed(e.fd) {
82- t.Error("fd closed before Remove")
83- }
84- }).Once()
85- e.sw.cancel(w)
86- if !e.sw.fired(w) || !fdClosed(e.fd) || e.paced != 0 {
87- t.Fatal("cancel left the wait registered")
88- }
89- e.sw.cancel(w)
90-}
91-
92-func TestExplicitWaitAddFailureFails(t *testing.T) {
93- e := newExplicitWaiter(t, unix.ENOMEM)
94- w, err := e.sw.watch(e.point)
95- if err == nil || !e.sw.fired(w) || !w.failed || !fdClosed(e.fd) {
96- t.Fatalf("add failure: err=%v", err)
97- }
98-}
99-
100-// A callback with nothing to read keeps the wait registered.
101-func TestExplicitWaitSpuriousCallbackKeepsWaiting(t *testing.T) {
102- e := newExplicitWaiter(t, nil)
103- w, err := e.sw.watch(e.point)
104- if err != nil {
105- t.Fatal(err)
106- }
107- e.fire()
108- if e.sw.fired(w) || e.paced != 0 || fdClosed(e.fd) {
109- t.Fatal("spurious callback ended the wait")
110- }
111- e.rm.EXPECT().Remove().Once()
112- e.sw.cancel(w)
113-}
114-
115-// Shutdown releases waits still registered, Remove before Close.
116-func TestExplicitWaitReleasePending(t *testing.T) {
117- e := newExplicitWaiter(t, nil)
118- if _, err := e.sw.watch(e.point); err != nil {
119- t.Fatal(err)
120- }
121- e.rm.EXPECT().Remove().Once()
122- e.sw.releasePending()
123- if !fdClosed(e.fd) {
124- t.Fatal("pending wait leaked its eventfd")
125- }
126-}
127-
128-// End to end: a queued commit stays queued until the callback runs, then
129-// is applied by that same tick.
130-func TestSyncobjCommitAppliedByAcquireCallback(t *testing.T) {
131- h := newSyncHarness(t)
132- h.releases = map[uint32]*syncReleaseProxy{}
133- src, rm := newMockacquireSource(t), newMockacquireRemover(t)
134- var fire func()
135- src.EXPECT().add(mock.Anything, mock.Anything).RunAndReturn(func(_ int, f func()) (acquireRemover, error) {
136- fire = f
137- return rm, nil
138- }).Once()
139- rm.EXPECT().Remove().Maybe()
140- h.s.display.Do(func() { h.s.syncWait.src = src })
141- a := h.dmabuf()
142- h.commit(a, 1, 2)
143- if _, ok := h.content(50 * time.Millisecond); ok {
144- t.Fatal("content before the acquire point fired")
145- }
146- h.fire(1) // the kernel signals: efd readable (display loop is mocked, so nothing reads it)
147- var applied, posted bool
148- h.s.display.Do(func() {
149- fire()
150- // Same tick: the commit left the queue without posting a pace tick.
151- applied = len(h.s.windows[h.win].xdg.surface.queue) == 0
152- posted = h.s.paceSignal.Load()
153- })
154- if !applied || posted {
155- t.Fatalf("acquire callback: applied=%v posted tick=%v, want applied inline", applied, posted)
156- }
157- got, ok := h.content(2 * time.Second)
158- if !ok || got.Acquire == nil {
159- t.Fatalf("content %+v", got)
160- }
161-}
@@ -1,161 +0,0 @@
1-package wayland
2-
3-import (
4- "testing"
5- "time"
6-
7- "github.com/stretchr/testify/mock"
8- "golang.org/x/sys/unix"
9-)
10-
11-// explicitWaiter is a waiter whose explicit waits register with a mocked
12-// display-loop source; the callback and fd are captured.
13-type explicitWaiter struct {
14- sw *syncWaiter
15- src *mockacquireSource
16- rm *mockacquireRemover
17- paced int
18- fd int
19- fire func()
20- point syncPoint
21-}
22-
23-func newExplicitWaiter(t *testing.T, addErr error) *explicitWaiter {
24- t.Helper()
25- e := &explicitWaiter{src: newMockacquireSource(t), rm: newMockacquireRemover(t)}
26- dev := newMocksyncobjDevice(t)
27- dev.EXPECT().signalled(uint32(7), uint64(4)).Return(false, nil).Once()
28- dev.EXPECT().eventfd(uint32(7), uint64(4), mock.Anything).Return(nil).Once()
29- e.point = syncPoint{tl: &timeline{dev: dev, handle: 7}, point: 4}
30- sw, err := newSyncWaiter(func() { t.Error("explicit wait posted a tick") })
31- if err != nil {
32- t.Fatal(err)
33- }
34- t.Cleanup(func() { sw.discard(); sw.close() })
35- sw.src, sw.pace = e.src, func() { e.paced++ }
36- e.sw = sw
37- e.src.EXPECT().add(mock.Anything, mock.Anything).RunAndReturn(func(fd int, f func()) (acquireRemover, error) {
38- e.fd, e.fire = fd, f
39- if addErr != nil {
40- return nil, addErr
41- }
42- return e.rm, nil
43- }).Once()
44- return e
45-}
46-
47-func fdClosed(fd int) bool {
48- _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
49- return err == unix.EBADF
50-}
51-
52-// A fired eventfd removes the source, closes the fd and wakes the pacer
53-// inline, never through a posted tick.
54-func TestExplicitWaitFiresOnDisplayLoop(t *testing.T) {
55- e := newExplicitWaiter(t, nil)
56- w, err := e.sw.watch(e.point)
57- if err != nil || e.sw.fired(w) {
58- t.Fatalf("watch: %v fired=%v", err, e.sw.fired(w))
59- }
60- e.rm.EXPECT().Remove().Once()
61- one := [8]byte{1}
62- if _, err := unix.Write(e.fd, one[:]); err != nil {
63- t.Fatal(err)
64- }
65- fd := e.fd
66- e.fire()
67- if !e.sw.fired(w) || w.failed || e.paced != 1 || !fdClosed(fd) {
68- t.Fatalf("fired=%v failed=%v paced=%d closed=%v", e.sw.fired(w), w.failed, e.paced, fdClosed(fd))
69- }
70- e.sw.cancel(w) // applySync cancels after the fire: nothing more to release
71-}
72-
73-// Cancelling before the fire removes the source, then closes the fd.
74-func TestExplicitWaitCancelRemovesThenCloses(t *testing.T) {
75- e := newExplicitWaiter(t, nil)
76- w, err := e.sw.watch(e.point)
77- if err != nil {
78- t.Fatal(err)
79- }
80- e.rm.EXPECT().Remove().RunAndReturn(func() {
81- if fdClosed(e.fd) {
82- t.Error("fd closed before Remove")
83- }
84- }).Once()
85- e.sw.cancel(w)
86- if !e.sw.fired(w) || !fdClosed(e.fd) || e.paced != 0 {
87- t.Fatal("cancel left the wait registered")
88- }
89- e.sw.cancel(w)
90-}
91-
92-func TestExplicitWaitAddFailureFails(t *testing.T) {
93- e := newExplicitWaiter(t, unix.ENOMEM)
94- w, err := e.sw.watch(e.point)
95- if err == nil || !e.sw.fired(w) || !w.failed || !fdClosed(e.fd) {
96- t.Fatalf("add failure: err=%v", err)
97- }
98-}
99-
100-// A callback with nothing to read keeps the wait registered.
101-func TestExplicitWaitSpuriousCallbackKeepsWaiting(t *testing.T) {
102- e := newExplicitWaiter(t, nil)
103- w, err := e.sw.watch(e.point)
104- if err != nil {
105- t.Fatal(err)
106- }
107- e.fire()
108- if e.sw.fired(w) || e.paced != 0 || fdClosed(e.fd) {
109- t.Fatal("spurious callback ended the wait")
110- }
111- e.rm.EXPECT().Remove().Once()
112- e.sw.cancel(w)
113-}
114-
115-// Shutdown releases waits still registered, Remove before Close.
116-func TestExplicitWaitReleasePending(t *testing.T) {
117- e := newExplicitWaiter(t, nil)
118- if _, err := e.sw.watch(e.point); err != nil {
119- t.Fatal(err)
120- }
121- e.rm.EXPECT().Remove().Once()
122- e.sw.releasePending()
123- if !fdClosed(e.fd) {
124- t.Fatal("pending wait leaked its eventfd")
125- }
126-}
127-
128-// End to end: a queued commit stays queued until the callback runs, then
129-// is applied by that same tick.
130-func TestSyncobjCommitAppliedByAcquireCallback(t *testing.T) {
131- h := newSyncHarness(t)
132- h.releases = map[uint32]*syncReleaseProxy{}
133- src, rm := newMockacquireSource(t), newMockacquireRemover(t)
134- var fire func()
135- src.EXPECT().add(mock.Anything, mock.Anything).RunAndReturn(func(_ int, f func()) (acquireRemover, error) {
136- fire = f
137- return rm, nil
138- }).Once()
139- rm.EXPECT().Remove().Maybe()
140- h.s.display.Do(func() { h.s.syncWait.src = src })
141- a := h.dmabuf()
142- h.commit(a, 1, 2)
143- if _, ok := h.content(50 * time.Millisecond); ok {
144- t.Fatal("content before the acquire point fired")
145- }
146- h.fire(1) // the kernel signals: efd readable (display loop is mocked, so nothing reads it)
147- var applied, posted bool
148- h.s.display.Do(func() {
149- fire()
150- // Same tick: the commit left the queue without posting a pace tick.
151- applied = len(h.s.windows[h.win].xdg.surface.queue) == 0
152- posted = h.s.paceSignal.Load()
153- })
154- if !applied || posted {
155- t.Fatalf("acquire callback: applied=%v posted tick=%v, want applied inline", applied, posted)
156- }
157- got, ok := h.content(2 * time.Second)
158- if !ok || got.Acquire == nil {
159- t.Fatalf("content %+v", got)
160- }
161-}
M internal/core/action.go +47 -1
@@ -388,7 +388,39 @@ func (c *Core) dispatchAction(a Action) Effect {
388388 if a == ActionMoveColumnLeft {
389389 dir, d = -1, dirLeft
390390 }
391- if i := c.neighbor(d); w.leavesBand(w.Focus+dir) && i >= 0 {
391+ // A fullscreen window (tiled or floating) leaves for the neighbor
392+ // screen at once and stays fullscreen there; with no screen,
393+ // nothing moves.
394+ i := c.neighbor(d)
395+ if fid, ok := w.Focused(); ok && w.fullscreen == fid && (w.floatIndex(fid) >= 0 || !w.onFloat()) {
396+ if i < 0 {
397+ return Effect{}
398+ }
399+ var col Column
400+ if w.floatIndex(fid) < 0 {
401+ if col, ok = w.takeColumn(); !ok {
402+ return Effect{}
403+ }
404+ }
405+ w.reconcileFloats() // floats the fullscreen kept demoted show again
406+ from := c.cur()
407+ c.focusScreen = i
408+ to := c.cur().mon.Current()
409+ if len(col.Windows) == 0 {
410+ moveFloat(w, to, fid)
411+ } else {
412+ at := 0
413+ if d == dirLeft {
414+ at = len(to.Columns)
415+ }
416+ to.receive(col, at)
417+ to.SetFullscreen(fid, true)
418+ }
419+ from.mon.normalize()
420+ c.cur().mon.normalize()
421+ return Effect{}
422+ }
423+ if i >= 0 && w.leavesBand(w.Focus+dir) {
392424 col, ok := w.takeColumn()
393425 if !ok {
394426 return Effect{}
@@ -608,3 +640,17 @@ func (w *Workspace) Apply(a Action) Effect {
608640 }
609641 return Effect{}
610642 }
643+
644+// moveFloat moves float id from workspace cur to to, as MoveToWorkspace
645+// does, focuses it there and keeps it fullscreen.
646+func moveFloat(cur, to *Workspace, id WindowID) {
647+ fl := cur.Floats[cur.floatIndex(id)]
648+ if fl.free && fl.back != nil {
649+ fl = fl.rehome()
650+ }
651+ cur.RemoveWindow(id)
652+ to.AddFloating(id, fl.W, fl.H)
653+ to.Floats[len(to.Floats)-1] = fl
654+ to.Activate(id)
655+ to.SetFullscreen(id, true)
656+}
@@ -388,7 +388,39 @@ func (c *Core) dispatchAction(a Action) Effect {
388 if a == ActionMoveColumnLeft {388 if a == ActionMoveColumnLeft {
389 dir, d = -1, dirLeft389 dir, d = -1, dirLeft
390 }390 }
391- if i := c.neighbor(d); w.leavesBand(w.Focus+dir) && i >= 0 {391+ // A fullscreen window (tiled or floating) leaves for the neighbor
392+ // screen at once and stays fullscreen there; with no screen,
393+ // nothing moves.
394+ i := c.neighbor(d)
395+ if fid, ok := w.Focused(); ok && w.fullscreen == fid && (w.floatIndex(fid) >= 0 || !w.onFloat()) {
396+ if i < 0 {
397+ return Effect{}
398+ }
399+ var col Column
400+ if w.floatIndex(fid) < 0 {
401+ if col, ok = w.takeColumn(); !ok {
402+ return Effect{}
403+ }
404+ }
405+ w.reconcileFloats() // floats the fullscreen kept demoted show again
406+ from := c.cur()
407+ c.focusScreen = i
408+ to := c.cur().mon.Current()
409+ if len(col.Windows) == 0 {
410+ moveFloat(w, to, fid)
411+ } else {
412+ at := 0
413+ if d == dirLeft {
414+ at = len(to.Columns)
415+ }
416+ to.receive(col, at)
417+ to.SetFullscreen(fid, true)
418+ }
419+ from.mon.normalize()
420+ c.cur().mon.normalize()
421+ return Effect{}
422+ }
423+ if i >= 0 && w.leavesBand(w.Focus+dir) {
392 col, ok := w.takeColumn()424 col, ok := w.takeColumn()
393 if !ok {425 if !ok {
394 return Effect{}426 return Effect{}
@@ -608,3 +640,17 @@ func (w *Workspace) Apply(a Action) Effect {
608 }640 }
609 return Effect{}641 return Effect{}
610 }642 }
643+
644+// moveFloat moves float id from workspace cur to to, as MoveToWorkspace
645+// does, focuses it there and keeps it fullscreen.
646+func moveFloat(cur, to *Workspace, id WindowID) {
647+ fl := cur.Floats[cur.floatIndex(id)]
648+ if fl.free && fl.back != nil {
649+ fl = fl.rehome()
650+ }
651+ cur.RemoveWindow(id)
652+ to.AddFloating(id, fl.W, fl.H)
653+ to.Floats[len(to.Floats)-1] = fl
654+ to.Activate(id)
655+ to.SetFullscreen(id, true)
656+}
M internal/core/cascade.go +80 -14
@@ -371,6 +371,9 @@ func (w *Workspace) shiftBand(lo, hi, dir, at int) bool {
371371 id, _ := w.Focused()
372372 bands := w.bandList()
373373 cols := slices.Clone(bands[b][lo-first : hi-first+1])
374+ for k := range cols {
375+ cols[k].ownBand = false
376+ }
374377 bands[b] = slices.Delete(bands[b], lo-first, hi-first+1)
375378 w.setBands(w.sendBand(bands, b, dir, at, cols), id)
376379 return true
@@ -419,6 +422,53 @@ func (w *Workspace) isolateBand() {
419422 w.setBands(bands, id)
420423 }
421424
425+// fullscreenBand gives tiled window id, about to go fullscreen, a band of
426+// its own right below the band it is in, so the other columns keep their
427+// band and a focus change among them never touches the fullscreen output.
428+// A window stacked in a column is expelled into a column of its own first.
429+// Leaving fullscreen does not move it back: the column keeps its band, so
430+// nothing jumps when the user leaves. Only cascade layouts do this; the
431+// focus stays on the same window.
432+func (w *Workspace) fullscreenBand(id WindowID) {
433+ i := w.columnOf(id)
434+ if !w.policy().wraps || i < 0 {
435+ return
436+ }
437+ b := w.band(i)
438+ first, last := w.bandBounds(b)
439+ if len(w.Columns[i].Windows) == 1 && first == last {
440+ return
441+ }
442+ fc := w.Columns[w.Focus]
443+ focus := fc.Windows[fc.Focus]
444+ bands := w.bandList()
445+ col := bands[b][i-first]
446+ nc := col
447+ if len(col.Windows) == 1 {
448+ // The column leaves its lane, as deleteColumn: manual widths of the
449+ // lane no longer add up. It keeps its slot and its own width.
450+ w.clearSpans(i)
451+ bands[b] = slices.Delete(bands[b], i-first, i-first+1)
452+ } else {
453+ j := slices.Index(col.Windows, id)
454+ // The expelled window gets a plain column: no Span (a manual width
455+ // is the stack's, and the new band is alone), no shares.
456+ nc = Column{Windows: []WindowID{id}, Cells: col.Cells}
457+ col.Windows = slices.Delete(slices.Clone(col.Windows), j, j+1)
458+ col.Shares = nil
459+ if j == 0 {
460+ col.Slot = 0 // the slot window left
461+ }
462+ if col.Focus > j || col.Focus >= len(col.Windows) {
463+ col.Focus = max(min(col.Focus-1, len(col.Windows)-1), 0)
464+ }
465+ bands[b][i-first] = col
466+ }
467+ nc.ownBand = true
468+ bands = slices.Insert(bands, b+1, []Column{nc})
469+ w.setBands(bands, focus)
470+}
471+
422472 // pushNeighbor makes room in the focused column's full cascade band for a
423473 // wider column: the column at the far end of the band leaves it. The
424474 // rightmost goes to the band below; from the last column, the leftmost
@@ -457,13 +507,10 @@ func (w *Workspace) restoreBand() bool {
457507 bands := w.bandList()
458508 b := w.band(w.Focus)
459509 room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
460- // take removes from band nb the remembered columns found there, at
461- // most room of them, in the order of ids walked from the focused
510+ // take removes from the bands nbs the remembered columns found there,
511+ // at most room of them, in the order of ids walked from the focused
462512 // column outward (rev: ids end nearest).
463- take := func(nb int, ids [][]WindowID, rev bool) []Column {
464- if nb < 0 || nb >= len(bands) {
465- return nil
466- }
513+ take := func(nbs []int, ids [][]WindowID, rev bool) []Column {
467514 var got []Column
468515 for k := range ids {
469516 if room == 0 {
@@ -481,21 +528,40 @@ func (w *Workspace) restoreBand() bool {
481528 if head < 0 {
482529 continue
483530 }
484- i := slices.IndexFunc(bands[nb], func(c Column) bool { return c.Windows[0] == e[head] })
485- if i < 0 {
486- continue
531+ for k, nb := range nbs {
532+ // Past the adjacent band, only a column fullscreenBand put in
533+ // a band of its own is taken: one the user moved there stays.
534+ i := slices.IndexFunc(bands[nb], func(c Column) bool {
535+ return c.Windows[0] == e[head] && (k == 0 || c.ownBand)
536+ })
537+ if i < 0 {
538+ continue
539+ }
540+ got = append(got, bands[nb][i])
541+ bands[nb] = slices.Delete(bands[nb], i, i+1)
542+ room--
543+ break
487544 }
488- got = append(got, bands[nb][i])
489- bands[nb] = slices.Delete(bands[nb], i, i+1)
490- room--
491545 }
492546 if rev {
493547 slices.Reverse(got)
494548 }
495549 return got
496550 }
497- above := take(b-1, up, true)
498- below := take(b+1, down, false)
551+ // reach lists the bands dir away to look in: the adjacent one, and the
552+ // next, where a column that fullscreenBand split off may lie.
553+ reach := func(dir int) []int {
554+ var nbs []int
555+ for n := b + dir; n >= 0 && n < len(bands); n += dir {
556+ nbs = append(nbs, n)
557+ if len(nbs) == 2 {
558+ break
559+ }
560+ }
561+ return nbs
562+ }
563+ above := take(reach(-1), up, true)
564+ below := take(reach(1), down, false)
499565 bands[b] = slices.Concat(above, bands[b], below)
500566 w.setBands(bands, id)
501567 return true
@@ -371,6 +371,9 @@ func (w *Workspace) shiftBand(lo, hi, dir, at int) bool {
371 id, _ := w.Focused()371 id, _ := w.Focused()
372 bands := w.bandList()372 bands := w.bandList()
373 cols := slices.Clone(bands[b][lo-first : hi-first+1])373 cols := slices.Clone(bands[b][lo-first : hi-first+1])
374+ for k := range cols {
375+ cols[k].ownBand = false
376+ }
374 bands[b] = slices.Delete(bands[b], lo-first, hi-first+1)377 bands[b] = slices.Delete(bands[b], lo-first, hi-first+1)
375 w.setBands(w.sendBand(bands, b, dir, at, cols), id)378 w.setBands(w.sendBand(bands, b, dir, at, cols), id)
376 return true379 return true
@@ -419,6 +422,53 @@ func (w *Workspace) isolateBand() {
419 w.setBands(bands, id)422 w.setBands(bands, id)
420 }423 }
421 424
425+// fullscreenBand gives tiled window id, about to go fullscreen, a band of
426+// its own right below the band it is in, so the other columns keep their
427+// band and a focus change among them never touches the fullscreen output.
428+// A window stacked in a column is expelled into a column of its own first.
429+// Leaving fullscreen does not move it back: the column keeps its band, so
430+// nothing jumps when the user leaves. Only cascade layouts do this; the
431+// focus stays on the same window.
432+func (w *Workspace) fullscreenBand(id WindowID) {
433+ i := w.columnOf(id)
434+ if !w.policy().wraps || i < 0 {
435+ return
436+ }
437+ b := w.band(i)
438+ first, last := w.bandBounds(b)
439+ if len(w.Columns[i].Windows) == 1 && first == last {
440+ return
441+ }
442+ fc := w.Columns[w.Focus]
443+ focus := fc.Windows[fc.Focus]
444+ bands := w.bandList()
445+ col := bands[b][i-first]
446+ nc := col
447+ if len(col.Windows) == 1 {
448+ // The column leaves its lane, as deleteColumn: manual widths of the
449+ // lane no longer add up. It keeps its slot and its own width.
450+ w.clearSpans(i)
451+ bands[b] = slices.Delete(bands[b], i-first, i-first+1)
452+ } else {
453+ j := slices.Index(col.Windows, id)
454+ // The expelled window gets a plain column: no Span (a manual width
455+ // is the stack's, and the new band is alone), no shares.
456+ nc = Column{Windows: []WindowID{id}, Cells: col.Cells}
457+ col.Windows = slices.Delete(slices.Clone(col.Windows), j, j+1)
458+ col.Shares = nil
459+ if j == 0 {
460+ col.Slot = 0 // the slot window left
461+ }
462+ if col.Focus > j || col.Focus >= len(col.Windows) {
463+ col.Focus = max(min(col.Focus-1, len(col.Windows)-1), 0)
464+ }
465+ bands[b][i-first] = col
466+ }
467+ nc.ownBand = true
468+ bands = slices.Insert(bands, b+1, []Column{nc})
469+ w.setBands(bands, focus)
470+}
471+
422 // pushNeighbor makes room in the focused column's full cascade band for a472 // pushNeighbor makes room in the focused column's full cascade band for a
423 // wider column: the column at the far end of the band leaves it. The473 // wider column: the column at the far end of the band leaves it. The
424 // rightmost goes to the band below; from the last column, the leftmost474 // rightmost goes to the band below; from the last column, the leftmost
@@ -457,13 +507,10 @@ func (w *Workspace) restoreBand() bool {
457 bands := w.bandList()507 bands := w.bandList()
458 b := w.band(w.Focus)508 b := w.band(w.Focus)
459 room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)509 room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
460- // take removes from band nb the remembered columns found there, at510+ // take removes from the bands nbs the remembered columns found there,
461- // most room of them, in the order of ids walked from the focused511+ // at most room of them, in the order of ids walked from the focused
462 // column outward (rev: ids end nearest).512 // column outward (rev: ids end nearest).
463- take := func(nb int, ids [][]WindowID, rev bool) []Column {513+ take := func(nbs []int, ids [][]WindowID, rev bool) []Column {
464- if nb < 0 || nb >= len(bands) {
465- return nil
466- }
467 var got []Column514 var got []Column
468 for k := range ids {515 for k := range ids {
469 if room == 0 {516 if room == 0 {
@@ -481,21 +528,40 @@ func (w *Workspace) restoreBand() bool {
481 if head < 0 {528 if head < 0 {
482 continue529 continue
483 }530 }
484- i := slices.IndexFunc(bands[nb], func(c Column) bool { return c.Windows[0] == e[head] })531+ for k, nb := range nbs {
485- if i < 0 {532+ // Past the adjacent band, only a column fullscreenBand put in
486- continue533+ // a band of its own is taken: one the user moved there stays.
534+ i := slices.IndexFunc(bands[nb], func(c Column) bool {
535+ return c.Windows[0] == e[head] && (k == 0 || c.ownBand)
536+ })
537+ if i < 0 {
538+ continue
539+ }
540+ got = append(got, bands[nb][i])
541+ bands[nb] = slices.Delete(bands[nb], i, i+1)
542+ room--
543+ break
487 }544 }
488- got = append(got, bands[nb][i])
489- bands[nb] = slices.Delete(bands[nb], i, i+1)
490- room--
491 }545 }
492 if rev {546 if rev {
493 slices.Reverse(got)547 slices.Reverse(got)
494 }548 }
495 return got549 return got
496 }550 }
497- above := take(b-1, up, true)551+ // reach lists the bands dir away to look in: the adjacent one, and the
498- below := take(b+1, down, false)552+ // next, where a column that fullscreenBand split off may lie.
553+ reach := func(dir int) []int {
554+ var nbs []int
555+ for n := b + dir; n >= 0 && n < len(bands); n += dir {
556+ nbs = append(nbs, n)
557+ if len(nbs) == 2 {
558+ break
559+ }
560+ }
561+ return nbs
562+ }
563+ above := take(reach(-1), up, true)
564+ below := take(reach(1), down, false)
499 bands[b] = slices.Concat(above, bands[b], below)565 bands[b] = slices.Concat(above, bands[b], below)
500 w.setBands(bands, id)566 w.setBands(bands, id)
501 return true567 return true
A internal/core/cascade_fullscreen_band_test.go +297 -0
@@ -0,0 +1,297 @@
1+package core
2+
3+import "testing"
4+
5+// Entering fullscreen gives a cascade column a band below its own; the
6+// others stay. Leaving keeps it there.
7+func TestCascadeFullscreenOwnBand(t *testing.T) {
8+ for _, tc := range []struct {
9+ name string
10+ do func(w *Workspace)
11+ }{
12+ {"toggle", func(w *Workspace) { w.ToggleFullscreen() }},
13+ {"client request", func(w *Workspace) { w.SetFullscreen(3, true) }},
14+ } {
15+ t.Run(tc.name, func(t *testing.T) {
16+ c, w := cascadeMovesCore(t, 3, 3, false)
17+ wantBands(t, w, []WindowID{1, 2, 3})
18+ tc.do(w)
19+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
20+ wantFocus(t, c, "M", 3)
21+ if w.fullscreen != 3 {
22+ t.Fatalf("fullscreen %d, want 3", w.fullscreen)
23+ }
24+ w.ToggleFullscreen()
25+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
26+ })
27+ }
28+}
29+
30+func TestCascadeFullscreenMiddleAndAlone(t *testing.T) {
31+ c, w := cascadeMovesCore(t, 3, 3, false)
32+ w.FocusID(2)
33+ w.ToggleFullscreen()
34+ wantBands(t, w, []WindowID{1, 3}, []WindowID{2})
35+ wantFocus(t, c, "M", 2)
36+
37+ _, w = cascadeMovesCore(t, 1, 3, false)
38+ w.ToggleFullscreen()
39+ wantBands(t, w, []WindowID{1})
40+}
41+
42+func TestCascadeFullscreenExpelsFromStack(t *testing.T) {
43+ c, w := cascadeMovesCore(t, 2, 3, false)
44+ w.FocusID(2)
45+ c.applyAction(ActionConsumeOrExpelLeft) // 2 stacks under 1
46+ wantColumns(t, w, 1)
47+ w.ToggleFullscreen()
48+ wantBands(t, w, []WindowID{1}, []WindowID{2})
49+ if len(w.Columns[0].Windows) != 1 || w.fullscreen != 2 {
50+ t.Fatalf("columns %v fullscreen %d", w.Columns, w.fullscreen)
51+ }
52+ wantFocus(t, c, "M", 2)
53+}
54+
55+func TestScrollFullscreenKeepsColumns(t *testing.T) {
56+ _, w := cascadeMovesCore(t, 3, 3, false)
57+ w.Overflow = OverflowScroll
58+ w.forgetBands()
59+ w.ToggleFullscreen()
60+ if len(w.Columns) != 3 {
61+ t.Fatalf("columns %d", len(w.Columns))
62+ }
63+}
64+
65+func TestFullscreenColumnMovesToNeighborMonitor(t *testing.T) {
66+ for _, tc := range []struct {
67+ action Action
68+ screen string
69+ }{{ActionMoveColumnLeft, "L"}, {ActionMoveColumnRight, "R"}} {
70+ t.Run(tc.screen, func(t *testing.T) {
71+ c, w := cascadeMovesCore(t, 3, 3, true)
72+ w.ToggleFullscreen()
73+ c.applyAction(tc.action)
74+ wantColumns(t, w, 1, 2)
75+ dst := screenWorkspace(c, tc.screen)
76+ wantColumns(t, dst, 3)
77+ wantFocus(t, c, tc.screen, 3)
78+ if dst.fullscreen != 3 || w.fullscreen != 0 {
79+ t.Fatalf("fullscreen dst %d src %d", dst.fullscreen, w.fullscreen)
80+ }
81+ })
82+ }
83+}
84+
85+func TestFullscreenColumnNoNeighborStays(t *testing.T) {
86+ c, w := cascadeMovesCore(t, 3, 3, false)
87+ w.ToggleFullscreen()
88+ c.applyAction(ActionMoveColumnLeft)
89+ c.applyAction(ActionMoveColumnRight)
90+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
91+ if w.fullscreen != 3 {
92+ t.Fatalf("fullscreen %d", w.fullscreen)
93+ }
94+}
95+
96+func TestFullscreenFloatMovesToNeighborMonitor(t *testing.T) {
97+ for _, tc := range []struct {
98+ action Action
99+ screen string
100+ }{{ActionMoveColumnLeft, "L"}, {ActionMoveColumnRight, "R"}} {
101+ t.Run(tc.screen, func(t *testing.T) {
102+ c, w := cascadeMovesCore(t, 2, 3, true)
103+ w.AddFloating(9, 5120, 2160)
104+ w.SetFullscreen(9, true)
105+ c.applyAction(tc.action)
106+ dst := screenWorkspace(c, tc.screen)
107+ if w.floatIndex(9) >= 0 || dst.floatIndex(9) < 0 || w.fullscreen != 0 || dst.fullscreen != 9 {
108+ t.Fatalf("float on src %v dst %v, fullscreen src %d dst %d", w.Floats, dst.Floats, w.fullscreen, dst.fullscreen)
109+ }
110+ wantFocus(t, c, tc.screen, 9)
111+ wantColumns(t, w, 1, 2)
112+ })
113+ }
114+}
115+
116+func TestFullscreenFloatNoNeighborStays(t *testing.T) {
117+ c, w := cascadeMovesCore(t, 2, 3, false)
118+ w.AddFloating(9, 800, 600)
119+ w.SetFullscreen(9, true)
120+ c.applyAction(ActionMoveColumnLeft)
121+ if w.floatIndex(9) < 0 || w.fullscreen != 9 {
122+ t.Fatalf("float moved: %v fullscreen %d", w.Floats, w.fullscreen)
123+ }
124+}
125+
126+func TestCascadeFullscreenRepeatedRequestKeepsBands(t *testing.T) {
127+ _, w := cascadeMovesCore(t, 3, 3, false)
128+ w.SetFullscreen(3, true)
129+ w.SetFullscreen(3, true)
130+ w.SetFullscreen(3, true)
131+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
132+}
133+
134+func TestCascadeFullscreenStackedFixups(t *testing.T) {
135+ for _, tc := range []struct {
136+ name string
137+ focus WindowID
138+ rest WindowID
139+ slotKept bool
140+ }{{"last row", 3, 2, true}, {"first row", 2, 3, false}} {
141+ t.Run(tc.name, func(t *testing.T) {
142+ c, w := cascadeMovesCore(t, 3, 3, false)
143+ w.FocusID(3)
144+ c.applyAction(ActionConsumeOrExpelLeft) // 3 stacks under 2
145+ w.FocusID(tc.focus)
146+ w.Columns[1].Shares = []int{5000, 5000}
147+ w.Columns[1].Span = 3000
148+ w.Columns[1].Slot = 4
149+ w.SetFullscreen(tc.focus, true)
150+ wantBands(t, w, []WindowID{1, tc.rest}, []WindowID{tc.focus})
151+ col := w.Columns[1]
152+ if len(col.Windows) != 1 || col.Windows[0] != tc.rest || col.Shares != nil || col.Focus != 0 || (col.Slot == 4) != tc.slotKept {
153+ t.Fatalf("remaining column %+v", col)
154+ }
155+ if got, _ := w.Focused(); got != tc.focus {
156+ t.Fatalf("focus %d, want %d", got, tc.focus)
157+ }
158+ if w.Columns[2].Span != 0 || w.Columns[2].Slot != 0 {
159+ t.Fatalf("expelled column %+v", w.Columns[2])
160+ }
161+ })
162+ }
163+}
164+
165+func TestCascadeFullscreenKeepsSlotOfIntactColumn(t *testing.T) {
166+ _, w := cascadeMovesCore(t, 3, 3, false)
167+ w.Columns[2].Slot = 4
168+ w.ToggleFullscreen()
169+ if i := w.columnOf(3); w.Columns[i].Slot != 4 {
170+ t.Fatalf("slot %d, want 4", w.Columns[i].Slot)
171+ }
172+}
173+
174+func TestCascadeFullscreenAfterMaximizeRestores(t *testing.T) {
175+ _, w := cascadeMovesCore(t, 3, 3, false)
176+ w.FocusID(1)
177+ w.ToggleFullWidth()
178+ w.FocusID(3)
179+ w.ToggleFullscreen()
180+ w.ToggleFullscreen()
181+ w.FocusID(1)
182+ w.ToggleFullWidth()
183+ wantBands(t, w, []WindowID{1, 2, 3})
184+}
185+
186+func TestFullscreenMoveEdges(t *testing.T) {
187+ t.Run("destination fullscreen elsewhere", func(t *testing.T) {
188+ c, w := cascadeMovesCore(t, 2, 3, true)
189+ dst := screenWorkspace(c, "R")
190+ dst.AddWindow(20)
191+ dst.SetFullscreen(20, true)
192+ w.ToggleFullscreen()
193+ c.applyAction(ActionMoveColumnRight)
194+ if dst.fullscreen != 2 {
195+ t.Fatalf("fullscreen %d, want 2", dst.fullscreen)
196+ }
197+ wantFocus(t, c, "R", 2)
198+ })
199+ t.Run("scroll layout", func(t *testing.T) {
200+ c, w := cascadeMovesCore(t, 2, 3, true)
201+ w.Overflow = OverflowScroll
202+ w.ToggleFullscreen()
203+ c.applyAction(ActionMoveColumnLeft)
204+ dst := screenWorkspace(c, "L")
205+ wantColumns(t, dst, 2)
206+ if dst.fullscreen != 2 || w.fullscreen != 0 {
207+ t.Fatalf("fullscreen dst %d src %d", dst.fullscreen, w.fullscreen)
208+ }
209+ })
210+ t.Run("source floats promoted", func(t *testing.T) {
211+ c, w := cascadeMovesCore(t, 2, 3, true)
212+ w.AddFloating(9, 100, 100)
213+ w.FocusID(2)
214+ w.ToggleFullscreen()
215+ // A fullscreen keeps a float demoted without it covering the output.
216+ w.Floats[0].below = true
217+ if len(w.Floats) != 1 || !w.Floats[0].below || w.fullscreen != 2 {
218+ t.Fatalf("setup: float not demoted: %+v", w.Floats)
219+ }
220+ c.applyAction(ActionMoveColumnRight)
221+ if len(w.Floats) != 1 || w.Floats[0].below {
222+ t.Fatalf("float still demoted: %+v", w.Floats)
223+ }
224+ })
225+}
226+
227+func TestCascadeFullscreenRestoresAcrossBand(t *testing.T) {
228+ _, w := cascadeMovesCore(t, 3, 3, false)
229+ w.FocusID(1)
230+ w.ToggleFullWidth() // X=1 alone; 2 and 3 pushed below
231+ wantBands(t, w, []WindowID{1}, []WindowID{2, 3})
232+ w.FocusID(2)
233+ w.ToggleFullscreen()
234+ wantBands(t, w, []WindowID{1}, []WindowID{3}, []WindowID{2})
235+ w.ToggleFullscreen()
236+ w.FocusID(1)
237+ w.ToggleFullWidth()
238+ wantBands(t, w, []WindowID{1, 2, 3})
239+}
240+
241+func TestFullscreenMoveDestinations(t *testing.T) {
242+ t.Run("arrival focused despite left mark", func(t *testing.T) {
243+ // The destination has a pending "left" mark on the window, which
244+ // mayCover would refuse unless it is focused; the arrival is
245+ // focused, so it ends fullscreen.
246+ c, w := cascadeMovesCore(t, 2, 3, true)
247+ dst := screenWorkspace(c, "R")
248+ dst.left = 2
249+ w.ToggleFullscreen()
250+ c.applyAction(ActionMoveColumnRight)
251+ wantFocus(t, c, "R", 2)
252+ if dst.fullscreen != 2 {
253+ t.Fatalf("fullscreen %d, want 2", dst.fullscreen)
254+ }
255+ })
256+ t.Run("fullscreen window expelled from a stack then moved", func(t *testing.T) {
257+ c, w := cascadeMovesCore(t, 3, 3, true)
258+ w.FocusID(3)
259+ c.applyAction(ActionConsumeOrExpelLeft) // 3 under 2
260+ w.ToggleFullscreen() // 3 expelled to its own band
261+ c.applyAction(ActionMoveColumnLeft)
262+ wantColumns(t, w, 1, 2)
263+ dst := screenWorkspace(c, "L")
264+ wantColumns(t, dst, 3)
265+ if dst.fullscreen != 3 || len(w.Columns[1].Windows) != 1 {
266+ t.Fatalf("fullscreen %d, source column %v", dst.fullscreen, w.Columns[1].Windows)
267+ }
268+ })
269+ t.Run("destination with columns gets its own band", func(t *testing.T) {
270+ c, w := cascadeMovesCore(t, 1, 3, true)
271+ dst := screenWorkspace(c, "R")
272+ dst.Overflow = OverflowCascade
273+ dst.SetMaxColumns(3)
274+ dst.AddWindow(20)
275+ dst.AddWindow(21)
276+ w.ToggleFullscreen()
277+ c.applyAction(ActionMoveColumnRight)
278+ wantBands(t, dst, []WindowID{20, 21}, []WindowID{1})
279+ if dst.fullscreen != 1 {
280+ t.Fatalf("fullscreen %d", dst.fullscreen)
281+ }
282+ })
283+}
284+
285+// A band the user shaped is not looked past: P, pushed down from F, then
286+// moved past Y on purpose, stays there.
287+func TestRestoreBandDoesNotLookPastUserBand(t *testing.T) {
288+ c, w := cascadeMovesCore(t, 3, 3, false)
289+ w.FocusID(1)
290+ w.ToggleFullWidth() // {1} {2 3}
291+ w.FocusID(2)
292+ c.applyAction(ActionMoveWindowDown) // {1} {3} {2}
293+ wantBands(t, w, []WindowID{1}, []WindowID{3}, []WindowID{2})
294+ w.FocusID(1)
295+ w.ToggleFullWidth()
296+ wantBands(t, w, []WindowID{1, 3}, []WindowID{2})
297+}
@@ -0,0 +1,297 @@
1+package core
2+
3+import "testing"
4+
5+// Entering fullscreen gives a cascade column a band below its own; the
6+// others stay. Leaving keeps it there.
7+func TestCascadeFullscreenOwnBand(t *testing.T) {
8+ for _, tc := range []struct {
9+ name string
10+ do func(w *Workspace)
11+ }{
12+ {"toggle", func(w *Workspace) { w.ToggleFullscreen() }},
13+ {"client request", func(w *Workspace) { w.SetFullscreen(3, true) }},
14+ } {
15+ t.Run(tc.name, func(t *testing.T) {
16+ c, w := cascadeMovesCore(t, 3, 3, false)
17+ wantBands(t, w, []WindowID{1, 2, 3})
18+ tc.do(w)
19+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
20+ wantFocus(t, c, "M", 3)
21+ if w.fullscreen != 3 {
22+ t.Fatalf("fullscreen %d, want 3", w.fullscreen)
23+ }
24+ w.ToggleFullscreen()
25+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
26+ })
27+ }
28+}
29+
30+func TestCascadeFullscreenMiddleAndAlone(t *testing.T) {
31+ c, w := cascadeMovesCore(t, 3, 3, false)
32+ w.FocusID(2)
33+ w.ToggleFullscreen()
34+ wantBands(t, w, []WindowID{1, 3}, []WindowID{2})
35+ wantFocus(t, c, "M", 2)
36+
37+ _, w = cascadeMovesCore(t, 1, 3, false)
38+ w.ToggleFullscreen()
39+ wantBands(t, w, []WindowID{1})
40+}
41+
42+func TestCascadeFullscreenExpelsFromStack(t *testing.T) {
43+ c, w := cascadeMovesCore(t, 2, 3, false)
44+ w.FocusID(2)
45+ c.applyAction(ActionConsumeOrExpelLeft) // 2 stacks under 1
46+ wantColumns(t, w, 1)
47+ w.ToggleFullscreen()
48+ wantBands(t, w, []WindowID{1}, []WindowID{2})
49+ if len(w.Columns[0].Windows) != 1 || w.fullscreen != 2 {
50+ t.Fatalf("columns %v fullscreen %d", w.Columns, w.fullscreen)
51+ }
52+ wantFocus(t, c, "M", 2)
53+}
54+
55+func TestScrollFullscreenKeepsColumns(t *testing.T) {
56+ _, w := cascadeMovesCore(t, 3, 3, false)
57+ w.Overflow = OverflowScroll
58+ w.forgetBands()
59+ w.ToggleFullscreen()
60+ if len(w.Columns) != 3 {
61+ t.Fatalf("columns %d", len(w.Columns))
62+ }
63+}
64+
65+func TestFullscreenColumnMovesToNeighborMonitor(t *testing.T) {
66+ for _, tc := range []struct {
67+ action Action
68+ screen string
69+ }{{ActionMoveColumnLeft, "L"}, {ActionMoveColumnRight, "R"}} {
70+ t.Run(tc.screen, func(t *testing.T) {
71+ c, w := cascadeMovesCore(t, 3, 3, true)
72+ w.ToggleFullscreen()
73+ c.applyAction(tc.action)
74+ wantColumns(t, w, 1, 2)
75+ dst := screenWorkspace(c, tc.screen)
76+ wantColumns(t, dst, 3)
77+ wantFocus(t, c, tc.screen, 3)
78+ if dst.fullscreen != 3 || w.fullscreen != 0 {
79+ t.Fatalf("fullscreen dst %d src %d", dst.fullscreen, w.fullscreen)
80+ }
81+ })
82+ }
83+}
84+
85+func TestFullscreenColumnNoNeighborStays(t *testing.T) {
86+ c, w := cascadeMovesCore(t, 3, 3, false)
87+ w.ToggleFullscreen()
88+ c.applyAction(ActionMoveColumnLeft)
89+ c.applyAction(ActionMoveColumnRight)
90+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
91+ if w.fullscreen != 3 {
92+ t.Fatalf("fullscreen %d", w.fullscreen)
93+ }
94+}
95+
96+func TestFullscreenFloatMovesToNeighborMonitor(t *testing.T) {
97+ for _, tc := range []struct {
98+ action Action
99+ screen string
100+ }{{ActionMoveColumnLeft, "L"}, {ActionMoveColumnRight, "R"}} {
101+ t.Run(tc.screen, func(t *testing.T) {
102+ c, w := cascadeMovesCore(t, 2, 3, true)
103+ w.AddFloating(9, 5120, 2160)
104+ w.SetFullscreen(9, true)
105+ c.applyAction(tc.action)
106+ dst := screenWorkspace(c, tc.screen)
107+ if w.floatIndex(9) >= 0 || dst.floatIndex(9) < 0 || w.fullscreen != 0 || dst.fullscreen != 9 {
108+ t.Fatalf("float on src %v dst %v, fullscreen src %d dst %d", w.Floats, dst.Floats, w.fullscreen, dst.fullscreen)
109+ }
110+ wantFocus(t, c, tc.screen, 9)
111+ wantColumns(t, w, 1, 2)
112+ })
113+ }
114+}
115+
116+func TestFullscreenFloatNoNeighborStays(t *testing.T) {
117+ c, w := cascadeMovesCore(t, 2, 3, false)
118+ w.AddFloating(9, 800, 600)
119+ w.SetFullscreen(9, true)
120+ c.applyAction(ActionMoveColumnLeft)
121+ if w.floatIndex(9) < 0 || w.fullscreen != 9 {
122+ t.Fatalf("float moved: %v fullscreen %d", w.Floats, w.fullscreen)
123+ }
124+}
125+
126+func TestCascadeFullscreenRepeatedRequestKeepsBands(t *testing.T) {
127+ _, w := cascadeMovesCore(t, 3, 3, false)
128+ w.SetFullscreen(3, true)
129+ w.SetFullscreen(3, true)
130+ w.SetFullscreen(3, true)
131+ wantBands(t, w, []WindowID{1, 2}, []WindowID{3})
132+}
133+
134+func TestCascadeFullscreenStackedFixups(t *testing.T) {
135+ for _, tc := range []struct {
136+ name string
137+ focus WindowID
138+ rest WindowID
139+ slotKept bool
140+ }{{"last row", 3, 2, true}, {"first row", 2, 3, false}} {
141+ t.Run(tc.name, func(t *testing.T) {
142+ c, w := cascadeMovesCore(t, 3, 3, false)
143+ w.FocusID(3)
144+ c.applyAction(ActionConsumeOrExpelLeft) // 3 stacks under 2
145+ w.FocusID(tc.focus)
146+ w.Columns[1].Shares = []int{5000, 5000}
147+ w.Columns[1].Span = 3000
148+ w.Columns[1].Slot = 4
149+ w.SetFullscreen(tc.focus, true)
150+ wantBands(t, w, []WindowID{1, tc.rest}, []WindowID{tc.focus})
151+ col := w.Columns[1]
152+ if len(col.Windows) != 1 || col.Windows[0] != tc.rest || col.Shares != nil || col.Focus != 0 || (col.Slot == 4) != tc.slotKept {
153+ t.Fatalf("remaining column %+v", col)
154+ }
155+ if got, _ := w.Focused(); got != tc.focus {
156+ t.Fatalf("focus %d, want %d", got, tc.focus)
157+ }
158+ if w.Columns[2].Span != 0 || w.Columns[2].Slot != 0 {
159+ t.Fatalf("expelled column %+v", w.Columns[2])
160+ }
161+ })
162+ }
163+}
164+
165+func TestCascadeFullscreenKeepsSlotOfIntactColumn(t *testing.T) {
166+ _, w := cascadeMovesCore(t, 3, 3, false)
167+ w.Columns[2].Slot = 4
168+ w.ToggleFullscreen()
169+ if i := w.columnOf(3); w.Columns[i].Slot != 4 {
170+ t.Fatalf("slot %d, want 4", w.Columns[i].Slot)
171+ }
172+}
173+
174+func TestCascadeFullscreenAfterMaximizeRestores(t *testing.T) {
175+ _, w := cascadeMovesCore(t, 3, 3, false)
176+ w.FocusID(1)
177+ w.ToggleFullWidth()
178+ w.FocusID(3)
179+ w.ToggleFullscreen()
180+ w.ToggleFullscreen()
181+ w.FocusID(1)
182+ w.ToggleFullWidth()
183+ wantBands(t, w, []WindowID{1, 2, 3})
184+}
185+
186+func TestFullscreenMoveEdges(t *testing.T) {
187+ t.Run("destination fullscreen elsewhere", func(t *testing.T) {
188+ c, w := cascadeMovesCore(t, 2, 3, true)
189+ dst := screenWorkspace(c, "R")
190+ dst.AddWindow(20)
191+ dst.SetFullscreen(20, true)
192+ w.ToggleFullscreen()
193+ c.applyAction(ActionMoveColumnRight)
194+ if dst.fullscreen != 2 {
195+ t.Fatalf("fullscreen %d, want 2", dst.fullscreen)
196+ }
197+ wantFocus(t, c, "R", 2)
198+ })
199+ t.Run("scroll layout", func(t *testing.T) {
200+ c, w := cascadeMovesCore(t, 2, 3, true)
201+ w.Overflow = OverflowScroll
202+ w.ToggleFullscreen()
203+ c.applyAction(ActionMoveColumnLeft)
204+ dst := screenWorkspace(c, "L")
205+ wantColumns(t, dst, 2)
206+ if dst.fullscreen != 2 || w.fullscreen != 0 {
207+ t.Fatalf("fullscreen dst %d src %d", dst.fullscreen, w.fullscreen)
208+ }
209+ })
210+ t.Run("source floats promoted", func(t *testing.T) {
211+ c, w := cascadeMovesCore(t, 2, 3, true)
212+ w.AddFloating(9, 100, 100)
213+ w.FocusID(2)
214+ w.ToggleFullscreen()
215+ // A fullscreen keeps a float demoted without it covering the output.
216+ w.Floats[0].below = true
217+ if len(w.Floats) != 1 || !w.Floats[0].below || w.fullscreen != 2 {
218+ t.Fatalf("setup: float not demoted: %+v", w.Floats)
219+ }
220+ c.applyAction(ActionMoveColumnRight)
221+ if len(w.Floats) != 1 || w.Floats[0].below {
222+ t.Fatalf("float still demoted: %+v", w.Floats)
223+ }
224+ })
225+}
226+
227+func TestCascadeFullscreenRestoresAcrossBand(t *testing.T) {
228+ _, w := cascadeMovesCore(t, 3, 3, false)
229+ w.FocusID(1)
230+ w.ToggleFullWidth() // X=1 alone; 2 and 3 pushed below
231+ wantBands(t, w, []WindowID{1}, []WindowID{2, 3})
232+ w.FocusID(2)
233+ w.ToggleFullscreen()
234+ wantBands(t, w, []WindowID{1}, []WindowID{3}, []WindowID{2})
235+ w.ToggleFullscreen()
236+ w.FocusID(1)
237+ w.ToggleFullWidth()
238+ wantBands(t, w, []WindowID{1, 2, 3})
239+}
240+
241+func TestFullscreenMoveDestinations(t *testing.T) {
242+ t.Run("arrival focused despite left mark", func(t *testing.T) {
243+ // The destination has a pending "left" mark on the window, which
244+ // mayCover would refuse unless it is focused; the arrival is
245+ // focused, so it ends fullscreen.
246+ c, w := cascadeMovesCore(t, 2, 3, true)
247+ dst := screenWorkspace(c, "R")
248+ dst.left = 2
249+ w.ToggleFullscreen()
250+ c.applyAction(ActionMoveColumnRight)
251+ wantFocus(t, c, "R", 2)
252+ if dst.fullscreen != 2 {
253+ t.Fatalf("fullscreen %d, want 2", dst.fullscreen)
254+ }
255+ })
256+ t.Run("fullscreen window expelled from a stack then moved", func(t *testing.T) {
257+ c, w := cascadeMovesCore(t, 3, 3, true)
258+ w.FocusID(3)
259+ c.applyAction(ActionConsumeOrExpelLeft) // 3 under 2
260+ w.ToggleFullscreen() // 3 expelled to its own band
261+ c.applyAction(ActionMoveColumnLeft)
262+ wantColumns(t, w, 1, 2)
263+ dst := screenWorkspace(c, "L")
264+ wantColumns(t, dst, 3)
265+ if dst.fullscreen != 3 || len(w.Columns[1].Windows) != 1 {
266+ t.Fatalf("fullscreen %d, source column %v", dst.fullscreen, w.Columns[1].Windows)
267+ }
268+ })
269+ t.Run("destination with columns gets its own band", func(t *testing.T) {
270+ c, w := cascadeMovesCore(t, 1, 3, true)
271+ dst := screenWorkspace(c, "R")
272+ dst.Overflow = OverflowCascade
273+ dst.SetMaxColumns(3)
274+ dst.AddWindow(20)
275+ dst.AddWindow(21)
276+ w.ToggleFullscreen()
277+ c.applyAction(ActionMoveColumnRight)
278+ wantBands(t, dst, []WindowID{20, 21}, []WindowID{1})
279+ if dst.fullscreen != 1 {
280+ t.Fatalf("fullscreen %d", dst.fullscreen)
281+ }
282+ })
283+}
284+
285+// A band the user shaped is not looked past: P, pushed down from F, then
286+// moved past Y on purpose, stays there.
287+func TestRestoreBandDoesNotLookPastUserBand(t *testing.T) {
288+ c, w := cascadeMovesCore(t, 3, 3, false)
289+ w.FocusID(1)
290+ w.ToggleFullWidth() // {1} {2 3}
291+ w.FocusID(2)
292+ c.applyAction(ActionMoveWindowDown) // {1} {3} {2}
293+ wantBands(t, w, []WindowID{1}, []WindowID{3}, []WindowID{2})
294+ w.FocusID(1)
295+ w.ToggleFullWidth()
296+ wantBands(t, w, []WindowID{1, 3}, []WindowID{2})
297+}
M internal/core/cascade_moves_test.go +3 -2
@@ -299,13 +299,14 @@ func TestCascadeMoveWindowStackedFirst(t *testing.T) {
299299 wantFocus(t, c, "M", 5)
300300 }
301301
302-// A fullscreen window keeps its band.
302+// A fullscreen window keeps its own band (it left {4 5} when it went
303+// fullscreen).
303304 func TestCascadeMoveWindowFullscreenStays(t *testing.T) {
304305 c, w := cascadeMovesCore(t, 5, 3, false)
305306 w.FocusID(4)
306307 w.ToggleFullscreen()
307308 c.applyAction(ActionMoveWindowUp)
308- wantColumns(t, w, 1, 2, 3, 4, 5)
309+ wantColumns(t, w, 1, 2, 3, 5, 4)
309310 }
310311
311312 // Scroll overflow has no bands: up/down stop at the column edge.
@@ -299,13 +299,14 @@ func TestCascadeMoveWindowStackedFirst(t *testing.T) {
299 wantFocus(t, c, "M", 5)299 wantFocus(t, c, "M", 5)
300 }300 }
301 301
302-// A fullscreen window keeps its band.302+// A fullscreen window keeps its own band (it left {4 5} when it went
303+// fullscreen).
303 func TestCascadeMoveWindowFullscreenStays(t *testing.T) {304 func TestCascadeMoveWindowFullscreenStays(t *testing.T) {
304 c, w := cascadeMovesCore(t, 5, 3, false)305 c, w := cascadeMovesCore(t, 5, 3, false)
305 w.FocusID(4)306 w.FocusID(4)
306 w.ToggleFullscreen()307 w.ToggleFullscreen()
307 c.applyAction(ActionMoveWindowUp)308 c.applyAction(ActionMoveWindowUp)
308- wantColumns(t, w, 1, 2, 3, 4, 5)309+ wantColumns(t, w, 1, 2, 3, 5, 4)
309 }310 }
310 311
311 // Scroll overflow has no bands: up/down stop at the column edge.312 // Scroll overflow has no bands: up/down stop at the column edge.
M internal/core/cascade_test.go +1 -1
@@ -195,7 +195,7 @@ func TestCascadeCovers(t *testing.T) {
195195 }
196196 w.ToggleFullscreen()
197197 w.FocusID(4)
198- if w.View != 600 {
198+ if w.View != 1200 {
199199 t.Fatal("fullscreen restore lost band reveal")
200200 }
201201 }
@@ -195,7 +195,7 @@ func TestCascadeCovers(t *testing.T) {
195 }195 }
196 w.ToggleFullscreen()196 w.ToggleFullscreen()
197 w.FocusID(4)197 w.FocusID(4)
198- if w.View != 600 {198+ if w.View != 1200 {
199 t.Fatal("fullscreen restore lost band reveal")199 t.Fatal("fullscreen restore lost band reveal")
200 }200 }
201 }201 }
M internal/core/layout.go +9 -0
@@ -40,6 +40,11 @@ type Column struct {
4040 // maximize-column or cycle-column-width sent to the band above or
4141 // below; they come back when the column is restored.
4242 pushedUp, pushedDown [][]WindowID
43+ // ownBand marks a column fullscreenBand put in a band of its own:
44+ // restoreBand brings it back from past the adjacent band, unlike a
45+ // column the user moved there. shiftBand clears it when the column
46+ // moves to another band on purpose.
47+ ownBand bool
4348 }
4449 type Placement struct {
4550 ID WindowID
@@ -1335,6 +1340,7 @@ func (w *Workspace) ToggleFullscreen() {
13351340 w.endFullscreen()
13361341 return
13371342 }
1343+ w.fullscreenBand(id)
13381344 w.fullscreen = id
13391345 w.scroll()
13401346 }
@@ -1444,6 +1450,9 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
14441450 // Another window covers: the override was for the old cover.
14451451 w.dropStashOver()
14461452 }
1453+ if !w.isFloat(id) && w.fullscreen != id {
1454+ w.fullscreenBand(id)
1455+ }
14471456 w.fullscreen = id
14481457 if !w.isFloat(id) {
14491458 w.scroll()
@@ -40,6 +40,11 @@ type Column struct {
40 // maximize-column or cycle-column-width sent to the band above or40 // maximize-column or cycle-column-width sent to the band above or
41 // below; they come back when the column is restored.41 // below; they come back when the column is restored.
42 pushedUp, pushedDown [][]WindowID42 pushedUp, pushedDown [][]WindowID
43+ // ownBand marks a column fullscreenBand put in a band of its own:
44+ // restoreBand brings it back from past the adjacent band, unlike a
45+ // column the user moved there. shiftBand clears it when the column
46+ // moves to another band on purpose.
47+ ownBand bool
43 }48 }
44 type Placement struct {49 type Placement struct {
45 ID WindowID50 ID WindowID
@@ -1335,6 +1340,7 @@ func (w *Workspace) ToggleFullscreen() {
1335 w.endFullscreen()1340 w.endFullscreen()
1336 return1341 return
1337 }1342 }
1343+ w.fullscreenBand(id)
1338 w.fullscreen = id1344 w.fullscreen = id
1339 w.scroll()1345 w.scroll()
1340 }1346 }
@@ -1444,6 +1450,9 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
1444 // Another window covers: the override was for the old cover.1450 // Another window covers: the override was for the old cover.
1445 w.dropStashOver()1451 w.dropStashOver()
1446 }1452 }
1453+ if !w.isFloat(id) && w.fullscreen != id {
1454+ w.fullscreenBand(id)
1455+ }
1447 w.fullscreen = id1456 w.fullscreen = id
1448 if !w.isFloat(id) {1457 if !w.isFloat(id) {
1449 w.scroll()1458 w.scroll()
M internal/core/overview_cascade_test.go +3 -2
@@ -728,7 +728,8 @@ func TestOverviewCascadeCoveringFloat(t *testing.T) {
728728 }
729729 }
730730
731-// T10b: a pinned fullscreen column keeps its Fullscreen tile in the band.
731+// T10b: a pinned fullscreen column keeps its Fullscreen tile, and the
732+// columns of the other bands stay shown.
732733 func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
733734 m := cascadeOverview()
734735 w := m.Current()
@@ -744,7 +745,7 @@ func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
744745 t.Fatalf("fullscreen tile: %+v", fs)
745746 }
746747 for _, id := range []WindowID{7, 9} {
747- if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) {
748+ if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen {
748749 t.Fatalf("band tile %d: %+v", id, p)
749750 }
750751 }
@@ -728,7 +728,8 @@ func TestOverviewCascadeCoveringFloat(t *testing.T) {
728 }728 }
729 }729 }
730 730
731-// T10b: a pinned fullscreen column keeps its Fullscreen tile in the band.731+// T10b: a pinned fullscreen column keeps its Fullscreen tile, and the
732+// columns of the other bands stay shown.
732 func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {733 func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
733 m := cascadeOverview()734 m := cascadeOverview()
734 w := m.Current()735 w := m.Current()
@@ -744,7 +745,7 @@ func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
744 t.Fatalf("fullscreen tile: %+v", fs)745 t.Fatalf("fullscreen tile: %+v", fs)
745 }746 }
746 for _, id := range []WindowID{7, 9} {747 for _, id := range []WindowID{7, 9} {
747- if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) {748+ if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen {
748 t.Fatalf("band tile %d: %+v", id, p)749 t.Fatalf("band tile %d: %+v", id, p)
749 }750 }
750 }751 }

Comments1 open / 1

bnema

Wrap-up review record (head 7ef9454f).

Review 1 (8 findings): fixed in aa552bf9. stale Shares after expel; slot dropped needlessly; ToggleFullscreen after receive -> SetFullscreen; repeated SetFullscreen re-split bands -> early return; source floats not reconciled after a move; single-case switch; restoreBand across the fullscreen band; test gaps. Review 2 (c55955b3):

  • GO-B1 restoreBand looked past any single-column band (regression without fullscreen, {F}{Y}{P}): fixed in f716fc9d with an ownBand mark set by fullscreenBand, cleared by shiftBand; negative test TestRestoreBandDoesNotLookPastUserBand.
  • GO-B2 same root as B1: fixed with it.
  • GO-B3 "mayCover refuses" test did not refuse: no refusal is reachable for a focused cascade arrival; test renamed and comment fixed.
  • GO-B4 misnamed stacked test: renamed.
  • GO-009 partial restore when the adjacent band keeps columns: fixed by the farther lookup limited to ownBand columns.
  • GO-010/011 vacuous float test, leftover log: fixed. GO-012 dispatch duplication: factored. Known limit: restoreBand looks at most one band past the adjacent one.

Headless E2E on 7ef9454f + !32 state field, 2 outputs 800x500, cascade, max-columns 3:

  • tiled: before [1 2 3] one band, fullscreen none; Super+Shift+f on 3 -> 1,2 upper band (400 wide), 3 alone below at 800x500, fullscreen [3]; Super+Shift+Right -> 3 on HEADLESS-2 800x500, focused, fullscreen [3], 1,2 back at y=0.
  • floating (rule floating): fullscreen false -> true; Super+Shift+Right -> focused window on HEADLESS-2, floating, fullscreen true. Checks: make check, make race, make mocks-check, make perf-check, staticcheck ./... green.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close