detached 9bc7abf

merge: activation (xdg-activation-v1 tokens and focus)

commit
9bc7abf38a2f1cc37b5bf10664cc8b887e81411e
author
brice · · 2026-09-25 19:48 UTC
merge of
856d644 + 4fa5f7d · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +504 -12
A internal/adapters/wayland/activation.go +148 -0
@@ -0,0 +1,148 @@
1+package wayland
2+
3+import (
4+ "crypto/rand"
5+ "time"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/wayland"
9+ "github.com/bnema/purego-libwayland/protocol/xdgactivation"
10+ "github.com/bnema/purego-libwayland/server"
11+)
12+
13+// xdg-activation: a client that has the user's attention (keyboard focus,
14+// or the last key or button press) gets a token, and the client it hands
15+// the token to (a link opened in the browser, a notification action) may
16+// bring its window forward with it. Other tokens are issued but inert, so
17+// a background app cannot steal the focus.
18+
19+// tokenLifetime is how long a token can activate a window, and how recent
20+// a press must be to earn one.
21+const tokenLifetime = 10 * time.Second
22+
23+// maxTokens bounds the valid tokens kept; the oldest goes first.
24+const maxTokens = 32
25+
26+// activationToken is an issued valid token. Tokens of a client die when
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.
30+type activationToken struct {
31+ client server.Client
32+ at time.Time
33+}
34+
35+func registerActivation(d *server.Display, s *Server) error {
36+ return xdgactivation.NewActivationV1Global(d, 1, func(c server.Client, v, id uint32) {
37+ _, _ = xdgactivation.NewActivationV1(c, int32(v), id, activation{s})
38+ })
39+}
40+
41+type activation struct{ server *Server }
42+
43+func (activation) Destroy(*xdgactivation.ActivationV1) {}
44+
45+func (a activation) GetActivationToken(r *xdgactivation.ActivationV1, id uint32) {
46+ _, _ = xdgactivation.NewActivationTokenV1(r.Client(), r.Version(), id, &tokenRequest{server: a.server})
47+}
48+
49+// Activate focuses the toplevel of surface when the token is valid. The
50+// token is used up either way.
51+func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf *wayland.Surface) {
52+ s := a.server
53+ t, ok := s.tokens[token]
54+ delete(s.tokens, token)
55+ state := s.surfaceOf(surf)
56+ reason := ""
57+ switch {
58+ case !ok:
59+ reason = "unknown_token"
60+ case time.Since(t.at) > tokenLifetime:
61+ reason = "expired"
62+ case state == nil:
63+ reason = "no_surface"
64+ }
65+ var w *window
66+ if reason == "" {
67+ if w = s.windows[state.root().windowID()]; w == nil || w.toplevel == nil || !w.mapped {
68+ reason = "not_toplevel"
69+ }
70+ }
71+ if reason != "" {
72+ s.log.Debug().Str("reason", reason).Msg("activation refused")
73+ return
74+ }
75+ s.log.Info().Uint64("id", uint64(w.id)).Msg("activation")
76+ s.emit(ports.WindowActivate{ID: w.id})
77+}
78+
79+// tokenRequest collects a token's serial and surface until commit.
80+type tokenRequest struct {
81+ server *Server
82+ serial uint32
83+ hasSerial bool
84+ used bool
85+}
86+
87+func (t *tokenRequest) SetSerial(_ *xdgactivation.ActivationTokenV1, serial uint32, _ *wayland.Seat) {
88+ t.serial, t.hasSerial = serial, true
89+}
90+func (*tokenRequest) SetAppId(*xdgactivation.ActivationTokenV1, string) {}
91+func (*tokenRequest) SetSurface(*xdgactivation.ActivationTokenV1, *wayland.Surface) {}
92+func (*tokenRequest) Destroy(*xdgactivation.ActivationTokenV1) {}
93+
94+// Commit issues the token. It is valid when the requesting client has the
95+// keyboard focus, or made the last press, recent and not followed by a
96+// focus change, that it names by serial. Invalid tokens are not kept.
97+func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
98+ if t.used {
99+ r.PostError(uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed), "token already committed")
100+ return
101+ }
102+ t.used = true
103+ s := t.server
104+ c := r.Client()
105+ fresh := time.Since(s.pressAt) <= tokenLifetime && !s.pressAt.Before(s.focusAt)
106+ valid := s.focusClient() == c || (t.hasSerial && t.serial == s.press && s.pressClient == c && fresh)
107+ token := rand.Text()
108+ if valid {
109+ s.pruneTokens()
110+ s.tokens[token] = activationToken{client: c, at: time.Now()}
111+ }
112+ r.SendDone(token)
113+}
114+
115+// focusClient is the client with the keyboard focus.
116+func (s *Server) focusClient() server.Client {
117+ if w := s.windows[s.focused]; w != nil && w.xdg.resource.Resource.Alive() {
118+ return w.xdg.resource.Client()
119+ }
120+ if l := s.layers[s.focused]; l != nil && l.resource.Resource.Alive() {
121+ return l.resource.Client()
122+ }
123+ return server.Client{}
124+}
125+
126+// pruneTokens forgets expired tokens and, past maxTokens, the oldest.
127+func (s *Server) pruneTokens() {
128+ oldest := ""
129+ for k, t := range s.tokens {
130+ if time.Since(t.at) > tokenLifetime {
131+ delete(s.tokens, k)
132+ } else if oldest == "" || t.at.Before(s.tokens[oldest].at) {
133+ oldest = k
134+ }
135+ }
136+ if len(s.tokens) >= maxTokens {
137+ delete(s.tokens, oldest)
138+ }
139+}
140+
141+// dropTokens forgets the tokens of a client that lost the focus.
142+func (s *Server) dropTokens(c server.Client) {
143+ for k, t := range s.tokens {
144+ if t.client == c {
145+ delete(s.tokens, k)
146+ }
147+ }
148+}
@@ -0,0 +1,148 @@
1+package wayland
2+
3+import (
4+ "crypto/rand"
5+ "time"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/wayland"
9+ "github.com/bnema/purego-libwayland/protocol/xdgactivation"
10+ "github.com/bnema/purego-libwayland/server"
11+)
12+
13+// xdg-activation: a client that has the user's attention (keyboard focus,
14+// or the last key or button press) gets a token, and the client it hands
15+// the token to (a link opened in the browser, a notification action) may
16+// bring its window forward with it. Other tokens are issued but inert, so
17+// a background app cannot steal the focus.
18+
19+// tokenLifetime is how long a token can activate a window, and how recent
20+// a press must be to earn one.
21+const tokenLifetime = 10 * time.Second
22+
23+// maxTokens bounds the valid tokens kept; the oldest goes first.
24+const maxTokens = 32
25+
26+// activationToken is an issued valid token. Tokens of a client die when
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.
30+type activationToken struct {
31+ client server.Client
32+ at time.Time
33+}
34+
35+func registerActivation(d *server.Display, s *Server) error {
36+ return xdgactivation.NewActivationV1Global(d, 1, func(c server.Client, v, id uint32) {
37+ _, _ = xdgactivation.NewActivationV1(c, int32(v), id, activation{s})
38+ })
39+}
40+
41+type activation struct{ server *Server }
42+
43+func (activation) Destroy(*xdgactivation.ActivationV1) {}
44+
45+func (a activation) GetActivationToken(r *xdgactivation.ActivationV1, id uint32) {
46+ _, _ = xdgactivation.NewActivationTokenV1(r.Client(), r.Version(), id, &tokenRequest{server: a.server})
47+}
48+
49+// Activate focuses the toplevel of surface when the token is valid. The
50+// token is used up either way.
51+func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf *wayland.Surface) {
52+ s := a.server
53+ t, ok := s.tokens[token]
54+ delete(s.tokens, token)
55+ state := s.surfaceOf(surf)
56+ reason := ""
57+ switch {
58+ case !ok:
59+ reason = "unknown_token"
60+ case time.Since(t.at) > tokenLifetime:
61+ reason = "expired"
62+ case state == nil:
63+ reason = "no_surface"
64+ }
65+ var w *window
66+ if reason == "" {
67+ if w = s.windows[state.root().windowID()]; w == nil || w.toplevel == nil || !w.mapped {
68+ reason = "not_toplevel"
69+ }
70+ }
71+ if reason != "" {
72+ s.log.Debug().Str("reason", reason).Msg("activation refused")
73+ return
74+ }
75+ s.log.Info().Uint64("id", uint64(w.id)).Msg("activation")
76+ s.emit(ports.WindowActivate{ID: w.id})
77+}
78+
79+// tokenRequest collects a token's serial and surface until commit.
80+type tokenRequest struct {
81+ server *Server
82+ serial uint32
83+ hasSerial bool
84+ used bool
85+}
86+
87+func (t *tokenRequest) SetSerial(_ *xdgactivation.ActivationTokenV1, serial uint32, _ *wayland.Seat) {
88+ t.serial, t.hasSerial = serial, true
89+}
90+func (*tokenRequest) SetAppId(*xdgactivation.ActivationTokenV1, string) {}
91+func (*tokenRequest) SetSurface(*xdgactivation.ActivationTokenV1, *wayland.Surface) {}
92+func (*tokenRequest) Destroy(*xdgactivation.ActivationTokenV1) {}
93+
94+// Commit issues the token. It is valid when the requesting client has the
95+// keyboard focus, or made the last press, recent and not followed by a
96+// focus change, that it names by serial. Invalid tokens are not kept.
97+func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
98+ if t.used {
99+ r.PostError(uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed), "token already committed")
100+ return
101+ }
102+ t.used = true
103+ s := t.server
104+ c := r.Client()
105+ fresh := time.Since(s.pressAt) <= tokenLifetime && !s.pressAt.Before(s.focusAt)
106+ valid := s.focusClient() == c || (t.hasSerial && t.serial == s.press && s.pressClient == c && fresh)
107+ token := rand.Text()
108+ if valid {
109+ s.pruneTokens()
110+ s.tokens[token] = activationToken{client: c, at: time.Now()}
111+ }
112+ r.SendDone(token)
113+}
114+
115+// focusClient is the client with the keyboard focus.
116+func (s *Server) focusClient() server.Client {
117+ if w := s.windows[s.focused]; w != nil && w.xdg.resource.Resource.Alive() {
118+ return w.xdg.resource.Client()
119+ }
120+ if l := s.layers[s.focused]; l != nil && l.resource.Resource.Alive() {
121+ return l.resource.Client()
122+ }
123+ return server.Client{}
124+}
125+
126+// pruneTokens forgets expired tokens and, past maxTokens, the oldest.
127+func (s *Server) pruneTokens() {
128+ oldest := ""
129+ for k, t := range s.tokens {
130+ if time.Since(t.at) > tokenLifetime {
131+ delete(s.tokens, k)
132+ } else if oldest == "" || t.at.Before(s.tokens[oldest].at) {
133+ oldest = k
134+ }
135+ }
136+ if len(s.tokens) >= maxTokens {
137+ delete(s.tokens, oldest)
138+ }
139+}
140+
141+// dropTokens forgets the tokens of a client that lost the focus.
142+func (s *Server) dropTokens(c server.Client) {
143+ for k, t := range s.tokens {
144+ if t.client == c {
145+ delete(s.tokens, k)
146+ }
147+ }
148+}
A internal/adapters/wayland/activation_test.go +202 -0
@@ -0,0 +1,202 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/xdgactivation"
9+ "github.com/bnema/wlturbo"
10+)
11+
12+// tokenProxy receives an xdg_activation_token_v1.done token.
13+type tokenProxy struct {
14+ wlturbo.BaseProxy
15+ tokens chan string
16+}
17+
18+func (p *tokenProxy) Dispatch(e *wlturbo.Event) { p.tokens <- e.String() }
19+
20+// newToken commits a token request, with a serial and seat when given,
21+// and returns the token.
22+func newToken(t *testing.T, c *wlturbo.Display, manager uint32, serialSeat ...uint32) string {
23+ t.Helper()
24+ id := c.AllocateID()
25+ p := &tokenProxy{tokens: make(chan string, 1)}
26+ p.SetID(id)
27+ c.Context().Register(p)
28+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id)
29+ if len(serialSeat) == 2 {
30+ requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestSetSerial, serialSeat[0], serialSeat[1])
31+ }
32+ requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
33+ for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
34+ if err := c.Roundtrip(); err != nil {
35+ t.Fatal(err)
36+ }
37+ select {
38+ case tok := <-p.tokens:
39+ return tok
40+ default:
41+ }
42+ }
43+ t.Fatal("no token")
44+ return ""
45+}
46+
47+func activated(t *testing.T, events <-chan ports.ClientEvent, want ports.WindowID) {
48+ t.Helper()
49+ deadline := time.After(2 * time.Second)
50+ for {
51+ select {
52+ case ev := <-events:
53+ if a, ok := ev.(ports.WindowActivate); ok {
54+ if a.ID != want {
55+ t.Fatalf("activated %d, want %d", a.ID, want)
56+ }
57+ return
58+ }
59+ case <-deadline:
60+ t.Fatal("no activation")
61+ }
62+ }
63+}
64+
65+func noActivation(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) {
66+ t.Helper()
67+ if err := c.Roundtrip(); err != nil {
68+ t.Fatal(err)
69+ }
70+ deadline := time.After(100 * time.Millisecond)
71+ for {
72+ select {
73+ case ev := <-events:
74+ if _, ok := ev.(ports.WindowActivate); ok {
75+ t.Fatal("activated with an invalid token")
76+ }
77+ case <-deadline:
78+ return
79+ }
80+ }
81+}
82+
83+func TestActivationNeedsFocusedRequester(t *testing.T) {
84+ s, events, commands, dir := lifecycleServer(t)
85+ c := protocolClient(t, s, dir)
86+ mapWindow := surfaceMapper(t, c, events)
87+ first, _, _ := mapWindow()
88+ second, surf, _ := mapWindow()
89+ manager := bindProtocol(t, c, "xdg_activation_v1")
90+
91+ // Without focus the token is inert.
92+ token := newToken(t, c, manager)
93+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
94+ noActivation(t, c, events)
95+
96+ // The focused client's token activates, once.
97+ commands <- ports.FocusWindow{ID: first.ID}
98+ if err := c.Roundtrip(); err != nil {
99+ t.Fatal(err)
100+ }
101+ token = newToken(t, c, manager)
102+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
103+ activated(t, events, second.ID)
104+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
105+ noActivation(t, c, events)
106+
107+ // Unknown tokens are ignored.
108+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, "not-a-token", surf)
109+ noActivation(t, c, events)
110+}
111+
112+// Another client's token is inert while one client has the focus; the
113+// focused client may hand its token to activate another client's window.
114+func TestActivationAcrossClients(t *testing.T) {
115+ s, events, commands, dir := lifecycleServer(t)
116+ a := protocolClient(t, s, dir)
117+ winA, _, _ := surfaceMapper(t, a, events)()
118+ b := protocolClient(t, s, dir)
119+ winB, surfB, _ := surfaceMapper(t, b, events)()
120+ managerA := bindProtocol(t, a, "xdg_activation_v1")
121+ managerB := bindProtocol(t, b, "xdg_activation_v1")
122+ commands <- ports.FocusWindow{ID: winA.ID}
123+ if err := a.Roundtrip(); err != nil {
124+ t.Fatal(err)
125+ }
126+ token := newToken(t, b, managerB)
127+ requestProtocol(t, b, managerB, xdgactivation.ActivationV1RequestActivate, token, surfB)
128+ noActivation(t, b, events)
129+ token = newToken(t, a, managerA)
130+ requestProtocol(t, b, managerB, xdgactivation.ActivationV1RequestActivate, token, surfB)
131+ activated(t, events, winB.ID)
132+
133+ // A token dies when the focus leaves its client.
134+ token = newToken(t, a, managerA)
135+ commands <- ports.FocusWindow{ID: winB.ID}
136+ waitFocus(t, s, winB.ID)
137+ requestProtocol(t, b, managerB, xdgactivation.ActivationV1RequestActivate, token, surfB)
138+ noActivation(t, b, events)
139+}
140+
141+// A press serial earns a token only while the press is the latest user
142+// action: after a focus change it is stale.
143+func TestActivationPressSerial(t *testing.T) {
144+ s, events, commands, dir := lifecycleServer(t)
145+ c := protocolClient(t, s, dir)
146+ w, surf, _ := surfaceMapper(t, c, events)()
147+ manager := bindProtocol(t, c, "xdg_activation_v1")
148+ seat := bindProtocol(t, c, "wl_seat")
149+ registerProtocol(t, c, seat)
150+ press := func() uint32 {
151+ var serial uint32
152+ done := make(chan struct{})
153+ s.display.Do(func() {
154+ s.serial++
155+ s.press, s.pressClient, s.pressAt = s.serial, s.windows[w.ID].xdg.resource.Client(), time.Now()
156+ serial = s.serial
157+ close(done)
158+ })
159+ <-done
160+ return serial
161+ }
162+ serial := press()
163+ token := newToken(t, c, manager, serial, seat)
164+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
165+ activated(t, events, w.ID)
166+ // A wrong serial earns nothing.
167+ token = newToken(t, c, manager, serial+100, seat)
168+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
169+ noActivation(t, c, events)
170+ // The focus moved after the press: stale.
171+ commands <- ports.FocusWindow{ID: w.ID}
172+ commands <- ports.FocusWindow{}
173+ waitFocus(t, s, 0)
174+ token = newToken(t, c, manager, serial, seat)
175+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
176+ noActivation(t, c, events)
177+}
178+
179+func TestActivationTokenCommittedTwice(t *testing.T) {
180+ s, _, _, dir := lifecycleServer(t)
181+ c := protocolClient(t, s, dir)
182+ manager := bindProtocol(t, c, "xdg_activation_v1")
183+ id := c.AllocateID()
184+ registerProtocol(t, c, id)
185+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id)
186+ requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
187+ requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
188+ expectProtocolError(t, c, id, uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed))
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+}
@@ -0,0 +1,202 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/xdgactivation"
9+ "github.com/bnema/wlturbo"
10+)
11+
12+// tokenProxy receives an xdg_activation_token_v1.done token.
13+type tokenProxy struct {
14+ wlturbo.BaseProxy
15+ tokens chan string
16+}
17+
18+func (p *tokenProxy) Dispatch(e *wlturbo.Event) { p.tokens <- e.String() }
19+
20+// newToken commits a token request, with a serial and seat when given,
21+// and returns the token.
22+func newToken(t *testing.T, c *wlturbo.Display, manager uint32, serialSeat ...uint32) string {
23+ t.Helper()
24+ id := c.AllocateID()
25+ p := &tokenProxy{tokens: make(chan string, 1)}
26+ p.SetID(id)
27+ c.Context().Register(p)
28+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id)
29+ if len(serialSeat) == 2 {
30+ requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestSetSerial, serialSeat[0], serialSeat[1])
31+ }
32+ requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
33+ for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
34+ if err := c.Roundtrip(); err != nil {
35+ t.Fatal(err)
36+ }
37+ select {
38+ case tok := <-p.tokens:
39+ return tok
40+ default:
41+ }
42+ }
43+ t.Fatal("no token")
44+ return ""
45+}
46+
47+func activated(t *testing.T, events <-chan ports.ClientEvent, want ports.WindowID) {
48+ t.Helper()
49+ deadline := time.After(2 * time.Second)
50+ for {
51+ select {
52+ case ev := <-events:
53+ if a, ok := ev.(ports.WindowActivate); ok {
54+ if a.ID != want {
55+ t.Fatalf("activated %d, want %d", a.ID, want)
56+ }
57+ return
58+ }
59+ case <-deadline:
60+ t.Fatal("no activation")
61+ }
62+ }
63+}
64+
65+func noActivation(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) {
66+ t.Helper()
67+ if err := c.Roundtrip(); err != nil {
68+ t.Fatal(err)
69+ }
70+ deadline := time.After(100 * time.Millisecond)
71+ for {
72+ select {
73+ case ev := <-events:
74+ if _, ok := ev.(ports.WindowActivate); ok {
75+ t.Fatal("activated with an invalid token")
76+ }
77+ case <-deadline:
78+ return
79+ }
80+ }
81+}
82+
83+func TestActivationNeedsFocusedRequester(t *testing.T) {
84+ s, events, commands, dir := lifecycleServer(t)
85+ c := protocolClient(t, s, dir)
86+ mapWindow := surfaceMapper(t, c, events)
87+ first, _, _ := mapWindow()
88+ second, surf, _ := mapWindow()
89+ manager := bindProtocol(t, c, "xdg_activation_v1")
90+
91+ // Without focus the token is inert.
92+ token := newToken(t, c, manager)
93+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
94+ noActivation(t, c, events)
95+
96+ // The focused client's token activates, once.
97+ commands <- ports.FocusWindow{ID: first.ID}
98+ if err := c.Roundtrip(); err != nil {
99+ t.Fatal(err)
100+ }
101+ token = newToken(t, c, manager)
102+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
103+ activated(t, events, second.ID)
104+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
105+ noActivation(t, c, events)
106+
107+ // Unknown tokens are ignored.
108+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, "not-a-token", surf)
109+ noActivation(t, c, events)
110+}
111+
112+// Another client's token is inert while one client has the focus; the
113+// focused client may hand its token to activate another client's window.
114+func TestActivationAcrossClients(t *testing.T) {
115+ s, events, commands, dir := lifecycleServer(t)
116+ a := protocolClient(t, s, dir)
117+ winA, _, _ := surfaceMapper(t, a, events)()
118+ b := protocolClient(t, s, dir)
119+ winB, surfB, _ := surfaceMapper(t, b, events)()
120+ managerA := bindProtocol(t, a, "xdg_activation_v1")
121+ managerB := bindProtocol(t, b, "xdg_activation_v1")
122+ commands <- ports.FocusWindow{ID: winA.ID}
123+ if err := a.Roundtrip(); err != nil {
124+ t.Fatal(err)
125+ }
126+ token := newToken(t, b, managerB)
127+ requestProtocol(t, b, managerB, xdgactivation.ActivationV1RequestActivate, token, surfB)
128+ noActivation(t, b, events)
129+ token = newToken(t, a, managerA)
130+ requestProtocol(t, b, managerB, xdgactivation.ActivationV1RequestActivate, token, surfB)
131+ activated(t, events, winB.ID)
132+
133+ // A token dies when the focus leaves its client.
134+ token = newToken(t, a, managerA)
135+ commands <- ports.FocusWindow{ID: winB.ID}
136+ waitFocus(t, s, winB.ID)
137+ requestProtocol(t, b, managerB, xdgactivation.ActivationV1RequestActivate, token, surfB)
138+ noActivation(t, b, events)
139+}
140+
141+// A press serial earns a token only while the press is the latest user
142+// action: after a focus change it is stale.
143+func TestActivationPressSerial(t *testing.T) {
144+ s, events, commands, dir := lifecycleServer(t)
145+ c := protocolClient(t, s, dir)
146+ w, surf, _ := surfaceMapper(t, c, events)()
147+ manager := bindProtocol(t, c, "xdg_activation_v1")
148+ seat := bindProtocol(t, c, "wl_seat")
149+ registerProtocol(t, c, seat)
150+ press := func() uint32 {
151+ var serial uint32
152+ done := make(chan struct{})
153+ s.display.Do(func() {
154+ s.serial++
155+ s.press, s.pressClient, s.pressAt = s.serial, s.windows[w.ID].xdg.resource.Client(), time.Now()
156+ serial = s.serial
157+ close(done)
158+ })
159+ <-done
160+ return serial
161+ }
162+ serial := press()
163+ token := newToken(t, c, manager, serial, seat)
164+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
165+ activated(t, events, w.ID)
166+ // A wrong serial earns nothing.
167+ token = newToken(t, c, manager, serial+100, seat)
168+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
169+ noActivation(t, c, events)
170+ // The focus moved after the press: stale.
171+ commands <- ports.FocusWindow{ID: w.ID}
172+ commands <- ports.FocusWindow{}
173+ waitFocus(t, s, 0)
174+ token = newToken(t, c, manager, serial, seat)
175+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
176+ noActivation(t, c, events)
177+}
178+
179+func TestActivationTokenCommittedTwice(t *testing.T) {
180+ s, _, _, dir := lifecycleServer(t)
181+ c := protocolClient(t, s, dir)
182+ manager := bindProtocol(t, c, "xdg_activation_v1")
183+ id := c.AllocateID()
184+ registerProtocol(t, c, id)
185+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id)
186+ requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
187+ requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
188+ expectProtocolError(t, c, id, uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed))
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/adapters/wayland/globals.go +1 -0
@@ -19,6 +19,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
1919 func() error { return registerPointerConstraints(d, s) },
2020 func() error { return registerTearing(d, s) },
2121 func() error { return registerPresentation(d, s) },
22+ func() error { return registerActivation(d, s) },
2223 func() error {
2324 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })
2425 },
@@ -19,6 +19,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
19 func() error { return registerPointerConstraints(d, s) },19 func() error { return registerPointerConstraints(d, s) },
20 func() error { return registerTearing(d, s) },20 func() error { return registerTearing(d, s) },
21 func() error { return registerPresentation(d, s) },21 func() error { return registerPresentation(d, s) },
22+ func() error { return registerActivation(d, s) },
22 func() error {23 func() error {
23 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })24 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })
24 },25 },
M internal/adapters/wayland/server.go +22 -8
@@ -62,14 +62,19 @@ type Server struct {
6262 // lastFlip is each output's latest page flip.
6363 fifoSurfaces map[*surface]struct{}
6464 lastFlip map[string]time.Time
65- surfaces map[*server.Resource]*surface
66- buffers map[*server.Resource]clientBuffer
67- dmabuf *dmabufGlobal
68- serial uint32
65+ // tokens are the issued xdg-activation tokens (activation.go).
66+ tokens map[string]activationToken
67+ surfaces map[*server.Resource]*surface
68+ buffers map[*server.Resource]clientBuffer
69+ dmabuf *dmabufGlobal
70+ serial uint32
6971 // press is the serial of the last button or key press, sent to
7072 // pressClient: popup grabs must come from it.
71- press uint32
72- pressClient server.Client
73+ press uint32
74+ pressClient server.Client
75+ // pressAt and focusAt date the last press and keyboard focus change,
76+ // for xdg-activation tokens.
77+ pressAt, focusAt time.Time
7378 ctx context.Context
7479 windows map[ports.WindowID]*window
7580 layers map[ports.WindowID]*layerSurface
@@ -154,6 +159,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
154159 s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
155160 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
156161 s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{}
162+ s.tokens = map[string]activationToken{}
157163 if opts.Keymap != "" {
158164 fd, size, e := keymapFile(opts.Keymap)
159165 if e != nil {
@@ -383,7 +389,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
383389 state := uint32(0)
384390 if c.Pressed {
385391 state = 1
386- s.press, s.pressClient = s.serial, w.xdg.resource.Client()
392+ s.press, s.pressClient, s.pressAt = s.serial, w.xdg.resource.Client(), time.Now()
387393 }
388394 for _, p := range s.windowPointers(w) {
389395 p.SendButton(s.serial, c.TimeMsec, c.Button, state)
@@ -417,7 +423,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
417423 }
418424 if c.Key.Pressed {
419425 if surf, _ := s.focusTarget(c.ID); surf != nil {
420- s.press, s.pressClient = s.serial, surf.Client()
426+ s.press, s.pressClient, s.pressAt = s.serial, surf.Client(), time.Now()
421427 }
422428 }
423429 for _, k := range keyboards {
@@ -568,6 +574,14 @@ func (s *Server) sendModifiers(k *wayland.Keyboard) {
568574 }
569575
570576 func (s *Server) changeFocus(id ports.WindowID) {
577+ old := s.focusClient()
578+ defer func() {
579+ // Tokens of a client losing the focus die with it.
580+ if now := s.focusClient(); now != old {
581+ s.dropTokens(old)
582+ s.focusAt = time.Now()
583+ }
584+ }()
571585 if surf, keyboards := s.focusTarget(s.focused); surf != nil {
572586 for _, k := range keyboards {
573587 s.serial++
@@ -62,14 +62,19 @@ type Server struct {
62 // lastFlip is each output's latest page flip.62 // lastFlip is each output's latest page flip.
63 fifoSurfaces map[*surface]struct{}63 fifoSurfaces map[*surface]struct{}
64 lastFlip map[string]time.Time64 lastFlip map[string]time.Time
65- surfaces map[*server.Resource]*surface65+ // tokens are the issued xdg-activation tokens (activation.go).
66- buffers map[*server.Resource]clientBuffer66+ tokens map[string]activationToken
67- dmabuf *dmabufGlobal67+ surfaces map[*server.Resource]*surface
68- serial uint3268+ buffers map[*server.Resource]clientBuffer
69+ dmabuf *dmabufGlobal
70+ serial uint32
69 // press is the serial of the last button or key press, sent to71 // press is the serial of the last button or key press, sent to
70 // pressClient: popup grabs must come from it.72 // pressClient: popup grabs must come from it.
71- press uint3273+ press uint32
72- pressClient server.Client74+ pressClient server.Client
75+ // pressAt and focusAt date the last press and keyboard focus change,
76+ // for xdg-activation tokens.
77+ pressAt, focusAt time.Time
73 ctx context.Context78 ctx context.Context
74 windows map[ports.WindowID]*window79 windows map[ports.WindowID]*window
75 layers map[ports.WindowID]*layerSurface80 layers map[ports.WindowID]*layerSurface
@@ -154,6 +159,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
154 s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}159 s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
155 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}160 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
156 s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{}161 s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{}
162+ s.tokens = map[string]activationToken{}
157 if opts.Keymap != "" {163 if opts.Keymap != "" {
158 fd, size, e := keymapFile(opts.Keymap)164 fd, size, e := keymapFile(opts.Keymap)
159 if e != nil {165 if e != nil {
@@ -383,7 +389,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
383 state := uint32(0)389 state := uint32(0)
384 if c.Pressed {390 if c.Pressed {
385 state = 1391 state = 1
386- s.press, s.pressClient = s.serial, w.xdg.resource.Client()392+ s.press, s.pressClient, s.pressAt = s.serial, w.xdg.resource.Client(), time.Now()
387 }393 }
388 for _, p := range s.windowPointers(w) {394 for _, p := range s.windowPointers(w) {
389 p.SendButton(s.serial, c.TimeMsec, c.Button, state)395 p.SendButton(s.serial, c.TimeMsec, c.Button, state)
@@ -417,7 +423,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
417 }423 }
418 if c.Key.Pressed {424 if c.Key.Pressed {
419 if surf, _ := s.focusTarget(c.ID); surf != nil {425 if surf, _ := s.focusTarget(c.ID); surf != nil {
420- s.press, s.pressClient = s.serial, surf.Client()426+ s.press, s.pressClient, s.pressAt = s.serial, surf.Client(), time.Now()
421 }427 }
422 }428 }
423 for _, k := range keyboards {429 for _, k := range keyboards {
@@ -568,6 +574,14 @@ func (s *Server) sendModifiers(k *wayland.Keyboard) {
568 }574 }
569 575
570 func (s *Server) changeFocus(id ports.WindowID) {576 func (s *Server) changeFocus(id ports.WindowID) {
577+ old := s.focusClient()
578+ defer func() {
579+ // Tokens of a client losing the focus die with it.
580+ if now := s.focusClient(); now != old {
581+ s.dropTokens(old)
582+ s.focusAt = time.Now()
583+ }
584+ }()
571 if surf, keyboards := s.focusTarget(s.focused); surf != nil {585 if surf, keyboards := s.focusTarget(s.focused); surf != nil {
572 for _, k := range keyboards {586 for _, k := range keyboards {
573 s.serial++587 s.serial++
M internal/core/core.go +32 -4
@@ -618,6 +618,10 @@ func (c *Core) Run(ctx context.Context) error {
618618 if s, _ := c.screenOf(v.ID); s != nil {
619619 s.mon.SetFullscreen(v.ID, v.Fullscreen)
620620 }
621+ case ports.WindowActivate:
622+ if c.activate(ctx, v.ID) != nil {
623+ return nil
624+ }
621625 }
622626 if err := c.publish(ctx); err != nil {
623627 return nil
@@ -794,10 +798,7 @@ func (c *Core) Run(ctx context.Context) error {
794798 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:
795799 }
796800 }
797- // Showing a declared workspace refills its empty slots.
798- c.releaseSlots()
799- c.settleGuests()
800- if c.spawnSlots(ctx, c.cur().mon.Current() != before) != nil {
801+ if c.afterShow(ctx, before) != nil {
801802 return nil
802803 }
803804 if effect.Close != 0 {
@@ -820,3 +821,30 @@ func (c *Core) Run(ctx context.Context) error {
820821 }
821822 }
822823 }
824+
825+// afterShow keeps slots and guests right once a workspace came on screen;
826+// before is the focused screen's workspace before the change.
827+func (c *Core) afterShow(ctx context.Context, before *Workspace) error {
828+ // Showing a declared workspace refills its empty slots.
829+ c.releaseSlots()
830+ c.settleGuests()
831+ return c.spawnSlots(ctx, c.cur().mon.Current() != before)
832+}
833+
834+// activate brings a window forward for a valid xdg-activation token. The
835+// token proves a user action in the requesting client (a click on a link,
836+// a notification), so unlike other client requests it may move the user
837+// (the exception to the ADR 011 golden rule).
838+func (c *Core) activate(ctx context.Context, id WindowID) error {
839+ s, w := c.screenOf(id)
840+ if s == nil || c.popups[id] != nil {
841+ return nil
842+ }
843+ before := c.cur().mon.Current()
844+ if w != s.mon.Current() {
845+ s.mon.show(w)
846+ }
847+ w.Activate(id)
848+ c.focusScreen = c.screenIndex(s.name())
849+ return c.afterShow(ctx, before)
850+}
@@ -618,6 +618,10 @@ func (c *Core) Run(ctx context.Context) error {
618 if s, _ := c.screenOf(v.ID); s != nil {618 if s, _ := c.screenOf(v.ID); s != nil {
619 s.mon.SetFullscreen(v.ID, v.Fullscreen)619 s.mon.SetFullscreen(v.ID, v.Fullscreen)
620 }620 }
621+ case ports.WindowActivate:
622+ if c.activate(ctx, v.ID) != nil {
623+ return nil
624+ }
621 }625 }
622 if err := c.publish(ctx); err != nil {626 if err := c.publish(ctx); err != nil {
623 return nil627 return nil
@@ -794,10 +798,7 @@ func (c *Core) Run(ctx context.Context) error {
794 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:798 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:
795 }799 }
796 }800 }
797- // Showing a declared workspace refills its empty slots.801+ if c.afterShow(ctx, before) != nil {
798- c.releaseSlots()
799- c.settleGuests()
800- if c.spawnSlots(ctx, c.cur().mon.Current() != before) != nil {
801 return nil802 return nil
802 }803 }
803 if effect.Close != 0 {804 if effect.Close != 0 {
@@ -820,3 +821,30 @@ func (c *Core) Run(ctx context.Context) error {
820 }821 }
821 }822 }
822 }823 }
824+
825+// afterShow keeps slots and guests right once a workspace came on screen;
826+// before is the focused screen's workspace before the change.
827+func (c *Core) afterShow(ctx context.Context, before *Workspace) error {
828+ // Showing a declared workspace refills its empty slots.
829+ c.releaseSlots()
830+ c.settleGuests()
831+ return c.spawnSlots(ctx, c.cur().mon.Current() != before)
832+}
833+
834+// activate brings a window forward for a valid xdg-activation token. The
835+// token proves a user action in the requesting client (a click on a link,
836+// a notification), so unlike other client requests it may move the user
837+// (the exception to the ADR 011 golden rule).
838+func (c *Core) activate(ctx context.Context, id WindowID) error {
839+ s, w := c.screenOf(id)
840+ if s == nil || c.popups[id] != nil {
841+ return nil
842+ }
843+ before := c.cur().mon.Current()
844+ if w != s.mon.Current() {
845+ s.mon.show(w)
846+ }
847+ w.Activate(id)
848+ c.focusScreen = c.screenIndex(s.name())
849+ return c.afterShow(ctx, before)
850+}
M internal/core/core_test.go +52 -0
@@ -722,3 +722,55 @@ func TestFloatOverFullscreenHit(t *testing.T) {
722722 t.Fatalf("pointer went to %v, want the dialog", v)
723723 }
724724 }
725+
726+// An activated window on another workspace comes on screen with focus.
727+func TestWindowActivateShowsAndFocuses(t *testing.T) {
728+ cfg := config.Defaults()
729+ client := make(chan ports.ClientEvent, 8)
730+ input := make(chan ports.InputEvent, 8)
731+ output := make(chan ports.OutputEvent, 8)
732+ commands := make(chan ports.ClientCommand, 64)
733+ scenes := make(chan []ports.Scene, 1)
734+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
735+ if err != nil {
736+ t.Fatal(err)
737+ }
738+ ctx, cancel := context.WithCancel(context.Background())
739+ defer cancel()
740+ go c.Run(ctx)
741+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
742+ scene(t, scenes)
743+ client <- ports.WindowMapped{ID: 1}
744+ scene(t, scenes)
745+ // Window 2 opens on workspace 2, then the user goes back to 1.
746+ input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
747+ scene(t, scenes)
748+ client <- ports.WindowMapped{ID: 2}
749+ scene(t, scenes)
750+ input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper, Pressed: true}
751+ visible := func(sc ports.Scene) (shown []ports.SceneWindow) {
752+ for _, w := range sc.Windows {
753+ if !w.Hidden {
754+ shown = append(shown, w)
755+ }
756+ }
757+ return shown
758+ }
759+ if v := visible(scene(t, scenes)); len(v) != 1 || v[0].ID != 1 {
760+ t.Fatalf("workspace 1: %+v", v)
761+ }
762+ client <- ports.WindowActivate{ID: 2}
763+ v := visible(scene(t, scenes))
764+ for len(v) != 1 || v[0].ID != 2 {
765+ v = visible(scene(t, scenes))
766+ }
767+ if !v[0].Focused {
768+ t.Fatalf("after activate: %+v", v)
769+ }
770+ for {
771+ if f, ok := command(t, commands).(ports.FocusWindow); ok && f.ID == 2 {
772+ break
773+ }
774+ }
775+
776+}
@@ -722,3 +722,55 @@ func TestFloatOverFullscreenHit(t *testing.T) {
722 t.Fatalf("pointer went to %v, want the dialog", v)722 t.Fatalf("pointer went to %v, want the dialog", v)
723 }723 }
724 }724 }
725+
726+// An activated window on another workspace comes on screen with focus.
727+func TestWindowActivateShowsAndFocuses(t *testing.T) {
728+ cfg := config.Defaults()
729+ client := make(chan ports.ClientEvent, 8)
730+ input := make(chan ports.InputEvent, 8)
731+ output := make(chan ports.OutputEvent, 8)
732+ commands := make(chan ports.ClientCommand, 64)
733+ scenes := make(chan []ports.Scene, 1)
734+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
735+ if err != nil {
736+ t.Fatal(err)
737+ }
738+ ctx, cancel := context.WithCancel(context.Background())
739+ defer cancel()
740+ go c.Run(ctx)
741+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
742+ scene(t, scenes)
743+ client <- ports.WindowMapped{ID: 1}
744+ scene(t, scenes)
745+ // Window 2 opens on workspace 2, then the user goes back to 1.
746+ input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
747+ scene(t, scenes)
748+ client <- ports.WindowMapped{ID: 2}
749+ scene(t, scenes)
750+ input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper, Pressed: true}
751+ visible := func(sc ports.Scene) (shown []ports.SceneWindow) {
752+ for _, w := range sc.Windows {
753+ if !w.Hidden {
754+ shown = append(shown, w)
755+ }
756+ }
757+ return shown
758+ }
759+ if v := visible(scene(t, scenes)); len(v) != 1 || v[0].ID != 1 {
760+ t.Fatalf("workspace 1: %+v", v)
761+ }
762+ client <- ports.WindowActivate{ID: 2}
763+ v := visible(scene(t, scenes))
764+ for len(v) != 1 || v[0].ID != 2 {
765+ v = visible(scene(t, scenes))
766+ }
767+ if !v[0].Focused {
768+ t.Fatalf("after activate: %+v", v)
769+ }
770+ for {
771+ if f, ok := command(t, commands).(ports.FocusWindow); ok && f.ID == 2 {
772+ break
773+ }
774+ }
775+
776+}
M internal/core/layout.go +9 -0
@@ -441,6 +441,15 @@ func (w *Workspace) ToggleFullscreen() {
441441 w.scroll()
442442 }
443443
444+// Activate focuses a window and makes it visible: a tiled window leaves
445+// another window's fullscreen, which would hide it.
446+func (w *Workspace) Activate(id WindowID) {
447+ if w.fullscreen != 0 && w.fullscreen != id && w.floatIndex(id) < 0 {
448+ w.fullscreen = 0
449+ }
450+ w.FocusID(id)
451+}
452+
444453 // SetFullscreen applies a client request. It never moves focus: client
445454 // requests are automatic events (ADR 011 golden rule).
446455 func (w *Workspace) SetFullscreen(id WindowID, on bool) {
@@ -441,6 +441,15 @@ func (w *Workspace) ToggleFullscreen() {
441 w.scroll()441 w.scroll()
442 }442 }
443 443
444+// Activate focuses a window and makes it visible: a tiled window leaves
445+// another window's fullscreen, which would hide it.
446+func (w *Workspace) Activate(id WindowID) {
447+ if w.fullscreen != 0 && w.fullscreen != id && w.floatIndex(id) < 0 {
448+ w.fullscreen = 0
449+ }
450+ w.FocusID(id)
451+}
452+
444 // SetFullscreen applies a client request. It never moves focus: client453 // SetFullscreen applies a client request. It never moves focus: client
445 // requests are automatic events (ADR 011 golden rule).454 // requests are automatic events (ADR 011 golden rule).
446 func (w *Workspace) SetFullscreen(id WindowID, on bool) {455 func (w *Workspace) SetFullscreen(id WindowID, on bool) {
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+}
M internal/ports/messages.go +6 -0
@@ -39,6 +39,12 @@ type WindowFullscreenRequest struct {
3939
4040 func (WindowFullscreenRequest) clientEvent() {}
4141
42+// WindowActivate carries wayland → core a valid xdg-activation request:
43+// show the window's workspace and focus it.
44+type WindowActivate struct{ ID WindowID }
45+
46+func (WindowActivate) clientEvent() {}
47+
4248 // WindowAppID carries wayland → core an app ID set after the window mapped.
4349 type WindowAppID struct {
4450 ID WindowID
@@ -39,6 +39,12 @@ type WindowFullscreenRequest struct {
39 39
40 func (WindowFullscreenRequest) clientEvent() {}40 func (WindowFullscreenRequest) clientEvent() {}
41 41
42+// WindowActivate carries wayland → core a valid xdg-activation request:
43+// show the window's workspace and focus it.
44+type WindowActivate struct{ ID WindowID }
45+
46+func (WindowActivate) clientEvent() {}
47+
42 // WindowAppID carries wayland → core an app ID set after the window mapped.48 // WindowAppID carries wayland → core an app ID set after the window mapped.
43 type WindowAppID struct {49 type WindowAppID struct {
44 ID WindowID50 ID WindowID

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 26

Tags 10

↑↓ moveenter openesc close