detached 9e3a591

fix(activation): fresh press serials only, tokens die with focus, bounded map, activation leaves fullscreen

commit
9e3a5914b0ba47f0dd5c1e67bd09d858304eda2c
author
brice · · 2026-09-25 19:30 UTC
parent
cf943d9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +216 -31
M internal/adapters/wayland/activation.go +49 -14
@@ -16,13 +16,18 @@ import (
1616 // bring its window forward with it. Other tokens are issued but inert, so
1717 // a background app cannot steal the focus.
1818
19-// tokenLifetime is how long a token can activate a window.
19+// tokenLifetime is how long a token can activate a window, and how recent
20+// a press must be to earn one.
2021 const tokenLifetime = 10 * time.Second
2122
22-// activationToken is an issued token; valid ones may activate a window.
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 user moves the focus away from it.
2328 type activationToken struct {
24- valid bool
25- at time.Time
29+ client server.Client
30+ at time.Time
2631 }
2732
2833 func registerActivation(d *server.Display, s *Server) error {
@@ -46,12 +51,23 @@ func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf *
4651 t, ok := s.tokens[token]
4752 delete(s.tokens, token)
4853 state := s.surfaceOf(surf)
49- if !ok || !t.valid || time.Since(t.at) > tokenLifetime || state == nil {
50- s.log.Debug().Bool("known", ok).Msg("activation refused")
51- return
54+ reason := ""
55+ switch {
56+ case !ok:
57+ reason = "unknown_token"
58+ case time.Since(t.at) > tokenLifetime:
59+ reason = "expired"
60+ case state == nil:
61+ reason = "no_surface"
62+ }
63+ var w *window
64+ if reason == "" {
65+ if w = s.windows[state.root().windowID()]; w == nil || w.toplevel == nil || !w.mapped {
66+ reason = "not_toplevel"
67+ }
5268 }
53- w := s.windows[state.root().windowID()]
54- if w == nil || w.toplevel == nil || !w.mapped {
69+ if reason != "" {
70+ s.log.Debug().Str("reason", reason).Msg("activation refused")
5571 return
5672 }
5773 s.log.Info().Uint64("id", uint64(w.id)).Msg("activation")
@@ -74,7 +90,8 @@ func (*tokenRequest) SetSurface(*xdgactivation.ActivationTokenV1, *wayland.Surfa
7490 func (*tokenRequest) Destroy(*xdgactivation.ActivationTokenV1) {}
7591
7692 // Commit issues the token. It is valid when the requesting client has the
77-// keyboard focus or made the last press it names by serial.
93+// keyboard focus, or made the last press, recent and not followed by a
94+// focus change, that it names by serial. Invalid tokens are not kept.
7895 func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
7996 if t.used {
8097 r.PostError(uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed), "token already committed")
@@ -83,10 +100,13 @@ func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
83100 t.used = true
84101 s := t.server
85102 c := r.Client()
86- valid := s.focusClient() == c || (t.hasSerial && t.serial == s.press && s.pressClient == c)
87- s.pruneTokens()
103+ fresh := time.Since(s.pressAt) <= tokenLifetime && !s.pressAt.Before(s.focusAt)
104+ valid := s.focusClient() == c || (t.hasSerial && t.serial == s.press && s.pressClient == c && fresh)
88105 token := rand.Text()
89- s.tokens[token] = activationToken{valid: valid, at: time.Now()}
106+ if valid {
107+ s.pruneTokens()
108+ s.tokens[token] = activationToken{client: c, at: time.Now()}
109+ }
90110 r.SendDone(token)
91111 }
92112
@@ -101,11 +121,26 @@ func (s *Server) focusClient() server.Client {
101121 return server.Client{}
102122 }
103123
104-// pruneTokens forgets expired tokens.
124+// pruneTokens forgets expired tokens and, past maxTokens, the oldest.
105125 func (s *Server) pruneTokens() {
126+ oldest := ""
106127 for k, t := range s.tokens {
107128 if time.Since(t.at) > tokenLifetime {
108129 delete(s.tokens, k)
130+ } else if oldest == "" || t.at.Before(s.tokens[oldest].at) {
131+ oldest = k
132+ }
133+ }
134+ if len(s.tokens) >= maxTokens {
135+ delete(s.tokens, oldest)
136+ }
137+}
138+
139+// dropTokens forgets the tokens of a client that lost the focus.
140+func (s *Server) dropTokens(c server.Client) {
141+ for k, t := range s.tokens {
142+ if t.client == c {
143+ delete(s.tokens, k)
109144 }
110145 }
111146 }
@@ -16,13 +16,18 @@ import (
16 // bring its window forward with it. Other tokens are issued but inert, so16 // bring its window forward with it. Other tokens are issued but inert, so
17 // a background app cannot steal the focus.17 // a background app cannot steal the focus.
18 18
19-// tokenLifetime is how long a token can activate a window.19+// tokenLifetime is how long a token can activate a window, and how recent
20+// a press must be to earn one.
20 const tokenLifetime = 10 * time.Second21 const tokenLifetime = 10 * time.Second
21 22
22-// activationToken is an issued token; valid ones may activate a window.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 user moves the focus away from it.
23 type activationToken struct {28 type activationToken struct {
24- valid bool29+ client server.Client
25- at time.Time30+ at time.Time
26 }31 }
27 32
28 func registerActivation(d *server.Display, s *Server) error {33 func registerActivation(d *server.Display, s *Server) error {
@@ -46,12 +51,23 @@ func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf *
46 t, ok := s.tokens[token]51 t, ok := s.tokens[token]
47 delete(s.tokens, token)52 delete(s.tokens, token)
48 state := s.surfaceOf(surf)53 state := s.surfaceOf(surf)
49- if !ok || !t.valid || time.Since(t.at) > tokenLifetime || state == nil {54+ reason := ""
50- s.log.Debug().Bool("known", ok).Msg("activation refused")55+ switch {
51- return56+ case !ok:
57+ reason = "unknown_token"
58+ case time.Since(t.at) > tokenLifetime:
59+ reason = "expired"
60+ case state == nil:
61+ reason = "no_surface"
62+ }
63+ var w *window
64+ if reason == "" {
65+ if w = s.windows[state.root().windowID()]; w == nil || w.toplevel == nil || !w.mapped {
66+ reason = "not_toplevel"
67+ }
52 }68 }
53- w := s.windows[state.root().windowID()]69+ if reason != "" {
54- if w == nil || w.toplevel == nil || !w.mapped {70+ s.log.Debug().Str("reason", reason).Msg("activation refused")
55 return71 return
56 }72 }
57 s.log.Info().Uint64("id", uint64(w.id)).Msg("activation")73 s.log.Info().Uint64("id", uint64(w.id)).Msg("activation")
@@ -74,7 +90,8 @@ func (*tokenRequest) SetSurface(*xdgactivation.ActivationTokenV1, *wayland.Surfa
74 func (*tokenRequest) Destroy(*xdgactivation.ActivationTokenV1) {}90 func (*tokenRequest) Destroy(*xdgactivation.ActivationTokenV1) {}
75 91
76 // Commit issues the token. It is valid when the requesting client has the92 // Commit issues the token. It is valid when the requesting client has the
77-// keyboard focus or made the last press it names by serial.93+// keyboard focus, or made the last press, recent and not followed by a
94+// focus change, that it names by serial. Invalid tokens are not kept.
78 func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {95 func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
79 if t.used {96 if t.used {
80 r.PostError(uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed), "token already committed")97 r.PostError(uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed), "token already committed")
@@ -83,10 +100,13 @@ func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
83 t.used = true100 t.used = true
84 s := t.server101 s := t.server
85 c := r.Client()102 c := r.Client()
86- valid := s.focusClient() == c || (t.hasSerial && t.serial == s.press && s.pressClient == c)103+ fresh := time.Since(s.pressAt) <= tokenLifetime && !s.pressAt.Before(s.focusAt)
87- s.pruneTokens()104+ valid := s.focusClient() == c || (t.hasSerial && t.serial == s.press && s.pressClient == c && fresh)
88 token := rand.Text()105 token := rand.Text()
89- s.tokens[token] = activationToken{valid: valid, at: time.Now()}106+ if valid {
107+ s.pruneTokens()
108+ s.tokens[token] = activationToken{client: c, at: time.Now()}
109+ }
90 r.SendDone(token)110 r.SendDone(token)
91 }111 }
92 112
@@ -101,11 +121,26 @@ func (s *Server) focusClient() server.Client {
101 return server.Client{}121 return server.Client{}
102 }122 }
103 123
104-// pruneTokens forgets expired tokens.124+// pruneTokens forgets expired tokens and, past maxTokens, the oldest.
105 func (s *Server) pruneTokens() {125 func (s *Server) pruneTokens() {
126+ oldest := ""
106 for k, t := range s.tokens {127 for k, t := range s.tokens {
107 if time.Since(t.at) > tokenLifetime {128 if time.Since(t.at) > tokenLifetime {
108 delete(s.tokens, k)129 delete(s.tokens, k)
130+ } else if oldest == "" || t.at.Before(s.tokens[oldest].at) {
131+ oldest = k
132+ }
133+ }
134+ if len(s.tokens) >= maxTokens {
135+ delete(s.tokens, oldest)
136+ }
137+}
138+
139+// dropTokens forgets the tokens of a client that lost the focus.
140+func (s *Server) dropTokens(c server.Client) {
141+ for k, t := range s.tokens {
142+ if t.client == c {
143+ delete(s.tokens, k)
109 }144 }
110 }145 }
111 }146 }
M internal/adapters/wayland/activation_test.go +91 -2
@@ -17,14 +17,18 @@ type tokenProxy struct {
1717
1818 func (p *tokenProxy) Dispatch(e *wlturbo.Event) { p.tokens <- e.String() }
1919
20-// newToken commits a token request and returns the token.
21-func newToken(t *testing.T, c *wlturbo.Display, manager uint32) string {
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 {
2223 t.Helper()
2324 id := c.AllocateID()
2425 p := &tokenProxy{tokens: make(chan string, 1)}
2526 p.SetID(id)
2627 c.Context().Register(p)
2728 requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id)
29+ if len(serialSeat) == 2 {
30+ requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestSetSerial, serialSeat[0], serialSeat[1])
31+ }
2832 requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
2933 for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
3034 if err := c.Roundtrip(); err != nil {
@@ -104,3 +108,88 @@ func TestActivationNeedsFocusedRequester(t *testing.T) {
104108 requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, "not-a-token", surf)
105109 noActivation(t, c, events)
106110 }
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+ if err := b.Roundtrip(); err != nil {
137+ t.Fatal(err)
138+ }
139+ time.Sleep(20 * time.Millisecond)
140+ requestProtocol(t, b, managerB, xdgactivation.ActivationV1RequestActivate, token, surfB)
141+ noActivation(t, b, events)
142+}
143+
144+// A press serial earns a token only while the press is the latest user
145+// action: after a focus change it is stale.
146+func TestActivationPressSerial(t *testing.T) {
147+ s, events, commands, dir := lifecycleServer(t)
148+ c := protocolClient(t, s, dir)
149+ w, surf, _ := surfaceMapper(t, c, events)()
150+ manager := bindProtocol(t, c, "xdg_activation_v1")
151+ seat := bindProtocol(t, c, "wl_seat")
152+ registerProtocol(t, c, seat)
153+ press := func() uint32 {
154+ var serial uint32
155+ done := make(chan struct{})
156+ s.display.Do(func() {
157+ s.serial++
158+ s.press, s.pressClient, s.pressAt = s.serial, s.windows[w.ID].xdg.resource.Client(), time.Now()
159+ serial = s.serial
160+ close(done)
161+ })
162+ <-done
163+ return serial
164+ }
165+ serial := press()
166+ token := newToken(t, c, manager, serial, seat)
167+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
168+ activated(t, events, w.ID)
169+ // A wrong serial earns nothing.
170+ token = newToken(t, c, manager, serial+100, seat)
171+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
172+ noActivation(t, c, events)
173+ // The focus moved after the press: stale.
174+ commands <- ports.FocusWindow{ID: w.ID}
175+ commands <- ports.FocusWindow{}
176+ if err := c.Roundtrip(); err != nil {
177+ t.Fatal(err)
178+ }
179+ time.Sleep(20 * time.Millisecond)
180+ token = newToken(t, c, manager, serial, seat)
181+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
182+ noActivation(t, c, events)
183+}
184+
185+func TestActivationTokenCommittedTwice(t *testing.T) {
186+ s, _, _, dir := lifecycleServer(t)
187+ c := protocolClient(t, s, dir)
188+ manager := bindProtocol(t, c, "xdg_activation_v1")
189+ id := c.AllocateID()
190+ registerProtocol(t, c, id)
191+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id)
192+ requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
193+ requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
194+ expectProtocolError(t, c, id, uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed))
195+}
@@ -17,14 +17,18 @@ type tokenProxy struct {
17 17
18 func (p *tokenProxy) Dispatch(e *wlturbo.Event) { p.tokens <- e.String() }18 func (p *tokenProxy) Dispatch(e *wlturbo.Event) { p.tokens <- e.String() }
19 19
20-// newToken commits a token request and returns the token.20+// newToken commits a token request, with a serial and seat when given,
21-func newToken(t *testing.T, c *wlturbo.Display, manager uint32) string {21+// and returns the token.
22+func newToken(t *testing.T, c *wlturbo.Display, manager uint32, serialSeat ...uint32) string {
22 t.Helper()23 t.Helper()
23 id := c.AllocateID()24 id := c.AllocateID()
24 p := &tokenProxy{tokens: make(chan string, 1)}25 p := &tokenProxy{tokens: make(chan string, 1)}
25 p.SetID(id)26 p.SetID(id)
26 c.Context().Register(p)27 c.Context().Register(p)
27 requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id)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+ }
28 requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)32 requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
29 for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {33 for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
30 if err := c.Roundtrip(); err != nil {34 if err := c.Roundtrip(); err != nil {
@@ -104,3 +108,88 @@ func TestActivationNeedsFocusedRequester(t *testing.T) {
104 requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, "not-a-token", surf)108 requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, "not-a-token", surf)
105 noActivation(t, c, events)109 noActivation(t, c, events)
106 }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+ if err := b.Roundtrip(); err != nil {
137+ t.Fatal(err)
138+ }
139+ time.Sleep(20 * time.Millisecond)
140+ requestProtocol(t, b, managerB, xdgactivation.ActivationV1RequestActivate, token, surfB)
141+ noActivation(t, b, events)
142+}
143+
144+// A press serial earns a token only while the press is the latest user
145+// action: after a focus change it is stale.
146+func TestActivationPressSerial(t *testing.T) {
147+ s, events, commands, dir := lifecycleServer(t)
148+ c := protocolClient(t, s, dir)
149+ w, surf, _ := surfaceMapper(t, c, events)()
150+ manager := bindProtocol(t, c, "xdg_activation_v1")
151+ seat := bindProtocol(t, c, "wl_seat")
152+ registerProtocol(t, c, seat)
153+ press := func() uint32 {
154+ var serial uint32
155+ done := make(chan struct{})
156+ s.display.Do(func() {
157+ s.serial++
158+ s.press, s.pressClient, s.pressAt = s.serial, s.windows[w.ID].xdg.resource.Client(), time.Now()
159+ serial = s.serial
160+ close(done)
161+ })
162+ <-done
163+ return serial
164+ }
165+ serial := press()
166+ token := newToken(t, c, manager, serial, seat)
167+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
168+ activated(t, events, w.ID)
169+ // A wrong serial earns nothing.
170+ token = newToken(t, c, manager, serial+100, seat)
171+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
172+ noActivation(t, c, events)
173+ // The focus moved after the press: stale.
174+ commands <- ports.FocusWindow{ID: w.ID}
175+ commands <- ports.FocusWindow{}
176+ if err := c.Roundtrip(); err != nil {
177+ t.Fatal(err)
178+ }
179+ time.Sleep(20 * time.Millisecond)
180+ token = newToken(t, c, manager, serial, seat)
181+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
182+ noActivation(t, c, events)
183+}
184+
185+func TestActivationTokenCommittedTwice(t *testing.T) {
186+ s, _, _, dir := lifecycleServer(t)
187+ c := protocolClient(t, s, dir)
188+ manager := bindProtocol(t, c, "xdg_activation_v1")
189+ id := c.AllocateID()
190+ registerProtocol(t, c, id)
191+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id)
192+ requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
193+ requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
194+ expectProtocolError(t, c, id, uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed))
195+}
M internal/adapters/wayland/server.go +15 -4
@@ -70,8 +70,11 @@ type Server struct {
7070 serial uint32
7171 // press is the serial of the last button or key press, sent to
7272 // pressClient: popup grabs must come from it.
73- press uint32
74- 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
7578 ctx context.Context
7679 windows map[ports.WindowID]*window
7780 layers map[ports.WindowID]*layerSurface
@@ -386,7 +389,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
386389 state := uint32(0)
387390 if c.Pressed {
388391 state = 1
389- 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()
390393 }
391394 for _, p := range s.windowPointers(w) {
392395 p.SendButton(s.serial, c.TimeMsec, c.Button, state)
@@ -420,7 +423,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
420423 }
421424 if c.Key.Pressed {
422425 if surf, _ := s.focusTarget(c.ID); surf != nil {
423- s.press, s.pressClient = s.serial, surf.Client()
426+ s.press, s.pressClient, s.pressAt = s.serial, surf.Client(), time.Now()
424427 }
425428 }
426429 for _, k := range keyboards {
@@ -571,6 +574,14 @@ func (s *Server) sendModifiers(k *wayland.Keyboard) {
571574 }
572575
573576 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+ }()
574585 if surf, keyboards := s.focusTarget(s.focused); surf != nil {
575586 for _, k := range keyboards {
576587 s.serial++
@@ -70,8 +70,11 @@ type Server struct {
70 serial uint3270 serial uint32
71 // 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
72 // pressClient: popup grabs must come from it.72 // pressClient: popup grabs must come from it.
73- press uint3273+ press uint32
74- 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
75 ctx context.Context78 ctx context.Context
76 windows map[ports.WindowID]*window79 windows map[ports.WindowID]*window
77 layers map[ports.WindowID]*layerSurface80 layers map[ports.WindowID]*layerSurface
@@ -386,7 +389,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
386 state := uint32(0)389 state := uint32(0)
387 if c.Pressed {390 if c.Pressed {
388 state = 1391 state = 1
389- 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()
390 }393 }
391 for _, p := range s.windowPointers(w) {394 for _, p := range s.windowPointers(w) {
392 p.SendButton(s.serial, c.TimeMsec, c.Button, state)395 p.SendButton(s.serial, c.TimeMsec, c.Button, state)
@@ -420,7 +423,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
420 }423 }
421 if c.Key.Pressed {424 if c.Key.Pressed {
422 if surf, _ := s.focusTarget(c.ID); surf != nil {425 if surf, _ := s.focusTarget(c.ID); surf != nil {
423- s.press, s.pressClient = s.serial, surf.Client()426+ s.press, s.pressClient, s.pressAt = s.serial, surf.Client(), time.Now()
424 }427 }
425 }428 }
426 for _, k := range keyboards {429 for _, k := range keyboards {
@@ -571,6 +574,14 @@ func (s *Server) sendModifiers(k *wayland.Keyboard) {
571 }574 }
572 575
573 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+ }()
574 if surf, keyboards := s.focusTarget(s.focused); surf != nil {585 if surf, keyboards := s.focusTarget(s.focused); surf != nil {
575 for _, k := range keyboards {586 for _, k := range keyboards {
576 s.serial++587 s.serial++
M internal/core/core.go +30 -11
@@ -619,13 +619,8 @@ func (c *Core) Run(ctx context.Context) error {
619619 s.mon.SetFullscreen(v.ID, v.Fullscreen)
620620 }
621621 case ports.WindowActivate:
622- // Wayland checked the token: bring the window forward.
623- if s, w := c.screenOf(v.ID); s != nil && c.popups[v.ID] == nil {
624- if w != s.mon.Current() {
625- s.mon.show(w)
626- }
627- w.FocusID(v.ID)
628- c.focusScreen = c.screenIndex(s.name())
622+ if c.activate(ctx, v.ID) != nil {
623+ return nil
629624 }
630625 }
631626 if err := c.publish(ctx); err != nil {
@@ -803,10 +798,7 @@ func (c *Core) Run(ctx context.Context) error {
803798 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:
804799 }
805800 }
806- // Showing a declared workspace refills its empty slots.
807- c.releaseSlots()
808- c.settleGuests()
809- if c.spawnSlots(ctx, c.cur().mon.Current() != before) != nil {
801+ if c.afterShow(ctx, before) != nil {
810802 return nil
811803 }
812804 if effect.Close != 0 {
@@ -829,3 +821,30 @@ func (c *Core) Run(ctx context.Context) error {
829821 }
830822 }
831823 }
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+}
@@ -619,13 +619,8 @@ func (c *Core) Run(ctx context.Context) error {
619 s.mon.SetFullscreen(v.ID, v.Fullscreen)619 s.mon.SetFullscreen(v.ID, v.Fullscreen)
620 }620 }
621 case ports.WindowActivate:621 case ports.WindowActivate:
622- // Wayland checked the token: bring the window forward.622+ if c.activate(ctx, v.ID) != nil {
623- if s, w := c.screenOf(v.ID); s != nil && c.popups[v.ID] == nil {623+ return nil
624- if w != s.mon.Current() {
625- s.mon.show(w)
626- }
627- w.FocusID(v.ID)
628- c.focusScreen = c.screenIndex(s.name())
629 }624 }
630 }625 }
631 if err := c.publish(ctx); err != nil {626 if err := c.publish(ctx); err != nil {
@@ -803,10 +798,7 @@ func (c *Core) Run(ctx context.Context) error {
803 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:798 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:
804 }799 }
805 }800 }
806- // Showing a declared workspace refills its empty slots.801+ if c.afterShow(ctx, before) != nil {
807- c.releaseSlots()
808- c.settleGuests()
809- if c.spawnSlots(ctx, c.cur().mon.Current() != before) != nil {
810 return nil802 return nil
811 }803 }
812 if effect.Close != 0 {804 if effect.Close != 0 {
@@ -829,3 +821,30 @@ func (c *Core) Run(ctx context.Context) error {
829 }821 }
830 }822 }
831 }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 +22 -0
@@ -6,6 +6,7 @@ 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"
910 "testing"
1011 "time"
1112 )
@@ -767,4 +768,25 @@ func TestWindowActivateShowsAndFocuses(t *testing.T) {
767768 if !v[0].Focused {
768769 t.Fatalf("after activate: %+v", v)
769770 }
771+ for {
772+ if f, ok := command(t, commands).(ports.FocusWindow); ok && f.ID == 2 {
773+ break
774+ }
775+ }
776+
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+ }
770792 }
@@ -6,6 +6,7 @@ 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"
9 "testing"10 "testing"
10 "time"11 "time"
11 )12 )
@@ -767,4 +768,25 @@ func TestWindowActivateShowsAndFocuses(t *testing.T) {
767 if !v[0].Focused {768 if !v[0].Focused {
768 t.Fatalf("after activate: %+v", v)769 t.Fatalf("after activate: %+v", v)
769 }770 }
771+ for {
772+ if f, ok := command(t, commands).(ports.FocusWindow); ok && f.ID == 2 {
773+ break
774+ }
775+ }
776+
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+ }
770 }792 }
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) {

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