detached f75a2e7

#30 feat(core): fullscreen windows that map at the output size

state
merged · 7d904d5 · merge commit
branch
feat/auto-fullscreen → main
opened
bnema ·
updated

A parentless toplevel whose first committed size equals the logical size of the output it is on goes into the normal fullscreen state (same state as toggle-fullscreen), without being resized. Main case: X11 games in borderless mode through xwayland-satellite.

Rules:

  • Only the first committed size counts; later resizes and remaps never trigger.
  • Only when the window is already the focused window of the shown workspace (ADR 011: no focus stealing from an existing fullscreen window or on a hidden workspace).
  • No entrance motion is kept on the window, so direct scanout can apply at once.
  • The wayland adapter reports the first size of a tile only when it matches an output; core decides.

Checks: make check, make race, make mocks-check, make perf-check, staticcheck ./... green. Headless E2E through xwayland-satellite: a 640x360 undecorated X11 window on a 640x360 output maps fullscreen and focused; a 300x200 one stays a normal tile.

Needs hardware validation with a real game (borderless mode).

CommitSubjectAuthorDateSignatureStatus
f75a2e7test(core): cover a screen-sized tile mapping under a bound fullscreenbriceGPG Verified
2ca07b7test(core): cover auto-fullscreen focus rulesbriceGPG Verified
bc208bafix(core): keep auto-fullscreen within the focus rulesbriceGPG Verified
fb82a5btest(wayland): cover the first-size report of output-sized toplevelsbriceGPG Verified
43dcf6efeat(core): fullscreen windows that map at the output sizebriceGPG Verified

Checks 1

