detached 4fa5f7d

test(activation): cover fullscreen exit on activate and sync focus waits

commit
4fa5f7d43b5dc09d6aba34272538055bcde9d305
author
brice · · 2026-09-25 19:47 UTC
parent
9e3a591
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +50 -25
M internal/adapters/wayland/activation.go +3 -1
@@ -24,7 +24,9 @@ const tokenLifetime = 10 * time.Second
2424 const maxTokens = 32
2525
2626 // activationToken is an issued valid token. Tokens of a client die when
27-// the user moves the focus away from it.
27+// the focus moves away from it, even on an automatic focus change such as
28+// a new window mapping: a hand-off racing a transient window then fails
29+// safe, without stealing focus.
2830 type activationToken struct {
2931 client server.Client
3032 at time.Time
@@ -24,7 +24,9 @@ const tokenLifetime = 10 * time.Second
24 const maxTokens = 3224 const maxTokens = 32
25 25
26 // activationToken is an issued valid token. Tokens of a client die when26 // activationToken is an issued valid token. Tokens of a client die when
27-// the user moves the focus away from it.27+// the focus moves away from it, even on an automatic focus change such as
28+// a new window mapping: a hand-off racing a transient window then fails
29+// safe, without stealing focus.
28 type activationToken struct {30 type activationToken struct {
29 client server.Client31 client server.Client
30 at time.Time32 at time.Time
M internal/adapters/wayland/activation_test.go +15 -8
@@ -133,10 +133,7 @@ func TestActivationAcrossClients(t *testing.T) {
133133 // A token dies when the focus leaves its client.
134134 token = newToken(t, a, managerA)
135135 commands <- ports.FocusWindow{ID: winB.ID}
136- if err := b.Roundtrip(); err != nil {
137- t.Fatal(err)
138- }
139- time.Sleep(20 * time.Millisecond)
136+ waitFocus(t, s, winB.ID)
140137 requestProtocol(t, b, managerB, xdgactivation.ActivationV1RequestActivate, token, surfB)
141138 noActivation(t, b, events)
142139 }
@@ -173,10 +170,7 @@ func TestActivationPressSerial(t *testing.T) {
173170 // The focus moved after the press: stale.
174171 commands <- ports.FocusWindow{ID: w.ID}
175172 commands <- ports.FocusWindow{}
176- if err := c.Roundtrip(); err != nil {
177- t.Fatal(err)
178- }
179- time.Sleep(20 * time.Millisecond)
173+ waitFocus(t, s, 0)
180174 token = newToken(t, c, manager, serial, seat)
181175 requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
182176 noActivation(t, c, events)
@@ -193,3 +187,16 @@ func TestActivationTokenCommittedTwice(t *testing.T) {
193187 requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
194188 expectProtocolError(t, c, id, uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed))
195189 }
190+
191+// waitFocus waits until the display goroutine has applied a focus change.
192+func waitFocus(t *testing.T, s *Server, id ports.WindowID) {
193+ t.Helper()
194+ for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) {
195+ done := make(chan ports.WindowID, 1)
196+ s.display.Do(func() { done <- s.focused })
197+ if <-done == id {
198+ return
199+ }
200+ }
201+ t.Fatal("focus not applied", id)
202+}
@@ -133,10 +133,7 @@ func TestActivationAcrossClients(t *testing.T) {
133 // A token dies when the focus leaves its client.133 // A token dies when the focus leaves its client.
134 token = newToken(t, a, managerA)134 token = newToken(t, a, managerA)
135 commands <- ports.FocusWindow{ID: winB.ID}135 commands <- ports.FocusWindow{ID: winB.ID}
136- if err := b.Roundtrip(); err != nil {136+ waitFocus(t, s, winB.ID)
137- t.Fatal(err)
138- }
139- time.Sleep(20 * time.Millisecond)
140 requestProtocol(t, b, managerB, xdgactivation.ActivationV1RequestActivate, token, surfB)137 requestProtocol(t, b, managerB, xdgactivation.ActivationV1RequestActivate, token, surfB)
141 noActivation(t, b, events)138 noActivation(t, b, events)
142 }139 }
@@ -173,10 +170,7 @@ func TestActivationPressSerial(t *testing.T) {
173 // The focus moved after the press: stale.170 // The focus moved after the press: stale.
174 commands <- ports.FocusWindow{ID: w.ID}171 commands <- ports.FocusWindow{ID: w.ID}
175 commands <- ports.FocusWindow{}172 commands <- ports.FocusWindow{}
176- if err := c.Roundtrip(); err != nil {173+ waitFocus(t, s, 0)
177- t.Fatal(err)
178- }
179- time.Sleep(20 * time.Millisecond)
180 token = newToken(t, c, manager, serial, seat)174 token = newToken(t, c, manager, serial, seat)
181 requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)175 requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
182 noActivation(t, c, events)176 noActivation(t, c, events)
@@ -193,3 +187,16 @@ func TestActivationTokenCommittedTwice(t *testing.T) {
193 requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)187 requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
194 expectProtocolError(t, c, id, uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed))188 expectProtocolError(t, c, id, uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed))
195 }189 }
190+
191+// waitFocus waits until the display goroutine has applied a focus change.
192+func waitFocus(t *testing.T, s *Server, id ports.WindowID) {
193+ t.Helper()
194+ for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) {
195+ done := make(chan ports.WindowID, 1)
196+ s.display.Do(func() { done <- s.focused })
197+ if <-done == id {
198+ return
199+ }
200+ }
201+ t.Fatal("focus not applied", id)
202+}
M internal/core/core_test.go +0 -16
@@ -6,7 +6,6 @@ import (
66 "github.com/bnema/nefertty/internal/adapters/config"
77 "github.com/bnema/nefertty/internal/core"
88 "github.com/bnema/nefertty/internal/ports"
9- "slices"
109 "testing"
1110 "time"
1211 )
@@ -774,19 +773,4 @@ func TestWindowActivateShowsAndFocuses(t *testing.T) {
774773 }
775774 }
776775
777- // An activated window behind a fullscreen one comes out.
778- client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
779- scene(t, scenes)
780- client <- ports.WindowMapped{ID: 3}
781- scene(t, scenes)
782- client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
783- scene(t, scenes)
784- client <- ports.WindowActivate{ID: 3}
785- for {
786- sc := scene(t, scenes)
787- shown := visible(sc)
788- if len(shown) > 0 && slices.ContainsFunc(shown, func(w ports.SceneWindow) bool { return w.ID == 3 && w.Focused }) {
789- break
790- }
791- }
792776 }
@@ -6,7 +6,6 @@ import (
6 "github.com/bnema/nefertty/internal/adapters/config"6 "github.com/bnema/nefertty/internal/adapters/config"
7 "github.com/bnema/nefertty/internal/core"7 "github.com/bnema/nefertty/internal/core"
8 "github.com/bnema/nefertty/internal/ports"8 "github.com/bnema/nefertty/internal/ports"
9- "slices"
10 "testing"9 "testing"
11 "time"10 "time"
12 )11 )
@@ -774,19 +773,4 @@ func TestWindowActivateShowsAndFocuses(t *testing.T) {
774 }773 }
775 }774 }
776 775
777- // An activated window behind a fullscreen one comes out.
778- client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
779- scene(t, scenes)
780- client <- ports.WindowMapped{ID: 3}
781- scene(t, scenes)
782- client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
783- scene(t, scenes)
784- client <- ports.WindowActivate{ID: 3}
785- for {
786- sc := scene(t, scenes)
787- shown := visible(sc)
788- if len(shown) > 0 && slices.ContainsFunc(shown, func(w ports.SceneWindow) bool { return w.ID == 3 && w.Focused }) {
789- break
790- }
791- }
792 }776 }
M internal/core/layout_test.go +32 -0
@@ -449,3 +449,35 @@ func TestFixedOverflowEdgeCases(t *testing.T) {
449449 }
450450 })
451451 }
452+
453+// Activating a window hidden by another's fullscreen shows it; a floating
454+// target stays above the fullscreen window, which keeps it.
455+func TestActivateLeavesFullscreen(t *testing.T) {
456+ for _, tc := range []struct {
457+ name string
458+ setup func(w *Workspace)
459+ keep bool
460+ }{
461+ {"fixed", func(w *Workspace) { w.Overflow = OverflowFixed; w.MaxColumns = 2; w.AddWindow(3) }, false},
462+ {"same column", func(w *Workspace) { w.Columns[0].Windows = append(w.Columns[0].Windows, 3) }, false},
463+ {"floating", func(w *Workspace) { w.AddFloating(3, 10, 10) }, true},
464+ } {
465+ t.Run(tc.name, func(t *testing.T) {
466+ w := &Workspace{}
467+ w.SetOutput(100, 80)
468+ w.AddWindow(2)
469+ tc.setup(w)
470+ w.FocusID(2)
471+ w.SetFullscreen(2, true)
472+ w.Activate(3)
473+ for _, p := range w.Layout() {
474+ if p.ID == 3 && (p.Hidden || !p.Focused) {
475+ t.Fatalf("3 not shown and focused: %+v", p)
476+ }
477+ if p.ID == 2 && p.Fullscreen != tc.keep {
478+ t.Fatalf("fullscreen of 2 = %v", p.Fullscreen)
479+ }
480+ }
481+ })
482+ }
483+}
@@ -449,3 +449,35 @@ func TestFixedOverflowEdgeCases(t *testing.T) {
449 }449 }
450 })450 })
451 }451 }
452+
453+// Activating a window hidden by another's fullscreen shows it; a floating
454+// target stays above the fullscreen window, which keeps it.
455+func TestActivateLeavesFullscreen(t *testing.T) {
456+ for _, tc := range []struct {
457+ name string
458+ setup func(w *Workspace)
459+ keep bool
460+ }{
461+ {"fixed", func(w *Workspace) { w.Overflow = OverflowFixed; w.MaxColumns = 2; w.AddWindow(3) }, false},
462+ {"same column", func(w *Workspace) { w.Columns[0].Windows = append(w.Columns[0].Windows, 3) }, false},
463+ {"floating", func(w *Workspace) { w.AddFloating(3, 10, 10) }, true},
464+ } {
465+ t.Run(tc.name, func(t *testing.T) {
466+ w := &Workspace{}
467+ w.SetOutput(100, 80)
468+ w.AddWindow(2)
469+ tc.setup(w)
470+ w.FocusID(2)
471+ w.SetFullscreen(2, true)
472+ w.Activate(3)
473+ for _, p := range w.Layout() {
474+ if p.ID == 3 && (p.Hidden || !p.Focused) {
475+ t.Fatalf("3 not shown and focused: %+v", p)
476+ }
477+ if p.ID == 2 && p.Fullscreen != tc.keep {
478+ t.Fatalf("fullscreen of 2 = %v", p.Fullscreen)
479+ }
480+ }
481+ })
482+ }
483+}

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close