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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +216 -31
M internal/adapters/wayland/activation.go +49 -14
| @@ -16,13 +16,18 @@ import ( | |||
| 16 | 16 | // bring its window forward with it. Other tokens are issued but inert, so | |
| 17 | 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 | 21 | 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 | 28 | type activationToken struct { | |
| 24 | - | valid bool | |
| 25 | - | at time.Time | |
| 29 | + | client server.Client | |
| 30 | + | at time.Time | |
| 26 | 31 | } | |
| 27 | 32 | ||
| 28 | 33 | func registerActivation(d *server.Display, s *Server) error { | |
| @@ -46,12 +51,23 @@ func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf * | |||
| 46 | 51 | t, ok := s.tokens[token] | |
| 47 | 52 | delete(s.tokens, token) | |
| 48 | 53 | 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 | + | } | |
| 52 | 68 | } | |
| 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") | |
| 55 | 71 | return | |
| 56 | 72 | } | |
| 57 | 73 | s.log.Info().Uint64("id", uint64(w.id)).Msg("activation") | |
| @@ -74,7 +90,8 @@ func (*tokenRequest) SetSurface(*xdgactivation.ActivationTokenV1, *wayland.Surfa | |||
| 74 | 90 | func (*tokenRequest) Destroy(*xdgactivation.ActivationTokenV1) {} | |
| 75 | 91 | ||
| 76 | 92 | // 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 | 95 | func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) { | |
| 79 | 96 | if t.used { | |
| 80 | 97 | r.PostError(uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed), "token already committed") | |
| @@ -83,10 +100,13 @@ func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) { | |||
| 83 | 100 | t.used = true | |
| 84 | 101 | s := t.server | |
| 85 | 102 | 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) | |
| 88 | 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 | 110 | r.SendDone(token) | |
| 91 | 111 | } | |
| 92 | 112 | ||
| @@ -101,11 +121,26 @@ func (s *Server) focusClient() server.Client { | |||
| 101 | 121 | return server.Client{} | |
| 102 | 122 | } | |
| 103 | 123 | ||
| 104 | - | // pruneTokens forgets expired tokens. | |
| 124 | + | // pruneTokens forgets expired tokens and, past maxTokens, the oldest. | |
| 105 | 125 | func (s *Server) pruneTokens() { | |
| 126 | + | oldest := "" | |
| 106 | 127 | for k, t := range s.tokens { | |
| 107 | 128 | if time.Since(t.at) > tokenLifetime { | |
| 108 | 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 | } | |
| @@ -16,13 +16,18 @@ import ( | |||||
| 16 | // bring its window forward with it. Other tokens are issued but inert, so | 16 | // 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.Second | 21 | 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 bool | 29 | + | client server.Client |
| 25 | - | at time.Time | 30 | + | 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 | - | return | 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 | + | } | |||
| 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 | return | 71 | 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 the | 92 | // 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 = true | 100 | t.used = true | ||
| 84 | s := t.server | 101 | 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 { | |||
| 17 | 17 | ||
| 18 | 18 | func (p *tokenProxy) Dispatch(e *wlturbo.Event) { p.tokens <- e.String() } | |
| 19 | 19 | ||
| 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 { | |
| 22 | 23 | t.Helper() | |
| 23 | 24 | id := c.AllocateID() | |
| 24 | 25 | p := &tokenProxy{tokens: make(chan string, 1)} | |
| 25 | 26 | p.SetID(id) | |
| 26 | 27 | c.Context().Register(p) | |
| 27 | 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 | 32 | requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit) | |
| 29 | 33 | for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); { | |
| 30 | 34 | if err := c.Roundtrip(); err != nil { | |
| @@ -104,3 +108,88 @@ func TestActivationNeedsFocusedRequester(t *testing.T) { | |||
| 104 | 108 | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, "not-a-token", surf) | |
| 105 | 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 | + | } | |
| @@ -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 { | |||
| 70 | 70 | serial uint32 | |
| 71 | 71 | // press is the serial of the last button or key press, sent to | |
| 72 | 72 | // 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 | |
| 75 | 78 | ctx context.Context | |
| 76 | 79 | windows map[ports.WindowID]*window | |
| 77 | 80 | layers map[ports.WindowID]*layerSurface | |
| @@ -386,7 +389,7 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 386 | 389 | state := uint32(0) | |
| 387 | 390 | if c.Pressed { | |
| 388 | 391 | 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 | 394 | for _, p := range s.windowPointers(w) { | |
| 392 | 395 | p.SendButton(s.serial, c.TimeMsec, c.Button, state) | |
| @@ -420,7 +423,7 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 420 | 423 | } | |
| 421 | 424 | if c.Key.Pressed { | |
| 422 | 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 | 429 | for _, k := range keyboards { | |
| @@ -571,6 +574,14 @@ func (s *Server) sendModifiers(k *wayland.Keyboard) { | |||
| 571 | 574 | } | |
| 572 | 575 | ||
| 573 | 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 | 585 | if surf, keyboards := s.focusTarget(s.focused); surf != nil { | |
| 575 | 586 | for _, k := range keyboards { | |
| 576 | 587 | s.serial++ | |
| @@ -70,8 +70,11 @@ type Server struct { | |||||
| 70 | serial uint32 | 70 | serial uint32 | ||
| 71 | // press is the serial of the last button or key press, sent to | 71 | // 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 uint32 | 73 | + | press uint32 |
| 74 | - | pressClient server.Client | 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 | |||
| 75 | ctx context.Context | 78 | ctx context.Context | ||
| 76 | windows map[ports.WindowID]*window | 79 | windows map[ports.WindowID]*window | ||
| 77 | layers map[ports.WindowID]*layerSurface | 80 | 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 = 1 | 391 | 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 { | |||
| 619 | 619 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | |
| 620 | 620 | } | |
| 621 | 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()) | |
| 622 | + | if c.activate(ctx, v.ID) != nil { | |
| 623 | + | return nil | |
| 629 | 624 | } | |
| 630 | 625 | } | |
| 631 | 626 | if err := c.publish(ctx); err != nil { | |
| @@ -803,10 +798,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 803 | 798 | case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}: | |
| 804 | 799 | } | |
| 805 | 800 | } | |
| 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 { | |
| 810 | 802 | return nil | |
| 811 | 803 | } | |
| 812 | 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 | + | } | |
| @@ -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 nil | 802 | 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 ( | |||
| 6 | 6 | "github.com/bnema/nefertty/internal/adapters/config" | |
| 7 | 7 | "github.com/bnema/nefertty/internal/core" | |
| 8 | 8 | "github.com/bnema/nefertty/internal/ports" | |
| 9 | + | "slices" | |
| 9 | 10 | "testing" | |
| 10 | 11 | "time" | |
| 11 | 12 | ) | |
| @@ -767,4 +768,25 @@ func TestWindowActivateShowsAndFocuses(t *testing.T) { | |||
| 767 | 768 | if !v[0].Focused { | |
| 768 | 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 | } | |
| @@ -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() { | |||
| 441 | 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 | 453 | // SetFullscreen applies a client request. It never moves focus: client | |
| 445 | 454 | // requests are automatic events (ADR 011 golden rule). | |
| 446 | 455 | 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: client | 453 | // 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) { | ||