21 files · +428 -925
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 -2
@@ -260,6 +260,8 @@ bind.XF86MonBrightnessDown = spawn brightnessctl set 5%-
260260 | `scale-up` / `scale-down` | Zoom the display; the scale is saved to the config |
261261 | `quit` | Exit NeferWL |
262262
263+Note on `toggle-fullscreen`: a parentless window whose first committed size is exactly the logical size of the output it is on, without asking for fullscreen (a game in borderless-windowed mode), is put fullscreen as if by `toggle-fullscreen`, without being resized, when it is the focused window of the shown workspace. Later resizes do not.
264+
263265 ### Default binds
264266
265267 | Keys | Action |
@@ -307,8 +309,6 @@ The default layout. Columns sit side by side in bands of up to `max-columns`, st
307309 - A new window joins the focused band when it has room, else it gets a new band right below.
308310 - `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.
309311 - `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.
312312 - A band left empty goes away. A smaller `max-columns` moves the extra columns of a band to a new band below it.
313313
314314 ### Column widths
@@ -260,6 +260,8 @@ bind.XF86MonBrightnessDown = spawn brightnessctl set 5%-
260 | `scale-up` / `scale-down` | Zoom the display; the scale is saved to the config |260 | `scale-up` / `scale-down` | Zoom the display; the scale is saved to the config |
261 | `quit` | Exit NeferWL |261 | `quit` | Exit NeferWL |
262 262
263+Note on `toggle-fullscreen`: a parentless window whose first committed size is exactly the logical size of the output it is on, without asking for fullscreen (a game in borderless-windowed mode), is put fullscreen as if by `toggle-fullscreen`, without being resized, when it is the focused window of the shown workspace. Later resizes do not.
264+
263 ### Default binds265 ### Default binds
264 266
265 | Keys | Action |267 | Keys | Action |
@@ -307,8 +309,6 @@ 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.309 - 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.310 - `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.311 - `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.
312 - 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.
313 313
314 ### 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-}
A internal/adapters/wayland/legacy_fullscreen_test.go +59 -0
@@ -0,0 +1,59 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/adapters/logging"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// outputSizedServer runs a server whose only output is w×h logical.
13+func outputSizedServer(t *testing.T, w, h int) (*Server, chan ports.ClientEvent, string) {
14+ t.Helper()
15+ dir := t.TempDir()
16+ events := make(chan ports.ClientEvent, 16)
17+ out := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: w, Height: h}, Width: w, Height: h, Scale: 1}}
18+ s, err := New(Options{RuntimeDir: dir, Outputs: out}, Channels{Events: events, Commands: make(chan ports.ClientCommand, 16)}, logging.For(context.Background(), "wayland"))
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ ctx, cancel := context.WithCancel(context.Background())
23+ done := make(chan error, 1)
24+ go func() { done <- s.Run(ctx) }()
25+ t.Cleanup(func() {
26+ cancel()
27+ <-done
28+ })
29+ return s, events, dir
30+}
31+
32+// A tiled toplevel whose first committed size is its output's size reports
33+// it once (core then fullscreens it); a smaller one reports nothing.
34+func TestTiledFirstSizeAtOutputSizeEmitsResized(t *testing.T) {
35+ s, events, dir := outputSizedServer(t, 1, 1)
36+ c := protocolClient(t, s, dir)
37+ w := toplevelMapper(t, c, events)()
38+ if w.Floating {
39+ t.Fatal("expected a tiled window")
40+ }
41+ ev := waitEvent(t, events, 2*time.Second)
42+ if r, ok := ev.(ports.WindowResized); !ok || r.ID != w.ID || r.Width != 1 || r.Height != 1 {
43+ t.Fatalf("got %#v, want WindowResized 1x1 for %d", ev, w.ID)
44+ }
45+}
46+
47+func TestTiledSmallerThanOutputEmitsNoResized(t *testing.T) {
48+ s, events, dir := outputSizedServer(t, 2, 2)
49+ c := protocolClient(t, s, dir)
50+ toplevelMapper(t, c, events)()
51+ if err := c.Roundtrip(); err != nil {
52+ t.Fatal(err)
53+ }
54+ select {
55+ case ev := <-events:
56+ t.Fatalf("unexpected event %#v", ev)
57+ case <-time.After(200 * time.Millisecond):
58+ }
59+}
@@ -0,0 +1,59 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/adapters/logging"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// outputSizedServer runs a server whose only output is w×h logical.
13+func outputSizedServer(t *testing.T, w, h int) (*Server, chan ports.ClientEvent, string) {
14+ t.Helper()
15+ dir := t.TempDir()
16+ events := make(chan ports.ClientEvent, 16)
17+ out := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: w, Height: h}, Width: w, Height: h, Scale: 1}}
18+ s, err := New(Options{RuntimeDir: dir, Outputs: out}, Channels{Events: events, Commands: make(chan ports.ClientCommand, 16)}, logging.For(context.Background(), "wayland"))
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ ctx, cancel := context.WithCancel(context.Background())
23+ done := make(chan error, 1)
24+ go func() { done <- s.Run(ctx) }()
25+ t.Cleanup(func() {
26+ cancel()
27+ <-done
28+ })
29+ return s, events, dir
30+}
31+
32+// A tiled toplevel whose first committed size is its output's size reports
33+// it once (core then fullscreens it); a smaller one reports nothing.
34+func TestTiledFirstSizeAtOutputSizeEmitsResized(t *testing.T) {
35+ s, events, dir := outputSizedServer(t, 1, 1)
36+ c := protocolClient(t, s, dir)
37+ w := toplevelMapper(t, c, events)()
38+ if w.Floating {
39+ t.Fatal("expected a tiled window")
40+ }
41+ ev := waitEvent(t, events, 2*time.Second)
42+ if r, ok := ev.(ports.WindowResized); !ok || r.ID != w.ID || r.Width != 1 || r.Height != 1 {
43+ t.Fatalf("got %#v, want WindowResized 1x1 for %d", ev, w.ID)
44+ }
45+}
46+
47+func TestTiledSmallerThanOutputEmitsNoResized(t *testing.T) {
48+ s, events, dir := outputSizedServer(t, 2, 2)
49+ c := protocolClient(t, s, dir)
50+ toplevelMapper(t, c, events)()
51+ if err := c.Roundtrip(); err != nil {
52+ t.Fatal(err)
53+ }
54+ select {
55+ case ev := <-events:
56+ t.Fatalf("unexpected event %#v", ev)
57+ case <-time.After(200 * time.Millisecond):
58+ }
59+}
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/adapters/wayland/xdg.go +28 -2
@@ -145,7 +145,8 @@ type window struct {
145145 minW, minH int32
146146 maxW, maxH int32
147147 floating bool
148- floatW, floatH int // size last sent to core
148+ floatW, floatH int // size last sent to core
149+ sized bool // the first committed size after a map was sent
149150 // last is the most recent core configure, resent when decoration mode changes.
150151 last ports.ConfigureWindow
151152 hasLast bool
@@ -238,6 +239,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
238239 x.serials = append(x.serials, x.server.serial)
239240 } else if buffer && !w.mapped && x.acked {
240241 w.mapped = true
242+ w.sized = false
241243 w.gen++
242244 // Placement claims are for a new toplevel, not later remaps.
243245 // Ordinary commits, popups and remaps need no /proc lookup.
@@ -412,7 +414,21 @@ func (top) SetMinimized(*xdgshell.Toplevel) {}
412414 // afterCommit runs once the commit applied content and geometry: a
413415 // floating window keeps the size it draws.
414416 func (w *window) afterCommit() {
415- if !w.mapped || !w.floating {
417+ if !w.mapped {
418+ return
419+ }
420+ if !w.floating {
421+ // Core reads a tile's first size once, to spot a window that maps
422+ // at its output's size without asking for fullscreen. This
423+ // prefilter accepts any output size; core compares with the output
424+ // the window is on.
425+ if fw, fh := w.size(); !w.sized && fw > 0 && fh > 0 {
426+ w.sized = true
427+ if !w.xdg.server.outputSized(fw, fh) {
428+ return
429+ }
430+ w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh})
431+ }
416432 return
417433 }
418434 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {
@@ -453,3 +469,13 @@ func (x *xdgSurface) surfaceResource() *wayland.Surface {
453469 }
454470 return nil
455471 }
472+
473+// outputSized reports whether an output has the logical size w×h.
474+func (s *Server) outputSized(w, h int) bool {
475+ for _, o := range s.outputs {
476+ if o.place.Width == w && o.place.Height == h {
477+ return true
478+ }
479+ }
480+ return false
481+}
@@ -145,7 +145,8 @@ type window struct {
145 minW, minH int32145 minW, minH int32
146 maxW, maxH int32146 maxW, maxH int32
147 floating bool147 floating bool
148- floatW, floatH int // size last sent to core148+ floatW, floatH int // size last sent to core
149+ sized bool // the first committed size after a map was sent
149 // last is the most recent core configure, resent when decoration mode changes.150 // last is the most recent core configure, resent when decoration mode changes.
150 last ports.ConfigureWindow151 last ports.ConfigureWindow
151 hasLast bool152 hasLast bool
@@ -238,6 +239,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
238 x.serials = append(x.serials, x.server.serial)239 x.serials = append(x.serials, x.server.serial)
239 } else if buffer && !w.mapped && x.acked {240 } else if buffer && !w.mapped && x.acked {
240 w.mapped = true241 w.mapped = true
242+ w.sized = false
241 w.gen++243 w.gen++
242 // Placement claims are for a new toplevel, not later remaps.244 // Placement claims are for a new toplevel, not later remaps.
243 // Ordinary commits, popups and remaps need no /proc lookup.245 // Ordinary commits, popups and remaps need no /proc lookup.
@@ -412,7 +414,21 @@ func (top) SetMinimized(*xdgshell.Toplevel) {}
412 // afterCommit runs once the commit applied content and geometry: a414 // afterCommit runs once the commit applied content and geometry: a
413 // floating window keeps the size it draws.415 // floating window keeps the size it draws.
414 func (w *window) afterCommit() {416 func (w *window) afterCommit() {
415- if !w.mapped || !w.floating {417+ if !w.mapped {
418+ return
419+ }
420+ if !w.floating {
421+ // Core reads a tile's first size once, to spot a window that maps
422+ // at its output's size without asking for fullscreen. This
423+ // prefilter accepts any output size; core compares with the output
424+ // the window is on.
425+ if fw, fh := w.size(); !w.sized && fw > 0 && fh > 0 {
426+ w.sized = true
427+ if !w.xdg.server.outputSized(fw, fh) {
428+ return
429+ }
430+ w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh})
431+ }
416 return432 return
417 }433 }
418 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {434 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {
@@ -453,3 +469,13 @@ func (x *xdgSurface) surfaceResource() *wayland.Surface {
453 }469 }
454 return nil470 return nil
455 }471 }
472+
473+// outputSized reports whether an output has the logical size w×h.
474+func (s *Server) outputSized(w, h int) bool {
475+ for _, o := range s.outputs {
476+ if o.place.Width == w && o.place.Height == h {
477+ return true
478+ }
479+ }
480+ return false
481+}
M internal/core/action.go +1 -47
@@ -388,39 +388,7 @@ func (c *Core) dispatchAction(a Action) Effect {
388388 if a == ActionMoveColumnLeft {
389389 dir, d = -1, dirLeft
390390 }
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) {
391+ if i := c.neighbor(d); w.leavesBand(w.Focus+dir) && i >= 0 {
424392 col, ok := w.takeColumn()
425393 if !ok {
426394 return Effect{}
@@ -640,17 +608,3 @@ func (w *Workspace) Apply(a Action) Effect {
640608 }
641609 return Effect{}
642610 }
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,39 +388,7 @@ 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- // A fullscreen window (tiled or floating) leaves for the neighbor391+ if i := c.neighbor(d); w.leavesBand(w.Focus+dir) && i >= 0 {
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) {
424 col, ok := w.takeColumn()392 col, ok := w.takeColumn()
425 if !ok {393 if !ok {
426 return Effect{}394 return Effect{}
@@ -640,17 +608,3 @@ func (w *Workspace) Apply(a Action) Effect {
640 }608 }
641 return Effect{}609 return Effect{}
642 }610 }
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 +14 -80
@@ -371,9 +371,6 @@ 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- }
377374 bands[b] = slices.Delete(bands[b], lo-first, hi-first+1)
378375 w.setBands(w.sendBand(bands, b, dir, at, cols), id)
379376 return true
@@ -422,53 +419,6 @@ func (w *Workspace) isolateBand() {
422419 w.setBands(bands, id)
423420 }
424421
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-
472422 // pushNeighbor makes room in the focused column's full cascade band for a
473423 // wider column: the column at the far end of the band leaves it. The
474424 // rightmost goes to the band below; from the last column, the leftmost
@@ -507,10 +457,13 @@ func (w *Workspace) restoreBand() bool {
507457 bands := w.bandList()
508458 b := w.band(w.Focus)
509459 room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
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
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
512462 // column outward (rev: ids end nearest).
513- take := func(nbs []int, ids [][]WindowID, rev bool) []Column {
463+ take := func(nb int, ids [][]WindowID, rev bool) []Column {
464+ if nb < 0 || nb >= len(bands) {
465+ return nil
466+ }
514467 var got []Column
515468 for k := range ids {
516469 if room == 0 {
@@ -528,40 +481,21 @@ func (w *Workspace) restoreBand() bool {
528481 if head < 0 {
529482 continue
530483 }
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
484+ i := slices.IndexFunc(bands[nb], func(c Column) bool { return c.Windows[0] == e[head] })
485+ if i < 0 {
486+ continue
544487 }
488+ got = append(got, bands[nb][i])
489+ bands[nb] = slices.Delete(bands[nb], i, i+1)
490+ room--
545491 }
546492 if rev {
547493 slices.Reverse(got)
548494 }
549495 return got
550496 }
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)
497+ above := take(b-1, up, true)
498+ below := take(b+1, down, false)
565499 bands[b] = slices.Concat(above, bands[b], below)
566500 w.setBands(bands, id)
567501 return true
@@ -371,9 +371,6 @@ 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- }
377 bands[b] = slices.Delete(bands[b], lo-first, hi-first+1)374 bands[b] = slices.Delete(bands[b], lo-first, hi-first+1)
378 w.setBands(w.sendBand(bands, b, dir, at, cols), id)375 w.setBands(w.sendBand(bands, b, dir, at, cols), id)
379 return true376 return true
@@ -422,53 +419,6 @@ func (w *Workspace) isolateBand() {
422 w.setBands(bands, id)419 w.setBands(bands, id)
423 }420 }
424 421
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-
472 // pushNeighbor makes room in the focused column's full cascade band for a422 // pushNeighbor makes room in the focused column's full cascade band for a
473 // wider column: the column at the far end of the band leaves it. The423 // wider column: the column at the far end of the band leaves it. The
474 // rightmost goes to the band below; from the last column, the leftmost424 // rightmost goes to the band below; from the last column, the leftmost
@@ -507,10 +457,13 @@ func (w *Workspace) restoreBand() bool {
507 bands := w.bandList()457 bands := w.bandList()
508 b := w.band(w.Focus)458 b := w.band(w.Focus)
509 room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)459 room := max(max(w.MaxColumns, 1)-len(bands[b]), 0)
510- // take removes from the bands nbs the remembered columns found there,460+ // take removes from band nb the remembered columns found there, at
511- // at most room of them, in the order of ids walked from the focused461+ // most room of them, in the order of ids walked from the focused
512 // column outward (rev: ids end nearest).462 // column outward (rev: ids end nearest).
513- take := func(nbs []int, ids [][]WindowID, rev bool) []Column {463+ take := func(nb int, ids [][]WindowID, rev bool) []Column {
464+ if nb < 0 || nb >= len(bands) {
465+ return nil
466+ }
514 var got []Column467 var got []Column
515 for k := range ids {468 for k := range ids {
516 if room == 0 {469 if room == 0 {
@@ -528,40 +481,21 @@ func (w *Workspace) restoreBand() bool {
528 if head < 0 {481 if head < 0 {
529 continue482 continue
530 }483 }
531- for k, nb := range nbs {484+ i := slices.IndexFunc(bands[nb], func(c Column) bool { return c.Windows[0] == e[head] })
532- // Past the adjacent band, only a column fullscreenBand put in485+ if i < 0 {
533- // a band of its own is taken: one the user moved there stays.486+ continue
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
544 }487 }
488+ got = append(got, bands[nb][i])
489+ bands[nb] = slices.Delete(bands[nb], i, i+1)
490+ room--
545 }491 }
546 if rev {492 if rev {
547 slices.Reverse(got)493 slices.Reverse(got)
548 }494 }
549 return got495 return got
550 }496 }
551- // reach lists the bands dir away to look in: the adjacent one, and the497+ above := take(b-1, up, true)
552- // next, where a column that fullscreenBand split off may lie.498+ below := take(b+1, down, false)
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)
565 bands[b] = slices.Concat(above, bands[b], below)499 bands[b] = slices.Concat(above, bands[b], below)
566 w.setBands(bands, id)500 w.setBands(bands, id)
567 return true501 return true
D internal/core/cascade_fullscreen_band_test.go +0 -297
@@ -1,297 +0,0 @@
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-}
@@ -1,297 +0,0 @@
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 +2 -3
@@ -299,14 +299,13 @@ func TestCascadeMoveWindowStackedFirst(t *testing.T) {
299299 wantFocus(t, c, "M", 5)
300300 }
301301
302-// A fullscreen window keeps its own band (it left {4 5} when it went
303-// fullscreen).
302+// A fullscreen window keeps its band.
304303 func TestCascadeMoveWindowFullscreenStays(t *testing.T) {
305304 c, w := cascadeMovesCore(t, 5, 3, false)
306305 w.FocusID(4)
307306 w.ToggleFullscreen()
308307 c.applyAction(ActionMoveWindowUp)
309- wantColumns(t, w, 1, 2, 3, 5, 4)
308+ wantColumns(t, w, 1, 2, 3, 4, 5)
310309 }
311310
312311 // Scroll overflow has no bands: up/down stop at the column edge.
@@ -299,14 +299,13 @@ 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 own band (it left {4 5} when it went302+// A fullscreen window keeps its band.
303-// fullscreen).
304 func TestCascadeMoveWindowFullscreenStays(t *testing.T) {303 func TestCascadeMoveWindowFullscreenStays(t *testing.T) {
305 c, w := cascadeMovesCore(t, 5, 3, false)304 c, w := cascadeMovesCore(t, 5, 3, false)
306 w.FocusID(4)305 w.FocusID(4)
307 w.ToggleFullscreen()306 w.ToggleFullscreen()
308 c.applyAction(ActionMoveWindowUp)307 c.applyAction(ActionMoveWindowUp)
309- wantColumns(t, w, 1, 2, 3, 5, 4)308+ wantColumns(t, w, 1, 2, 3, 4, 5)
310 }309 }
311 310
312 // Scroll overflow has no bands: up/down stop at the column edge.311 // 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 != 1200 {
198+ if w.View != 600 {
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 != 1200 {198+ if w.View != 600 {
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 +0 -9
@@ -40,11 +40,6 @@ 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
4843 }
4944 type Placement struct {
5045 ID WindowID
@@ -1340,7 +1335,6 @@ func (w *Workspace) ToggleFullscreen() {
13401335 w.endFullscreen()
13411336 return
13421337 }
1343- w.fullscreenBand(id)
13441338 w.fullscreen = id
13451339 w.scroll()
13461340 }
@@ -1450,9 +1444,6 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
14501444 // Another window covers: the override was for the old cover.
14511445 w.dropStashOver()
14521446 }
1453- if !w.isFloat(id) && w.fullscreen != id {
1454- w.fullscreenBand(id)
1455- }
14561447 w.fullscreen = id
14571448 if !w.isFloat(id) {
14581449 w.scroll()
@@ -40,11 +40,6 @@ 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
48 }43 }
49 type Placement struct {44 type Placement struct {
50 ID WindowID45 ID WindowID
@@ -1340,7 +1335,6 @@ func (w *Workspace) ToggleFullscreen() {
1340 w.endFullscreen()1335 w.endFullscreen()
1341 return1336 return
1342 }1337 }
1343- w.fullscreenBand(id)
1344 w.fullscreen = id1338 w.fullscreen = id
1345 w.scroll()1339 w.scroll()
1346 }1340 }
@@ -1450,9 +1444,6 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
1450 // Another window covers: the override was for the old cover.1444 // Another window covers: the override was for the old cover.
1451 w.dropStashOver()1445 w.dropStashOver()
1452 }1446 }
1453- if !w.isFloat(id) && w.fullscreen != id {
1454- w.fullscreenBand(id)
1455- }
1456 w.fullscreen = id1447 w.fullscreen = id
1457 if !w.isFloat(id) {1448 if !w.isFloat(id) {
1458 w.scroll()1449 w.scroll()
A internal/core/legacy_fullscreen_internal_test.go +246 -0
@@ -0,0 +1,246 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// A window whose first committed size is its output's size goes fullscreen,
11+// focused, exactly as toggle-fullscreen does, and toggling leaves it.
12+func TestLegacyFullscreenAtOutputSize(t *testing.T) {
13+ c, _, _ := fadeCore(t)
14+ sc := c.cur()
15+ c.mapWindow(ports.WindowMapped{ID: 1})
16+ c.mapWindow(ports.WindowMapped{ID: 2})
17+ o := sc.rect()
18+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
19+ w := sc.mon.Current()
20+ if w.fullscreen != 2 {
21+ t.Fatalf("fullscreen = %d, want 2", w.fullscreen)
22+ }
23+ if id, _ := w.Focused(); id != 2 {
24+ t.Fatalf("focused %d, want 2", id)
25+ }
26+ sc.mon.ToggleFullscreen()
27+ if w.fullscreen != 0 {
28+ t.Fatalf("toggle left fullscreen = %d", w.fullscreen)
29+ }
30+}
31+
32+func TestLegacyFullscreenFloatingFixedSize(t *testing.T) {
33+ c, _, _ := fadeCore(t)
34+ sc := c.cur()
35+ c.mapWindow(ports.WindowMapped{ID: 1, Floating: true})
36+ o := sc.rect()
37+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
38+ if sc.mon.Current().fullscreen != 1 {
39+ t.Fatal("a screen-sized float did not go fullscreen")
40+ }
41+}
42+
43+func TestLegacyFullscreenNoFalsePositive(t *testing.T) {
44+ t.Run("smaller", func(t *testing.T) {
45+ c, _, _ := fadeCore(t)
46+ sc := c.cur()
47+ c.mapWindow(ports.WindowMapped{ID: 1})
48+ o := sc.rect()
49+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W - 1, Height: o.H})
50+ if sc.mon.Current().fullscreen != 0 {
51+ t.Fatal("smaller window went fullscreen")
52+ }
53+ })
54+ t.Run("parent", func(t *testing.T) {
55+ c, _, _ := fadeCore(t)
56+ sc := c.cur()
57+ c.mapWindow(ports.WindowMapped{ID: 1})
58+ c.mapWindow(ports.WindowMapped{ID: 2, Floating: true, Parent: 1})
59+ o := sc.rect()
60+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
61+ if sc.mon.Current().fullscreen != 0 {
62+ t.Fatal("dialog went fullscreen")
63+ }
64+ })
65+ t.Run("later resize", func(t *testing.T) {
66+ c, _, _ := fadeCore(t)
67+ sc := c.cur()
68+ c.mapWindow(ports.WindowMapped{ID: 1})
69+ o := sc.rect()
70+ c.resizeFloating(ports.WindowResized{ID: 1, Width: 50, Height: 50})
71+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
72+ if sc.mon.Current().fullscreen != 0 {
73+ t.Fatal("a later resize went fullscreen")
74+ }
75+ })
76+}
77+
78+// With animations on, the entrance never restarts for the window that went
79+// fullscreen: no motion, and the scene shows it at the full output.
80+func TestLegacyFullscreenDropsEntrance(t *testing.T) {
81+ c, _, _ := fadeCore(t)
82+ c.cfg.Animations.On = true
83+ sc := c.cur()
84+ c.mapWindow(ports.WindowMapped{ID: 1})
85+ c.stopAnimations()
86+ c.mapWindow(ports.WindowMapped{ID: 2})
87+ o := sc.rect()
88+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
89+ s := indicatorScene(t, c)
90+ if _, ok := sc.rects[2]; ok {
91+ t.Fatalf("entrance motion left for the fullscreen window: %+v", sc.rects)
92+ }
93+ if w := sceneWindow(t, s, 2); w.Rect != (Rect{W: o.W, H: o.H}) || w.Fade != 0 {
94+ t.Fatalf("scene window %+v, want the full output %+v", w, o)
95+ }
96+}
97+
98+// Focus rules (ADR 011): never takes the focus from a fullscreen cover, nor
99+// acts on a workspace that is not shown.
100+func TestLegacyFullscreenRespectsFocus(t *testing.T) {
101+ t.Run("under a fullscreen window", func(t *testing.T) {
102+ c, _, _ := fadeCore(t)
103+ sc := c.cur()
104+ c.mapWindow(ports.WindowMapped{ID: 1})
105+ sc.mon.SetFullscreen(1, true)
106+ c.mapWindow(ports.WindowMapped{ID: 2})
107+ o := sc.rect()
108+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
109+ w := sc.mon.Current()
110+ if w.fullscreen != 1 {
111+ t.Fatalf("fullscreen = %d, want the cover 1", w.fullscreen)
112+ }
113+ if id, _ := w.Focused(); id != 1 {
114+ t.Fatalf("focus moved to %d", id)
115+ }
116+ })
117+ t.Run("other workspace", func(t *testing.T) {
118+ c, _, _ := fadeCore(t)
119+ sc := c.cur()
120+ c.mapWindow(ports.WindowMapped{ID: 1})
121+ ws := sc.mon.Current()
122+ sc.mon.Focus(1)
123+ if sc.mon.Current() == ws {
124+ t.Fatal("setup: no second workspace")
125+ }
126+ o := sc.rect()
127+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
128+ if ws.fullscreen != 0 {
129+ t.Fatal("a window on a hidden workspace went fullscreen")
130+ }
131+ })
132+}
133+
134+func TestLegacyFullscreenRemapAndOutputs(t *testing.T) {
135+ t.Run("remap", func(t *testing.T) {
136+ c, _, _ := fadeCore(t)
137+ sc := c.cur()
138+ c.mapWindow(ports.WindowMapped{ID: 1, Remap: true})
139+ o := sc.rect()
140+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
141+ if sc.mon.Current().fullscreen != 0 {
142+ t.Fatal("a remap went fullscreen")
143+ }
144+ })
145+ second := func(t *testing.T) (*Core, *screen) {
146+ c, _, _ := fadeCore(t)
147+ c.addScreen(ports.OutputInfo{Name: "B", Width: 500, Height: 300})
148+ c.focusScreen = c.screenIndex("B")
149+ return c, c.cur()
150+ }
151+ t.Run("sized to its own output", func(t *testing.T) {
152+ c, b := second(t)
153+ c.mapWindow(ports.WindowMapped{ID: 1})
154+ if s, _ := c.screenOf(1); s != b {
155+ t.Fatal("window not on B")
156+ }
157+ c.resizeFloating(ports.WindowResized{ID: 1, Width: 500, Height: 300})
158+ if b.mon.Current().fullscreen != 1 {
159+ t.Fatal("window sized to its output B did not go fullscreen")
160+ }
161+ })
162+ t.Run("sized to another output", func(t *testing.T) {
163+ c, b := second(t)
164+ c.mapWindow(ports.WindowMapped{ID: 1})
165+ c.resizeFloating(ports.WindowResized{ID: 1, Width: 300, Height: 200})
166+ if b.mon.Current().fullscreen != 0 {
167+ t.Fatal("window sized to output A went fullscreen on B")
168+ }
169+ })
170+}
171+
172+// Only the focused window of the shown workspace is a candidate.
173+func TestLegacyFullscreenNeedsFocusAtFirstSize(t *testing.T) {
174+ t.Run("focus moved before the commit", func(t *testing.T) {
175+ c, _, _ := fadeCore(t)
176+ sc := c.cur()
177+ c.mapWindow(ports.WindowMapped{ID: 1})
178+ c.mapWindow(ports.WindowMapped{ID: 2})
179+ w := sc.mon.Current()
180+ w.FocusID(1)
181+ o := sc.rect()
182+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
183+ if w.fullscreen != 0 {
184+ t.Fatalf("unfocused window went fullscreen: %d", w.fullscreen)
185+ }
186+ if id, _ := w.Focused(); id != 1 {
187+ t.Fatalf("focus moved to %d", id)
188+ }
189+ })
190+ t.Run("quiet column", func(t *testing.T) {
191+ c, _, _ := fadeCore(t)
192+ sc := c.cur()
193+ c.mapWindow(ports.WindowMapped{ID: 1})
194+ c.windows.mapped(ports.WindowMapped{ID: 2}, c.now())
195+ w := sc.mon.Current()
196+ w.addColumnQuiet(Column{Windows: []WindowID{2}})
197+ o := sc.rect()
198+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
199+ if w.fullscreen != 0 {
200+ t.Fatalf("quiet window went fullscreen: %d", w.fullscreen)
201+ }
202+ if id, _ := w.Focused(); id != 1 {
203+ t.Fatalf("focus moved to %d", id)
204+ }
205+ })
206+ t.Run("quiet float", func(t *testing.T) {
207+ c, _, _ := fadeCore(t)
208+ sc := c.cur()
209+ c.mapWindow(ports.WindowMapped{ID: 1})
210+ c.windows.mapped(ports.WindowMapped{ID: 2, Floating: true}, c.now())
211+ w := sc.mon.Current()
212+ w.addFloat(Float{ID: 2}, true)
213+ o := sc.rect()
214+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
215+ if w.fullscreen != 0 {
216+ t.Fatalf("quiet float went fullscreen: %d", w.fullscreen)
217+ }
218+ })
219+}
220+
221+// The adapter's sequence for a screen-sized tile mapping under a cover
222+// (bound fullscreen, animations on): the cover and its focus stay.
223+func TestLegacyFullscreenUnderBoundCover(t *testing.T) {
224+ for _, anim := range []bool{false, true} {
225+ c, _, _ := fadeCore(t)
226+ c.cfg.Animations.On = anim
227+ sc := c.cur()
228+ c.mapWindow(ports.WindowMapped{ID: 1})
229+ c.mapWindow(ports.WindowMapped{ID: 2})
230+ sc.mon.ToggleFullscreen()
231+ w := sc.mon.Current()
232+ if w.fullscreen != 2 {
233+ t.Fatalf("setup: fullscreen = %d", w.fullscreen)
234+ }
235+ c.clientEvent(context.Background(), ports.WindowMapped{ID: 3})
236+ o := sc.rect()
237+ c.clientEvent(context.Background(), ports.WindowResized{ID: 3, Width: o.W, Height: o.H})
238+ _ = c.publish(context.Background())
239+ if w.fullscreen != 2 {
240+ t.Fatalf("anim=%v: fullscreen = %d, want the cover 2", anim, w.fullscreen)
241+ }
242+ if id, _ := w.Focused(); id != 2 {
243+ t.Fatalf("anim=%v: focus moved to %d", anim, id)
244+ }
245+ }
246+}
@@ -0,0 +1,246 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// A window whose first committed size is its output's size goes fullscreen,
11+// focused, exactly as toggle-fullscreen does, and toggling leaves it.
12+func TestLegacyFullscreenAtOutputSize(t *testing.T) {
13+ c, _, _ := fadeCore(t)
14+ sc := c.cur()
15+ c.mapWindow(ports.WindowMapped{ID: 1})
16+ c.mapWindow(ports.WindowMapped{ID: 2})
17+ o := sc.rect()
18+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
19+ w := sc.mon.Current()
20+ if w.fullscreen != 2 {
21+ t.Fatalf("fullscreen = %d, want 2", w.fullscreen)
22+ }
23+ if id, _ := w.Focused(); id != 2 {
24+ t.Fatalf("focused %d, want 2", id)
25+ }
26+ sc.mon.ToggleFullscreen()
27+ if w.fullscreen != 0 {
28+ t.Fatalf("toggle left fullscreen = %d", w.fullscreen)
29+ }
30+}
31+
32+func TestLegacyFullscreenFloatingFixedSize(t *testing.T) {
33+ c, _, _ := fadeCore(t)
34+ sc := c.cur()
35+ c.mapWindow(ports.WindowMapped{ID: 1, Floating: true})
36+ o := sc.rect()
37+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
38+ if sc.mon.Current().fullscreen != 1 {
39+ t.Fatal("a screen-sized float did not go fullscreen")
40+ }
41+}
42+
43+func TestLegacyFullscreenNoFalsePositive(t *testing.T) {
44+ t.Run("smaller", func(t *testing.T) {
45+ c, _, _ := fadeCore(t)
46+ sc := c.cur()
47+ c.mapWindow(ports.WindowMapped{ID: 1})
48+ o := sc.rect()
49+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W - 1, Height: o.H})
50+ if sc.mon.Current().fullscreen != 0 {
51+ t.Fatal("smaller window went fullscreen")
52+ }
53+ })
54+ t.Run("parent", func(t *testing.T) {
55+ c, _, _ := fadeCore(t)
56+ sc := c.cur()
57+ c.mapWindow(ports.WindowMapped{ID: 1})
58+ c.mapWindow(ports.WindowMapped{ID: 2, Floating: true, Parent: 1})
59+ o := sc.rect()
60+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
61+ if sc.mon.Current().fullscreen != 0 {
62+ t.Fatal("dialog went fullscreen")
63+ }
64+ })
65+ t.Run("later resize", func(t *testing.T) {
66+ c, _, _ := fadeCore(t)
67+ sc := c.cur()
68+ c.mapWindow(ports.WindowMapped{ID: 1})
69+ o := sc.rect()
70+ c.resizeFloating(ports.WindowResized{ID: 1, Width: 50, Height: 50})
71+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
72+ if sc.mon.Current().fullscreen != 0 {
73+ t.Fatal("a later resize went fullscreen")
74+ }
75+ })
76+}
77+
78+// With animations on, the entrance never restarts for the window that went
79+// fullscreen: no motion, and the scene shows it at the full output.
80+func TestLegacyFullscreenDropsEntrance(t *testing.T) {
81+ c, _, _ := fadeCore(t)
82+ c.cfg.Animations.On = true
83+ sc := c.cur()
84+ c.mapWindow(ports.WindowMapped{ID: 1})
85+ c.stopAnimations()
86+ c.mapWindow(ports.WindowMapped{ID: 2})
87+ o := sc.rect()
88+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
89+ s := indicatorScene(t, c)
90+ if _, ok := sc.rects[2]; ok {
91+ t.Fatalf("entrance motion left for the fullscreen window: %+v", sc.rects)
92+ }
93+ if w := sceneWindow(t, s, 2); w.Rect != (Rect{W: o.W, H: o.H}) || w.Fade != 0 {
94+ t.Fatalf("scene window %+v, want the full output %+v", w, o)
95+ }
96+}
97+
98+// Focus rules (ADR 011): never takes the focus from a fullscreen cover, nor
99+// acts on a workspace that is not shown.
100+func TestLegacyFullscreenRespectsFocus(t *testing.T) {
101+ t.Run("under a fullscreen window", func(t *testing.T) {
102+ c, _, _ := fadeCore(t)
103+ sc := c.cur()
104+ c.mapWindow(ports.WindowMapped{ID: 1})
105+ sc.mon.SetFullscreen(1, true)
106+ c.mapWindow(ports.WindowMapped{ID: 2})
107+ o := sc.rect()
108+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
109+ w := sc.mon.Current()
110+ if w.fullscreen != 1 {
111+ t.Fatalf("fullscreen = %d, want the cover 1", w.fullscreen)
112+ }
113+ if id, _ := w.Focused(); id != 1 {
114+ t.Fatalf("focus moved to %d", id)
115+ }
116+ })
117+ t.Run("other workspace", func(t *testing.T) {
118+ c, _, _ := fadeCore(t)
119+ sc := c.cur()
120+ c.mapWindow(ports.WindowMapped{ID: 1})
121+ ws := sc.mon.Current()
122+ sc.mon.Focus(1)
123+ if sc.mon.Current() == ws {
124+ t.Fatal("setup: no second workspace")
125+ }
126+ o := sc.rect()
127+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
128+ if ws.fullscreen != 0 {
129+ t.Fatal("a window on a hidden workspace went fullscreen")
130+ }
131+ })
132+}
133+
134+func TestLegacyFullscreenRemapAndOutputs(t *testing.T) {
135+ t.Run("remap", func(t *testing.T) {
136+ c, _, _ := fadeCore(t)
137+ sc := c.cur()
138+ c.mapWindow(ports.WindowMapped{ID: 1, Remap: true})
139+ o := sc.rect()
140+ c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H})
141+ if sc.mon.Current().fullscreen != 0 {
142+ t.Fatal("a remap went fullscreen")
143+ }
144+ })
145+ second := func(t *testing.T) (*Core, *screen) {
146+ c, _, _ := fadeCore(t)
147+ c.addScreen(ports.OutputInfo{Name: "B", Width: 500, Height: 300})
148+ c.focusScreen = c.screenIndex("B")
149+ return c, c.cur()
150+ }
151+ t.Run("sized to its own output", func(t *testing.T) {
152+ c, b := second(t)
153+ c.mapWindow(ports.WindowMapped{ID: 1})
154+ if s, _ := c.screenOf(1); s != b {
155+ t.Fatal("window not on B")
156+ }
157+ c.resizeFloating(ports.WindowResized{ID: 1, Width: 500, Height: 300})
158+ if b.mon.Current().fullscreen != 1 {
159+ t.Fatal("window sized to its output B did not go fullscreen")
160+ }
161+ })
162+ t.Run("sized to another output", func(t *testing.T) {
163+ c, b := second(t)
164+ c.mapWindow(ports.WindowMapped{ID: 1})
165+ c.resizeFloating(ports.WindowResized{ID: 1, Width: 300, Height: 200})
166+ if b.mon.Current().fullscreen != 0 {
167+ t.Fatal("window sized to output A went fullscreen on B")
168+ }
169+ })
170+}
171+
172+// Only the focused window of the shown workspace is a candidate.
173+func TestLegacyFullscreenNeedsFocusAtFirstSize(t *testing.T) {
174+ t.Run("focus moved before the commit", func(t *testing.T) {
175+ c, _, _ := fadeCore(t)
176+ sc := c.cur()
177+ c.mapWindow(ports.WindowMapped{ID: 1})
178+ c.mapWindow(ports.WindowMapped{ID: 2})
179+ w := sc.mon.Current()
180+ w.FocusID(1)
181+ o := sc.rect()
182+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
183+ if w.fullscreen != 0 {
184+ t.Fatalf("unfocused window went fullscreen: %d", w.fullscreen)
185+ }
186+ if id, _ := w.Focused(); id != 1 {
187+ t.Fatalf("focus moved to %d", id)
188+ }
189+ })
190+ t.Run("quiet column", func(t *testing.T) {
191+ c, _, _ := fadeCore(t)
192+ sc := c.cur()
193+ c.mapWindow(ports.WindowMapped{ID: 1})
194+ c.windows.mapped(ports.WindowMapped{ID: 2}, c.now())
195+ w := sc.mon.Current()
196+ w.addColumnQuiet(Column{Windows: []WindowID{2}})
197+ o := sc.rect()
198+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
199+ if w.fullscreen != 0 {
200+ t.Fatalf("quiet window went fullscreen: %d", w.fullscreen)
201+ }
202+ if id, _ := w.Focused(); id != 1 {
203+ t.Fatalf("focus moved to %d", id)
204+ }
205+ })
206+ t.Run("quiet float", func(t *testing.T) {
207+ c, _, _ := fadeCore(t)
208+ sc := c.cur()
209+ c.mapWindow(ports.WindowMapped{ID: 1})
210+ c.windows.mapped(ports.WindowMapped{ID: 2, Floating: true}, c.now())
211+ w := sc.mon.Current()
212+ w.addFloat(Float{ID: 2}, true)
213+ o := sc.rect()
214+ c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H})
215+ if w.fullscreen != 0 {
216+ t.Fatalf("quiet float went fullscreen: %d", w.fullscreen)
217+ }
218+ })
219+}
220+
221+// The adapter's sequence for a screen-sized tile mapping under a cover
222+// (bound fullscreen, animations on): the cover and its focus stay.
223+func TestLegacyFullscreenUnderBoundCover(t *testing.T) {
224+ for _, anim := range []bool{false, true} {
225+ c, _, _ := fadeCore(t)
226+ c.cfg.Animations.On = anim
227+ sc := c.cur()
228+ c.mapWindow(ports.WindowMapped{ID: 1})
229+ c.mapWindow(ports.WindowMapped{ID: 2})
230+ sc.mon.ToggleFullscreen()
231+ w := sc.mon.Current()
232+ if w.fullscreen != 2 {
233+ t.Fatalf("setup: fullscreen = %d", w.fullscreen)
234+ }
235+ c.clientEvent(context.Background(), ports.WindowMapped{ID: 3})
236+ o := sc.rect()
237+ c.clientEvent(context.Background(), ports.WindowResized{ID: 3, Width: o.W, Height: o.H})
238+ _ = c.publish(context.Background())
239+ if w.fullscreen != 2 {
240+ t.Fatalf("anim=%v: fullscreen = %d, want the cover 2", anim, w.fullscreen)
241+ }
242+ if id, _ := w.Focused(); id != 2 {
243+ t.Fatalf("anim=%v: focus moved to %d", anim, id)
244+ }
245+ }
246+}
M internal/core/overview_cascade_test.go +2 -3
@@ -728,8 +728,7 @@ func TestOverviewCascadeCoveringFloat(t *testing.T) {
728728 }
729729 }
730730
731-// T10b: a pinned fullscreen column keeps its Fullscreen tile, and the
732-// columns of the other bands stay shown.
731+// T10b: a pinned fullscreen column keeps its Fullscreen tile in the band.
733732 func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
734733 m := cascadeOverview()
735734 w := m.Current()
@@ -745,7 +744,7 @@ func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
745744 t.Fatalf("fullscreen tile: %+v", fs)
746745 }
747746 for _, id := range []WindowID{7, 9} {
748- if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen {
747+ if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) {
749748 t.Fatalf("band tile %d: %+v", id, p)
750749 }
751750 }
@@ -728,8 +728,7 @@ func TestOverviewCascadeCoveringFloat(t *testing.T) {
728 }728 }
729 }729 }
730 730
731-// T10b: a pinned fullscreen column keeps its Fullscreen tile, and the731+// T10b: a pinned fullscreen column keeps its Fullscreen tile in the band.
732-// columns of the other bands stay shown.
733 func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {732 func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
734 m := cascadeOverview()733 m := cascadeOverview()
735 w := m.Current()734 w := m.Current()
@@ -745,7 +744,7 @@ func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
745 t.Fatalf("fullscreen tile: %+v", fs)744 t.Fatalf("fullscreen tile: %+v", fs)
746 }745 }
747 for _, id := range []WindowID{7, 9} {746 for _, id := range []WindowID{7, 9} {
748- if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen {747+ if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) {
749 t.Fatalf("band tile %d: %+v", id, p)748 t.Fatalf("band tile %d: %+v", id, p)
750 }749 }
751 }750 }
M internal/core/transition.go +35 -0
@@ -518,6 +518,9 @@ func (c *Core) resizeFloating(v ports.WindowResized) {
518518 old = settledRect(sc.mon, v.ID)
519519 }
520520 w.ResizeFloating(v.ID, v.Width, v.Height)
521+ if c.legacyFullscreen(sc, v) {
522+ return
523+ }
521524 if !running {
522525 return
523526 }
@@ -981,3 +984,35 @@ func movedFrom(b *viewShot, before []viewShot) *viewShot {
981984 }
982985 return nil
983986 }
987+
988+// legacyFullscreen puts a window in the fullscreen state when the first size
989+// it commits after mapping is exactly the logical size of the output it is
990+// on: a game in borderless-windowed mode (X11 through xwayland-satellite)
991+// never asks for fullscreen. It is the state toggle-fullscreen sets, and the
992+// window is not resized. It follows the focus rules (ADR 011): only the
993+// window that already has the focus on the shown workspace goes fullscreen,
994+// so a fullscreen cover or another workspace is never disturbed. The parent
995+// is the one read at map (a later set_parent is not excluded); remaps and
996+// later resizes are left alone. It reports whether the window went
997+// fullscreen.
998+func (c *Core) legacyFullscreen(sc *screen, v ports.WindowResized) bool {
999+ if !c.windows.firstSize(v.ID) {
1000+ return false
1001+ }
1002+ if rec := c.windows.lookup(v.ID); rec.client.Parent != 0 || rec.client.Remap {
1003+ return false
1004+ }
1005+ if r := sc.rect(); v.Width != r.W || v.Height != r.H || r.W <= 0 {
1006+ return false
1007+ }
1008+ cur := sc.mon.Current()
1009+ if id, ok := cur.Focused(); !ok || id != v.ID || !cur.has(v.ID) {
1010+ return false
1011+ }
1012+ sc.mon.SetFullscreen(v.ID, true)
1013+ if cur.fullscreen != v.ID {
1014+ return false
1015+ }
1016+ delete(sc.rects, v.ID)
1017+ return true
1018+}
@@ -518,6 +518,9 @@ func (c *Core) resizeFloating(v ports.WindowResized) {
518 old = settledRect(sc.mon, v.ID)518 old = settledRect(sc.mon, v.ID)
519 }519 }
520 w.ResizeFloating(v.ID, v.Width, v.Height)520 w.ResizeFloating(v.ID, v.Width, v.Height)
521+ if c.legacyFullscreen(sc, v) {
522+ return
523+ }
521 if !running {524 if !running {
522 return525 return
523 }526 }
@@ -981,3 +984,35 @@ func movedFrom(b *viewShot, before []viewShot) *viewShot {
981 }984 }
982 return nil985 return nil
983 }986 }
987+
988+// legacyFullscreen puts a window in the fullscreen state when the first size
989+// it commits after mapping is exactly the logical size of the output it is
990+// on: a game in borderless-windowed mode (X11 through xwayland-satellite)
991+// never asks for fullscreen. It is the state toggle-fullscreen sets, and the
992+// window is not resized. It follows the focus rules (ADR 011): only the
993+// window that already has the focus on the shown workspace goes fullscreen,
994+// so a fullscreen cover or another workspace is never disturbed. The parent
995+// is the one read at map (a later set_parent is not excluded); remaps and
996+// later resizes are left alone. It reports whether the window went
997+// fullscreen.
998+func (c *Core) legacyFullscreen(sc *screen, v ports.WindowResized) bool {
999+ if !c.windows.firstSize(v.ID) {
1000+ return false
1001+ }
1002+ if rec := c.windows.lookup(v.ID); rec.client.Parent != 0 || rec.client.Remap {
1003+ return false
1004+ }
1005+ if r := sc.rect(); v.Width != r.W || v.Height != r.H || r.W <= 0 {
1006+ return false
1007+ }
1008+ cur := sc.mon.Current()
1009+ if id, ok := cur.Focused(); !ok || id != v.ID || !cur.has(v.ID) {
1010+ return false
1011+ }
1012+ sc.mon.SetFullscreen(v.ID, true)
1013+ if cur.fullscreen != v.ID {
1014+ return false
1015+ }
1016+ delete(sc.rects, v.ID)
1017+ return true
1018+}
M internal/core/windows.go +14 -1
@@ -12,6 +12,8 @@ type windowRecord struct {
1212 // client holds the app ID and PID of a mapped window, for State.
1313 client ports.WindowMapped
1414 mappedAt time.Time
15+ // sized is set once the window's first committed size was seen.
16+ sized bool
1517 // region is the input region; nil accepts input everywhere.
1618 region *ports.InputRegionChanged
1719 // inhibitShortcuts asks to keep compositor binds while focused;
@@ -50,7 +52,18 @@ func (r *windowRegistry) lookup(id WindowID) windowRecord {
5052
5153 func (r *windowRegistry) mapped(v ports.WindowMapped, now time.Time) {
5254 rec := r.at(v.ID)
53- rec.client, rec.mappedAt = v, now
55+ rec.client, rec.mappedAt, rec.sized = v, now, false
56+}
57+
58+// firstSize reports whether this is the first committed size of the window
59+// since it mapped, and records that it was seen.
60+func (r *windowRegistry) firstSize(id WindowID) bool {
61+ rec := r.records[id]
62+ if rec == nil || rec.sized || rec.client.ID == 0 {
63+ return false
64+ }
65+ rec.sized = true
66+ return true
5467 }
5568
5669 // setAppID updates the app ID of a mapped window only.
@@ -12,6 +12,8 @@ type windowRecord struct {
12 // client holds the app ID and PID of a mapped window, for State.12 // client holds the app ID and PID of a mapped window, for State.
13 client ports.WindowMapped13 client ports.WindowMapped
14 mappedAt time.Time14 mappedAt time.Time
15+ // sized is set once the window's first committed size was seen.
16+ sized bool
15 // region is the input region; nil accepts input everywhere.17 // region is the input region; nil accepts input everywhere.
16 region *ports.InputRegionChanged18 region *ports.InputRegionChanged
17 // inhibitShortcuts asks to keep compositor binds while focused;19 // inhibitShortcuts asks to keep compositor binds while focused;
@@ -50,7 +52,18 @@ func (r *windowRegistry) lookup(id WindowID) windowRecord {
50 52
51 func (r *windowRegistry) mapped(v ports.WindowMapped, now time.Time) {53 func (r *windowRegistry) mapped(v ports.WindowMapped, now time.Time) {
52 rec := r.at(v.ID)54 rec := r.at(v.ID)
53- rec.client, rec.mappedAt = v, now55+ rec.client, rec.mappedAt, rec.sized = v, now, false
56+}
57+
58+// firstSize reports whether this is the first committed size of the window
59+// since it mapped, and records that it was seen.
60+func (r *windowRegistry) firstSize(id WindowID) bool {
61+ rec := r.records[id]
62+ if rec == nil || rec.sized || rec.client.ID == 0 {
63+ return false
64+ }
65+ rec.sized = true
66+ return true
54 }67 }
55 68
56 // setAppID updates the app ID of a mapped window only.69 // setAppID updates the app ID of a mapped window only.

Comments1 open / 1

bnema

Wrap-up review record (head f75a2e77).

Review 1: GO-001 entrance motion re-added after auto-fullscreen -> fixed (bc208baf, early return). GO-002 unconditional FocusID stole focus (ADR 011) -> fixed: only when the window is already the focused window of the shown workspace. GO-003 rects delete guarded. GO-004/007 comments added. GO-005 docs moved next to toggle-fullscreen. GO-006 tests (remap, second output of another size). Review 2 (bc208baf): logic approved. GO-A1 spliced comment -> merged (2ca07b77). GO-A2 focus tests (focus moved away, quiet column, quiet float; t.Skip -> t.Fatal) -> added. GO-A3 docs note -> done.

Headless E2E through xwayland-satellite (640x360, X display :2), with the !32 state field:

  • 300x200 X11 window then 640x360 one: window 3 configure 640x360 fullscreen=true activated=true; state fullscreen [3], focus 3.
  • 640x360 window mapping while window 2 is fullscreen: when satellite sends no xdg-activation, window 2 stays fullscreen and focused, window 3 is not fullscreened (same as main). When satellite sends xdg-activation for the new window (racy, ~half the runs), Workspace.Activate leaves the cover's fullscreen and focuses the new window. This happens on main too (fdd661a8, f2d166b7) and is not caused by this branch; regression test TestLegacyFullscreenUnderBoundCover covers the adapter event sequence without activation.

Open, out of scope: whether an xdg-activation from a window that just mapped under a fullscreen cover should be refused. 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