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