detached cf943d9

feat(wayland): xdg-activation with focus-checked tokens

commit
cf943d9aa51e5f0744d3037d7fec72f5ee29d3f8
author
brice · · 2026-09-25 19:21 UTC
parent
856d644
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +286 -4
A internal/adapters/wayland/activation.go +111 -0
@@ -0,0 +1,111 @@
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.
20+const tokenLifetime = 10 * time.Second
21+
22+// activationToken is an issued token; valid ones may activate a window.
23+type activationToken struct {
24+ valid bool
25+ at time.Time
26+}
27+
28+func registerActivation(d *server.Display, s *Server) error {
29+ return xdgactivation.NewActivationV1Global(d, 1, func(c server.Client, v, id uint32) {
30+ _, _ = xdgactivation.NewActivationV1(c, int32(v), id, activation{s})
31+ })
32+}
33+
34+type activation struct{ server *Server }
35+
36+func (activation) Destroy(*xdgactivation.ActivationV1) {}
37+
38+func (a activation) GetActivationToken(r *xdgactivation.ActivationV1, id uint32) {
39+ _, _ = xdgactivation.NewActivationTokenV1(r.Client(), r.Version(), id, &tokenRequest{server: a.server})
40+}
41+
42+// Activate focuses the toplevel of surface when the token is valid. The
43+// token is used up either way.
44+func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf *wayland.Surface) {
45+ s := a.server
46+ t, ok := s.tokens[token]
47+ delete(s.tokens, token)
48+ 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
52+ }
53+ w := s.windows[state.root().windowID()]
54+ if w == nil || w.toplevel == nil || !w.mapped {
55+ return
56+ }
57+ s.log.Info().Uint64("id", uint64(w.id)).Msg("activation")
58+ s.emit(ports.WindowActivate{ID: w.id})
59+}
60+
61+// tokenRequest collects a token's serial and surface until commit.
62+type tokenRequest struct {
63+ server *Server
64+ serial uint32
65+ hasSerial bool
66+ used bool
67+}
68+
69+func (t *tokenRequest) SetSerial(_ *xdgactivation.ActivationTokenV1, serial uint32, _ *wayland.Seat) {
70+ t.serial, t.hasSerial = serial, true
71+}
72+func (*tokenRequest) SetAppId(*xdgactivation.ActivationTokenV1, string) {}
73+func (*tokenRequest) SetSurface(*xdgactivation.ActivationTokenV1, *wayland.Surface) {}
74+func (*tokenRequest) Destroy(*xdgactivation.ActivationTokenV1) {}
75+
76+// 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.
78+func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
79+ if t.used {
80+ r.PostError(uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed), "token already committed")
81+ return
82+ }
83+ t.used = true
84+ s := t.server
85+ c := r.Client()
86+ valid := s.focusClient() == c || (t.hasSerial && t.serial == s.press && s.pressClient == c)
87+ s.pruneTokens()
88+ token := rand.Text()
89+ s.tokens[token] = activationToken{valid: valid, at: time.Now()}
90+ r.SendDone(token)
91+}
92+
93+// focusClient is the client with the keyboard focus.
94+func (s *Server) focusClient() server.Client {
95+ if w := s.windows[s.focused]; w != nil && w.xdg.resource.Resource.Alive() {
96+ return w.xdg.resource.Client()
97+ }
98+ if l := s.layers[s.focused]; l != nil && l.resource.Resource.Alive() {
99+ return l.resource.Client()
100+ }
101+ return server.Client{}
102+}
103+
104+// pruneTokens forgets expired tokens.
105+func (s *Server) pruneTokens() {
106+ for k, t := range s.tokens {
107+ if time.Since(t.at) > tokenLifetime {
108+ delete(s.tokens, k)
109+ }
110+ }
111+}
@@ -0,0 +1,111 @@
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.
20+const tokenLifetime = 10 * time.Second
21+
22+// activationToken is an issued token; valid ones may activate a window.
23+type activationToken struct {
24+ valid bool
25+ at time.Time
26+}
27+
28+func registerActivation(d *server.Display, s *Server) error {
29+ return xdgactivation.NewActivationV1Global(d, 1, func(c server.Client, v, id uint32) {
30+ _, _ = xdgactivation.NewActivationV1(c, int32(v), id, activation{s})
31+ })
32+}
33+
34+type activation struct{ server *Server }
35+
36+func (activation) Destroy(*xdgactivation.ActivationV1) {}
37+
38+func (a activation) GetActivationToken(r *xdgactivation.ActivationV1, id uint32) {
39+ _, _ = xdgactivation.NewActivationTokenV1(r.Client(), r.Version(), id, &tokenRequest{server: a.server})
40+}
41+
42+// Activate focuses the toplevel of surface when the token is valid. The
43+// token is used up either way.
44+func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf *wayland.Surface) {
45+ s := a.server
46+ t, ok := s.tokens[token]
47+ delete(s.tokens, token)
48+ 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
52+ }
53+ w := s.windows[state.root().windowID()]
54+ if w == nil || w.toplevel == nil || !w.mapped {
55+ return
56+ }
57+ s.log.Info().Uint64("id", uint64(w.id)).Msg("activation")
58+ s.emit(ports.WindowActivate{ID: w.id})
59+}
60+
61+// tokenRequest collects a token's serial and surface until commit.
62+type tokenRequest struct {
63+ server *Server
64+ serial uint32
65+ hasSerial bool
66+ used bool
67+}
68+
69+func (t *tokenRequest) SetSerial(_ *xdgactivation.ActivationTokenV1, serial uint32, _ *wayland.Seat) {
70+ t.serial, t.hasSerial = serial, true
71+}
72+func (*tokenRequest) SetAppId(*xdgactivation.ActivationTokenV1, string) {}
73+func (*tokenRequest) SetSurface(*xdgactivation.ActivationTokenV1, *wayland.Surface) {}
74+func (*tokenRequest) Destroy(*xdgactivation.ActivationTokenV1) {}
75+
76+// 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.
78+func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
79+ if t.used {
80+ r.PostError(uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed), "token already committed")
81+ return
82+ }
83+ t.used = true
84+ s := t.server
85+ c := r.Client()
86+ valid := s.focusClient() == c || (t.hasSerial && t.serial == s.press && s.pressClient == c)
87+ s.pruneTokens()
88+ token := rand.Text()
89+ s.tokens[token] = activationToken{valid: valid, at: time.Now()}
90+ r.SendDone(token)
91+}
92+
93+// focusClient is the client with the keyboard focus.
94+func (s *Server) focusClient() server.Client {
95+ if w := s.windows[s.focused]; w != nil && w.xdg.resource.Resource.Alive() {
96+ return w.xdg.resource.Client()
97+ }
98+ if l := s.layers[s.focused]; l != nil && l.resource.Resource.Alive() {
99+ return l.resource.Client()
100+ }
101+ return server.Client{}
102+}
103+
104+// pruneTokens forgets expired tokens.
105+func (s *Server) pruneTokens() {
106+ for k, t := range s.tokens {
107+ if time.Since(t.at) > tokenLifetime {
108+ delete(s.tokens, k)
109+ }
110+ }
111+}
A internal/adapters/wayland/activation_test.go +106 -0
@@ -0,0 +1,106 @@
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 and returns the token.
21+func newToken(t *testing.T, c *wlturbo.Display, manager uint32) string {
22+ t.Helper()
23+ id := c.AllocateID()
24+ p := &tokenProxy{tokens: make(chan string, 1)}
25+ p.SetID(id)
26+ c.Context().Register(p)
27+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id)
28+ requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
29+ for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
30+ if err := c.Roundtrip(); err != nil {
31+ t.Fatal(err)
32+ }
33+ select {
34+ case tok := <-p.tokens:
35+ return tok
36+ default:
37+ }
38+ }
39+ t.Fatal("no token")
40+ return ""
41+}
42+
43+func activated(t *testing.T, events <-chan ports.ClientEvent, want ports.WindowID) {
44+ t.Helper()
45+ deadline := time.After(2 * time.Second)
46+ for {
47+ select {
48+ case ev := <-events:
49+ if a, ok := ev.(ports.WindowActivate); ok {
50+ if a.ID != want {
51+ t.Fatalf("activated %d, want %d", a.ID, want)
52+ }
53+ return
54+ }
55+ case <-deadline:
56+ t.Fatal("no activation")
57+ }
58+ }
59+}
60+
61+func noActivation(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) {
62+ t.Helper()
63+ if err := c.Roundtrip(); err != nil {
64+ t.Fatal(err)
65+ }
66+ deadline := time.After(100 * time.Millisecond)
67+ for {
68+ select {
69+ case ev := <-events:
70+ if _, ok := ev.(ports.WindowActivate); ok {
71+ t.Fatal("activated with an invalid token")
72+ }
73+ case <-deadline:
74+ return
75+ }
76+ }
77+}
78+
79+func TestActivationNeedsFocusedRequester(t *testing.T) {
80+ s, events, commands, dir := lifecycleServer(t)
81+ c := protocolClient(t, s, dir)
82+ mapWindow := surfaceMapper(t, c, events)
83+ first, _, _ := mapWindow()
84+ second, surf, _ := mapWindow()
85+ manager := bindProtocol(t, c, "xdg_activation_v1")
86+
87+ // Without focus the token is inert.
88+ token := newToken(t, c, manager)
89+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
90+ noActivation(t, c, events)
91+
92+ // The focused client's token activates, once.
93+ commands <- ports.FocusWindow{ID: first.ID}
94+ if err := c.Roundtrip(); err != nil {
95+ t.Fatal(err)
96+ }
97+ token = newToken(t, c, manager)
98+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
99+ activated(t, events, second.ID)
100+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
101+ noActivation(t, c, events)
102+
103+ // Unknown tokens are ignored.
104+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, "not-a-token", surf)
105+ noActivation(t, c, events)
106+}
@@ -0,0 +1,106 @@
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 and returns the token.
21+func newToken(t *testing.T, c *wlturbo.Display, manager uint32) string {
22+ t.Helper()
23+ id := c.AllocateID()
24+ p := &tokenProxy{tokens: make(chan string, 1)}
25+ p.SetID(id)
26+ c.Context().Register(p)
27+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id)
28+ requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit)
29+ for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
30+ if err := c.Roundtrip(); err != nil {
31+ t.Fatal(err)
32+ }
33+ select {
34+ case tok := <-p.tokens:
35+ return tok
36+ default:
37+ }
38+ }
39+ t.Fatal("no token")
40+ return ""
41+}
42+
43+func activated(t *testing.T, events <-chan ports.ClientEvent, want ports.WindowID) {
44+ t.Helper()
45+ deadline := time.After(2 * time.Second)
46+ for {
47+ select {
48+ case ev := <-events:
49+ if a, ok := ev.(ports.WindowActivate); ok {
50+ if a.ID != want {
51+ t.Fatalf("activated %d, want %d", a.ID, want)
52+ }
53+ return
54+ }
55+ case <-deadline:
56+ t.Fatal("no activation")
57+ }
58+ }
59+}
60+
61+func noActivation(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) {
62+ t.Helper()
63+ if err := c.Roundtrip(); err != nil {
64+ t.Fatal(err)
65+ }
66+ deadline := time.After(100 * time.Millisecond)
67+ for {
68+ select {
69+ case ev := <-events:
70+ if _, ok := ev.(ports.WindowActivate); ok {
71+ t.Fatal("activated with an invalid token")
72+ }
73+ case <-deadline:
74+ return
75+ }
76+ }
77+}
78+
79+func TestActivationNeedsFocusedRequester(t *testing.T) {
80+ s, events, commands, dir := lifecycleServer(t)
81+ c := protocolClient(t, s, dir)
82+ mapWindow := surfaceMapper(t, c, events)
83+ first, _, _ := mapWindow()
84+ second, surf, _ := mapWindow()
85+ manager := bindProtocol(t, c, "xdg_activation_v1")
86+
87+ // Without focus the token is inert.
88+ token := newToken(t, c, manager)
89+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
90+ noActivation(t, c, events)
91+
92+ // The focused client's token activates, once.
93+ commands <- ports.FocusWindow{ID: first.ID}
94+ if err := c.Roundtrip(); err != nil {
95+ t.Fatal(err)
96+ }
97+ token = newToken(t, c, manager)
98+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
99+ activated(t, events, second.ID)
100+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
101+ noActivation(t, c, events)
102+
103+ // Unknown tokens are ignored.
104+ requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, "not-a-token", surf)
105+ noActivation(t, c, events)
106+}
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 +7 -4
@@ -62,10 +62,12 @@ 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.
7173 press uint32
@@ -154,6 +156,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
154156 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}
155157 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
156158 s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{}
159+ s.tokens = map[string]activationToken{}
157160 if opts.Keymap != "" {
158161 fd, size, e := keymapFile(opts.Keymap)
159162 if e != nil {
@@ -62,10 +62,12 @@ 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
@@ -154,6 +156,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}156 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{}157 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
156 s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{}158 s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{}
159+ s.tokens = map[string]activationToken{}
157 if opts.Keymap != "" {160 if opts.Keymap != "" {
158 fd, size, e := keymapFile(opts.Keymap)161 fd, size, e := keymapFile(opts.Keymap)
159 if e != nil {162 if e != nil {
M internal/core/core.go +9 -0
@@ -618,6 +618,15 @@ 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+ // 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())
629+ }
621630 }
622631 if err := c.publish(ctx); err != nil {
623632 return nil
@@ -618,6 +618,15 @@ 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+ // 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())
629+ }
621 }630 }
622 if err := c.publish(ctx); err != nil {631 if err := c.publish(ctx); err != nil {
623 return nil632 return nil
M internal/core/core_test.go +46 -0
@@ -722,3 +722,49 @@ 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+}
@@ -722,3 +722,49 @@ 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+}
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