feat(wayland): add wlr foreign toplevel management (#32)
* feat(wayland): add wlr foreign toplevel management Advertise zwlr_foreign_toplevel_manager_v1 v3 so taskbars and notification daemons can list windows and read their fullscreen, activated and maximized states. Activate, close and (un)set fullscreen requests reuse the existing xdg-activation and xdg-shell messages. Closes #30 * fix(wayland): ignore requests on closed foreign toplevel handles A remapped window keeps its ID, so a closed handle could still act on it. Mark handles closed on unmap. Omit the fullscreen state for version 1 clients, which predate it. * fix(wayland): ignore set_rectangle on closed foreign toplevel handles
- commit
- 3aaf9f2a3c0609ebfeef3eb89fc73e3628cdc6e0
- author
- bnema · · 2026-09-27 19:12 UTC
- committer
- GitHub · · 2026-09-27 19:12 UTC
- parent
- 560aaa9
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
7 files · +498 -1
M docs/desktop.md +2 -0
| @@ -78,6 +78,8 @@ exec wlopm --off '*' | |||
| 78 | 78 | ||
| 79 | 79 | NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling. | |
| 80 | 80 | ||
| 81 | + | NeferWL also supports `zwlr_foreign_toplevel_manager_v1`. Taskbars can list, focus, close and fullscreen windows, and notification daemons such as Dunst can detect fullscreen windows. Every client can see window titles and app IDs. | |
| 82 | + | ||
| 81 | 83 | ## State for scripts | |
| 82 | 84 | ||
| 83 | 85 | While it runs, NeferWL writes its state to `$XDG_RUNTIME_DIR/neferwl/<wayland socket>.json` and passes that path to the programs it starts as `NEFERWL_STATE`. The file lists: | |
| @@ -78,6 +78,8 @@ exec wlopm --off '*' | |||||
| 78 | 78 | ||||
| 79 | NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling. | 79 | NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling. | ||
| 80 | 80 | ||||
| 81 | + | NeferWL also supports `zwlr_foreign_toplevel_manager_v1`. Taskbars can list, focus, close and fullscreen windows, and notification daemons such as Dunst can detect fullscreen windows. Every client can see window titles and app IDs. | |||
| 82 | + | ||||
| 81 | ## State for scripts | 83 | ## State for scripts | ||
| 82 | 84 | ||||
| 83 | While it runs, NeferWL writes its state to `$XDG_RUNTIME_DIR/neferwl/<wayland socket>.json` and passes that path to the programs it starts as `NEFERWL_STATE`. The file lists: | 85 | While it runs, NeferWL writes its state to `$XDG_RUNTIME_DIR/neferwl/<wayland socket>.json` and passes that path to the programs it starts as `NEFERWL_STATE`. The file lists: | ||
A internal/adapters/wayland/foreigntoplevel.go +256 -0
| @@ -0,0 +1,256 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/binary" | |
| 6 | + | "slices" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 10 | + | wlr "github.com/bnema/purego-libwayland/protocol/wlrforeigntoplevel" | |
| 11 | + | "github.com/bnema/purego-libwayland/server" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // wlr-foreign-toplevel-management lists mapped toplevels for taskbars and | |
| 15 | + | // notification daemons (Dunst reads the fullscreen state). Every client | |
| 16 | + | // may bind it, as on other compositors: it reveals less than screencopy. | |
| 17 | + | // Requests reuse the messages of xdg-shell and xdg-activation. | |
| 18 | + | ||
| 19 | + | type toplevelManager struct { | |
| 20 | + | s *Server | |
| 21 | + | res *wlr.ZwlrForeignToplevelManagerV1 | |
| 22 | + | handles map[ports.WindowID]*toplevelHandle | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // toplevelHandle is one window seen by one manager, with what it was last | |
| 26 | + | // sent so that only changes go out. | |
| 27 | + | type toplevelHandle struct { | |
| 28 | + | res *wlr.ZwlrForeignToplevelHandleV1 | |
| 29 | + | req *toplevelRequests | |
| 30 | + | title, appID string | |
| 31 | + | state []byte | |
| 32 | + | outputs map[*server.Resource]bool // output_enter sent | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func registerForeignToplevel(d *server.Display, s *Server) error { | |
| 36 | + | return wlr.NewZwlrForeignToplevelManagerV1Global(d, 3, func(c server.Client, v, id uint32) { | |
| 37 | + | m := &toplevelManager{s: s, handles: map[ports.WindowID]*toplevelHandle{}} | |
| 38 | + | r, err := wlr.NewZwlrForeignToplevelManagerV1(c, int32(v), id, m) | |
| 39 | + | if err != nil { | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | m.res = r | |
| 43 | + | s.toplevelManagers = append(s.toplevelManagers, m) | |
| 44 | + | r.OnDestroy = func() { s.toplevelManagers = removeItem(s.toplevelManagers, m) } | |
| 45 | + | ids := make([]ports.WindowID, 0, len(s.windows)) | |
| 46 | + | for id, w := range s.windows { | |
| 47 | + | if w.toplevel != nil && w.mapped { | |
| 48 | + | ids = append(ids, id) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | slices.Sort(ids) | |
| 52 | + | for _, id := range ids { | |
| 53 | + | m.refresh(s.windows[id]) | |
| 54 | + | } | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (m *toplevelManager) Stop(*wlr.ZwlrForeignToplevelManagerV1) { | |
| 59 | + | m.res.SendFinished() | |
| 60 | + | m.res.Destroy() | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // refresh announces w or sends what changed since the last done. | |
| 64 | + | func (m *toplevelManager) refresh(w *window) { | |
| 65 | + | if !m.res.Alive() { | |
| 66 | + | return | |
| 67 | + | } | |
| 68 | + | h := m.handles[w.id] | |
| 69 | + | fresh := h == nil | |
| 70 | + | if fresh { | |
| 71 | + | req := &toplevelRequests{s: m.s, id: w.id} | |
| 72 | + | r, err := wlr.NewZwlrForeignToplevelHandleV1(m.res.Client(), m.res.Version(), 0, req) | |
| 73 | + | if err != nil { | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | h = &toplevelHandle{res: r, req: req, outputs: map[*server.Resource]bool{}} | |
| 77 | + | m.handles[w.id] = h | |
| 78 | + | m.res.SendToplevel(r) | |
| 79 | + | } | |
| 80 | + | if !h.res.Alive() { | |
| 81 | + | return | |
| 82 | + | } | |
| 83 | + | changed := fresh | |
| 84 | + | if fresh || h.title != w.title { | |
| 85 | + | h.title = w.title | |
| 86 | + | h.res.SendTitle(w.title) | |
| 87 | + | changed = true | |
| 88 | + | } | |
| 89 | + | if fresh || h.appID != w.appID { | |
| 90 | + | h.appID = w.appID | |
| 91 | + | h.res.SendAppId(w.appID) | |
| 92 | + | changed = true | |
| 93 | + | } | |
| 94 | + | if state := toplevelState(w, h.res.Version()); fresh || !bytes.Equal(h.state, state) { | |
| 95 | + | h.state = state | |
| 96 | + | h.res.SendState(state) | |
| 97 | + | changed = true | |
| 98 | + | } | |
| 99 | + | if h.syncOutputs(m.s, m.res.Client(), w) { | |
| 100 | + | changed = true | |
| 101 | + | } | |
| 102 | + | if changed { | |
| 103 | + | h.res.SendDone() | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // closed sends closed for w; the client then destroys the handle. | |
| 108 | + | func (m *toplevelManager) closed(id ports.WindowID) { | |
| 109 | + | h := m.handles[id] | |
| 110 | + | if h == nil { | |
| 111 | + | return | |
| 112 | + | } | |
| 113 | + | delete(m.handles, id) | |
| 114 | + | // A remapped window keeps its ID but gets a new handle. | |
| 115 | + | h.req.closed = true | |
| 116 | + | if h.res.Alive() { | |
| 117 | + | h.res.SendClosed() | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // syncOutputs sends output_enter for every wl_output the client bound for | |
| 122 | + | // the window's output and output_leave for the others. | |
| 123 | + | func (h *toplevelHandle) syncOutputs(s *Server, c server.Client, w *window) bool { | |
| 124 | + | want := map[*server.Resource]bool{} | |
| 125 | + | if o := s.outputByNameExact(w.last.Output); o != nil && w.hasLast { | |
| 126 | + | for _, r := range o.resources { | |
| 127 | + | if r.Client() == c && r.Alive() { | |
| 128 | + | want[r.Resource] = true | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | changed := false | |
| 133 | + | for r := range h.outputs { | |
| 134 | + | if !want[r] { | |
| 135 | + | delete(h.outputs, r) | |
| 136 | + | if r.Alive() { | |
| 137 | + | h.res.SendOutputLeave(wayland.WrapOutput(r)) | |
| 138 | + | changed = true | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | for r := range want { | |
| 143 | + | if !h.outputs[r] { | |
| 144 | + | h.outputs[r] = true | |
| 145 | + | h.res.SendOutputEnter(wayland.WrapOutput(r)) | |
| 146 | + | changed = true | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | return changed | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // toplevelState mirrors the xdg_toplevel states of the last configure: | |
| 153 | + | // tiled windows are maximized. Fullscreen exists since version 2. | |
| 154 | + | func toplevelState(w *window, version int32) []byte { | |
| 155 | + | if !w.hasLast { | |
| 156 | + | return []byte{} | |
| 157 | + | } | |
| 158 | + | c := w.last | |
| 159 | + | var states []byte | |
| 160 | + | add := func(st wlr.ZwlrForeignToplevelHandleV1State) { | |
| 161 | + | states = binary.NativeEndian.AppendUint32(states, uint32(st)) | |
| 162 | + | } | |
| 163 | + | if !c.Fullscreen && !c.Floating { | |
| 164 | + | add(wlr.ZwlrForeignToplevelHandleV1StateMaximized) | |
| 165 | + | } | |
| 166 | + | if c.Activated { | |
| 167 | + | add(wlr.ZwlrForeignToplevelHandleV1StateActivated) | |
| 168 | + | } | |
| 169 | + | if c.Fullscreen && version >= 2 { | |
| 170 | + | add(wlr.ZwlrForeignToplevelHandleV1StateFullscreen) | |
| 171 | + | } | |
| 172 | + | if states == nil { | |
| 173 | + | return []byte{} | |
| 174 | + | } | |
| 175 | + | return states | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // toplevelChanged updates every manager after w mapped or changed. | |
| 179 | + | func (s *Server) toplevelChanged(w *window) { | |
| 180 | + | if w.toplevel == nil || !w.mapped { | |
| 181 | + | return | |
| 182 | + | } | |
| 183 | + | for _, m := range s.toplevelManagers { | |
| 184 | + | m.refresh(w) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // toplevelClosed tells every manager that w unmapped. | |
| 189 | + | func (s *Server) toplevelClosed(id ports.WindowID) { | |
| 190 | + | for _, m := range s.toplevelManagers { | |
| 191 | + | m.closed(id) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // refreshToplevels resends the output events of every handle after a | |
| 196 | + | // wl_output was bound or an output removed. | |
| 197 | + | func (s *Server) refreshToplevels() { | |
| 198 | + | for _, m := range s.toplevelManagers { | |
| 199 | + | for id := range m.handles { | |
| 200 | + | if w := s.windows[id]; w != nil { | |
| 201 | + | m.refresh(w) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // toplevelRequests handles requests on one handle. Requests on a closed | |
| 208 | + | // handle are ignored. | |
| 209 | + | type toplevelRequests struct { | |
| 210 | + | s *Server | |
| 211 | + | id ports.WindowID | |
| 212 | + | closed bool | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (t *toplevelRequests) window() *window { | |
| 216 | + | if t.closed { | |
| 217 | + | return nil | |
| 218 | + | } | |
| 219 | + | if w := t.s.windows[t.id]; w != nil && w.toplevel != nil && w.mapped && w.toplevel.Resource.Alive() { | |
| 220 | + | return w | |
| 221 | + | } | |
| 222 | + | return nil | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // Tiled windows are always maximized and nothing minimizes: these are no-ops. | |
| 226 | + | func (*toplevelRequests) SetMaximized(*wlr.ZwlrForeignToplevelHandleV1) {} | |
| 227 | + | func (*toplevelRequests) UnsetMaximized(*wlr.ZwlrForeignToplevelHandleV1) {} | |
| 228 | + | func (*toplevelRequests) SetMinimized(*wlr.ZwlrForeignToplevelHandleV1) {} | |
| 229 | + | func (*toplevelRequests) UnsetMinimized(*wlr.ZwlrForeignToplevelHandleV1) {} | |
| 230 | + | func (*toplevelRequests) Destroy(*wlr.ZwlrForeignToplevelHandleV1) {} | |
| 231 | + | ||
| 232 | + | func (t *toplevelRequests) Activate(*wlr.ZwlrForeignToplevelHandleV1, *wayland.Seat) { | |
| 233 | + | if w := t.window(); w != nil { | |
| 234 | + | t.s.emit(ports.WindowActivate{ID: w.id}) | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | func (t *toplevelRequests) Close(*wlr.ZwlrForeignToplevelHandleV1) { | |
| 238 | + | if w := t.window(); w != nil { | |
| 239 | + | w.toplevel.SendClose() | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *wayland.Surface, _, _, w, h int32) { | |
| 243 | + | if !t.closed && (w < 0 || h < 0) { | |
| 244 | + | r.PostError(uint32(wlr.ZwlrForeignToplevelHandleV1ErrorInvalidRectangle), "negative rectangle size") | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | func (t *toplevelRequests) SetFullscreen(*wlr.ZwlrForeignToplevelHandleV1, *wayland.Output) { | |
| 248 | + | if w := t.window(); w != nil { | |
| 249 | + | t.s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: true}) | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | func (t *toplevelRequests) UnsetFullscreen(*wlr.ZwlrForeignToplevelHandleV1) { | |
| 253 | + | if w := t.window(); w != nil { | |
| 254 | + | t.s.emit(ports.WindowFullscreenRequest{ID: w.id}) | |
| 255 | + | } | |
| 256 | + | } | |
| @@ -0,0 +1,256 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/binary" | |||
| 6 | + | "slices" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 10 | + | wlr "github.com/bnema/purego-libwayland/protocol/wlrforeigntoplevel" | |||
| 11 | + | "github.com/bnema/purego-libwayland/server" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // wlr-foreign-toplevel-management lists mapped toplevels for taskbars and | |||
| 15 | + | // notification daemons (Dunst reads the fullscreen state). Every client | |||
| 16 | + | // may bind it, as on other compositors: it reveals less than screencopy. | |||
| 17 | + | // Requests reuse the messages of xdg-shell and xdg-activation. | |||
| 18 | + | ||||
| 19 | + | type toplevelManager struct { | |||
| 20 | + | s *Server | |||
| 21 | + | res *wlr.ZwlrForeignToplevelManagerV1 | |||
| 22 | + | handles map[ports.WindowID]*toplevelHandle | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // toplevelHandle is one window seen by one manager, with what it was last | |||
| 26 | + | // sent so that only changes go out. | |||
| 27 | + | type toplevelHandle struct { | |||
| 28 | + | res *wlr.ZwlrForeignToplevelHandleV1 | |||
| 29 | + | req *toplevelRequests | |||
| 30 | + | title, appID string | |||
| 31 | + | state []byte | |||
| 32 | + | outputs map[*server.Resource]bool // output_enter sent | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func registerForeignToplevel(d *server.Display, s *Server) error { | |||
| 36 | + | return wlr.NewZwlrForeignToplevelManagerV1Global(d, 3, func(c server.Client, v, id uint32) { | |||
| 37 | + | m := &toplevelManager{s: s, handles: map[ports.WindowID]*toplevelHandle{}} | |||
| 38 | + | r, err := wlr.NewZwlrForeignToplevelManagerV1(c, int32(v), id, m) | |||
| 39 | + | if err != nil { | |||
| 40 | + | return | |||
| 41 | + | } | |||
| 42 | + | m.res = r | |||
| 43 | + | s.toplevelManagers = append(s.toplevelManagers, m) | |||
| 44 | + | r.OnDestroy = func() { s.toplevelManagers = removeItem(s.toplevelManagers, m) } | |||
| 45 | + | ids := make([]ports.WindowID, 0, len(s.windows)) | |||
| 46 | + | for id, w := range s.windows { | |||
| 47 | + | if w.toplevel != nil && w.mapped { | |||
| 48 | + | ids = append(ids, id) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | slices.Sort(ids) | |||
| 52 | + | for _, id := range ids { | |||
| 53 | + | m.refresh(s.windows[id]) | |||
| 54 | + | } | |||
| 55 | + | }) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (m *toplevelManager) Stop(*wlr.ZwlrForeignToplevelManagerV1) { | |||
| 59 | + | m.res.SendFinished() | |||
| 60 | + | m.res.Destroy() | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // refresh announces w or sends what changed since the last done. | |||
| 64 | + | func (m *toplevelManager) refresh(w *window) { | |||
| 65 | + | if !m.res.Alive() { | |||
| 66 | + | return | |||
| 67 | + | } | |||
| 68 | + | h := m.handles[w.id] | |||
| 69 | + | fresh := h == nil | |||
| 70 | + | if fresh { | |||
| 71 | + | req := &toplevelRequests{s: m.s, id: w.id} | |||
| 72 | + | r, err := wlr.NewZwlrForeignToplevelHandleV1(m.res.Client(), m.res.Version(), 0, req) | |||
| 73 | + | if err != nil { | |||
| 74 | + | return | |||
| 75 | + | } | |||
| 76 | + | h = &toplevelHandle{res: r, req: req, outputs: map[*server.Resource]bool{}} | |||
| 77 | + | m.handles[w.id] = h | |||
| 78 | + | m.res.SendToplevel(r) | |||
| 79 | + | } | |||
| 80 | + | if !h.res.Alive() { | |||
| 81 | + | return | |||
| 82 | + | } | |||
| 83 | + | changed := fresh | |||
| 84 | + | if fresh || h.title != w.title { | |||
| 85 | + | h.title = w.title | |||
| 86 | + | h.res.SendTitle(w.title) | |||
| 87 | + | changed = true | |||
| 88 | + | } | |||
| 89 | + | if fresh || h.appID != w.appID { | |||
| 90 | + | h.appID = w.appID | |||
| 91 | + | h.res.SendAppId(w.appID) | |||
| 92 | + | changed = true | |||
| 93 | + | } | |||
| 94 | + | if state := toplevelState(w, h.res.Version()); fresh || !bytes.Equal(h.state, state) { | |||
| 95 | + | h.state = state | |||
| 96 | + | h.res.SendState(state) | |||
| 97 | + | changed = true | |||
| 98 | + | } | |||
| 99 | + | if h.syncOutputs(m.s, m.res.Client(), w) { | |||
| 100 | + | changed = true | |||
| 101 | + | } | |||
| 102 | + | if changed { | |||
| 103 | + | h.res.SendDone() | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // closed sends closed for w; the client then destroys the handle. | |||
| 108 | + | func (m *toplevelManager) closed(id ports.WindowID) { | |||
| 109 | + | h := m.handles[id] | |||
| 110 | + | if h == nil { | |||
| 111 | + | return | |||
| 112 | + | } | |||
| 113 | + | delete(m.handles, id) | |||
| 114 | + | // A remapped window keeps its ID but gets a new handle. | |||
| 115 | + | h.req.closed = true | |||
| 116 | + | if h.res.Alive() { | |||
| 117 | + | h.res.SendClosed() | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // syncOutputs sends output_enter for every wl_output the client bound for | |||
| 122 | + | // the window's output and output_leave for the others. | |||
| 123 | + | func (h *toplevelHandle) syncOutputs(s *Server, c server.Client, w *window) bool { | |||
| 124 | + | want := map[*server.Resource]bool{} | |||
| 125 | + | if o := s.outputByNameExact(w.last.Output); o != nil && w.hasLast { | |||
| 126 | + | for _, r := range o.resources { | |||
| 127 | + | if r.Client() == c && r.Alive() { | |||
| 128 | + | want[r.Resource] = true | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | changed := false | |||
| 133 | + | for r := range h.outputs { | |||
| 134 | + | if !want[r] { | |||
| 135 | + | delete(h.outputs, r) | |||
| 136 | + | if r.Alive() { | |||
| 137 | + | h.res.SendOutputLeave(wayland.WrapOutput(r)) | |||
| 138 | + | changed = true | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | for r := range want { | |||
| 143 | + | if !h.outputs[r] { | |||
| 144 | + | h.outputs[r] = true | |||
| 145 | + | h.res.SendOutputEnter(wayland.WrapOutput(r)) | |||
| 146 | + | changed = true | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | return changed | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // toplevelState mirrors the xdg_toplevel states of the last configure: | |||
| 153 | + | // tiled windows are maximized. Fullscreen exists since version 2. | |||
| 154 | + | func toplevelState(w *window, version int32) []byte { | |||
| 155 | + | if !w.hasLast { | |||
| 156 | + | return []byte{} | |||
| 157 | + | } | |||
| 158 | + | c := w.last | |||
| 159 | + | var states []byte | |||
| 160 | + | add := func(st wlr.ZwlrForeignToplevelHandleV1State) { | |||
| 161 | + | states = binary.NativeEndian.AppendUint32(states, uint32(st)) | |||
| 162 | + | } | |||
| 163 | + | if !c.Fullscreen && !c.Floating { | |||
| 164 | + | add(wlr.ZwlrForeignToplevelHandleV1StateMaximized) | |||
| 165 | + | } | |||
| 166 | + | if c.Activated { | |||
| 167 | + | add(wlr.ZwlrForeignToplevelHandleV1StateActivated) | |||
| 168 | + | } | |||
| 169 | + | if c.Fullscreen && version >= 2 { | |||
| 170 | + | add(wlr.ZwlrForeignToplevelHandleV1StateFullscreen) | |||
| 171 | + | } | |||
| 172 | + | if states == nil { | |||
| 173 | + | return []byte{} | |||
| 174 | + | } | |||
| 175 | + | return states | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // toplevelChanged updates every manager after w mapped or changed. | |||
| 179 | + | func (s *Server) toplevelChanged(w *window) { | |||
| 180 | + | if w.toplevel == nil || !w.mapped { | |||
| 181 | + | return | |||
| 182 | + | } | |||
| 183 | + | for _, m := range s.toplevelManagers { | |||
| 184 | + | m.refresh(w) | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // toplevelClosed tells every manager that w unmapped. | |||
| 189 | + | func (s *Server) toplevelClosed(id ports.WindowID) { | |||
| 190 | + | for _, m := range s.toplevelManagers { | |||
| 191 | + | m.closed(id) | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // refreshToplevels resends the output events of every handle after a | |||
| 196 | + | // wl_output was bound or an output removed. | |||
| 197 | + | func (s *Server) refreshToplevels() { | |||
| 198 | + | for _, m := range s.toplevelManagers { | |||
| 199 | + | for id := range m.handles { | |||
| 200 | + | if w := s.windows[id]; w != nil { | |||
| 201 | + | m.refresh(w) | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // toplevelRequests handles requests on one handle. Requests on a closed | |||
| 208 | + | // handle are ignored. | |||
| 209 | + | type toplevelRequests struct { | |||
| 210 | + | s *Server | |||
| 211 | + | id ports.WindowID | |||
| 212 | + | closed bool | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func (t *toplevelRequests) window() *window { | |||
| 216 | + | if t.closed { | |||
| 217 | + | return nil | |||
| 218 | + | } | |||
| 219 | + | if w := t.s.windows[t.id]; w != nil && w.toplevel != nil && w.mapped && w.toplevel.Resource.Alive() { | |||
| 220 | + | return w | |||
| 221 | + | } | |||
| 222 | + | return nil | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // Tiled windows are always maximized and nothing minimizes: these are no-ops. | |||
| 226 | + | func (*toplevelRequests) SetMaximized(*wlr.ZwlrForeignToplevelHandleV1) {} | |||
| 227 | + | func (*toplevelRequests) UnsetMaximized(*wlr.ZwlrForeignToplevelHandleV1) {} | |||
| 228 | + | func (*toplevelRequests) SetMinimized(*wlr.ZwlrForeignToplevelHandleV1) {} | |||
| 229 | + | func (*toplevelRequests) UnsetMinimized(*wlr.ZwlrForeignToplevelHandleV1) {} | |||
| 230 | + | func (*toplevelRequests) Destroy(*wlr.ZwlrForeignToplevelHandleV1) {} | |||
| 231 | + | ||||
| 232 | + | func (t *toplevelRequests) Activate(*wlr.ZwlrForeignToplevelHandleV1, *wayland.Seat) { | |||
| 233 | + | if w := t.window(); w != nil { | |||
| 234 | + | t.s.emit(ports.WindowActivate{ID: w.id}) | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | func (t *toplevelRequests) Close(*wlr.ZwlrForeignToplevelHandleV1) { | |||
| 238 | + | if w := t.window(); w != nil { | |||
| 239 | + | w.toplevel.SendClose() | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *wayland.Surface, _, _, w, h int32) { | |||
| 243 | + | if !t.closed && (w < 0 || h < 0) { | |||
| 244 | + | r.PostError(uint32(wlr.ZwlrForeignToplevelHandleV1ErrorInvalidRectangle), "negative rectangle size") | |||
| 245 | + | } | |||
| 246 | + | } | |||
| 247 | + | func (t *toplevelRequests) SetFullscreen(*wlr.ZwlrForeignToplevelHandleV1, *wayland.Output) { | |||
| 248 | + | if w := t.window(); w != nil { | |||
| 249 | + | t.s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: true}) | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | func (t *toplevelRequests) UnsetFullscreen(*wlr.ZwlrForeignToplevelHandleV1) { | |||
| 253 | + | if w := t.window(); w != nil { | |||
| 254 | + | t.s.emit(ports.WindowFullscreenRequest{ID: w.id}) | |||
| 255 | + | } | |||
| 256 | + | } | |||
A internal/adapters/wayland/foreigntoplevel_test.go +224 -0
| @@ -0,0 +1,224 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "fmt" | |
| 6 | + | "slices" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 12 | + | wlr "github.com/bnema/purego-libwayland/protocol/wlrforeigntoplevel" | |
| 13 | + | "github.com/bnema/purego-libwayland/protocol/xdgshell" | |
| 14 | + | "github.com/bnema/wlturbo" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // toplevelManagerEvents registers a handle proxy for each announced toplevel. | |
| 18 | + | type toplevelManagerEvents struct { | |
| 19 | + | wlturbo.BaseProxy | |
| 20 | + | client *wlturbo.Display | |
| 21 | + | handles chan uint32 | |
| 22 | + | events chan string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (p *toplevelManagerEvents) Dispatch(e *wlturbo.Event) { | |
| 26 | + | if e.Opcode != uint16(wlr.ZwlrForeignToplevelManagerV1EventToplevel) { | |
| 27 | + | return | |
| 28 | + | } | |
| 29 | + | id := e.Uint32() | |
| 30 | + | h := &toplevelHandleEvents{events: p.events} | |
| 31 | + | h.SetID(id) | |
| 32 | + | p.client.Context().Register(h) | |
| 33 | + | p.handles <- id | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // toplevelHandleEvents records handle events as text. | |
| 37 | + | type toplevelHandleEvents struct { | |
| 38 | + | wlturbo.BaseProxy | |
| 39 | + | events chan string | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (p *toplevelHandleEvents) Dispatch(e *wlturbo.Event) { | |
| 43 | + | switch uint32(e.Opcode) { | |
| 44 | + | case wlr.ZwlrForeignToplevelHandleV1EventTitle: | |
| 45 | + | p.events <- "title " + e.String() | |
| 46 | + | case wlr.ZwlrForeignToplevelHandleV1EventAppId: | |
| 47 | + | p.events <- "app_id " + e.String() | |
| 48 | + | case wlr.ZwlrForeignToplevelHandleV1EventOutputEnter: | |
| 49 | + | p.events <- "output_enter" | |
| 50 | + | case wlr.ZwlrForeignToplevelHandleV1EventOutputLeave: | |
| 51 | + | p.events <- "output_leave" | |
| 52 | + | case wlr.ZwlrForeignToplevelHandleV1EventState: | |
| 53 | + | var states []uint32 | |
| 54 | + | for b := e.Array(); len(b) >= 4; b = b[4:] { | |
| 55 | + | states = append(states, binary.NativeEndian.Uint32(b)) | |
| 56 | + | } | |
| 57 | + | p.events <- fmt.Sprint("state ", states) | |
| 58 | + | case wlr.ZwlrForeignToplevelHandleV1EventDone: | |
| 59 | + | p.events <- "done" | |
| 60 | + | case wlr.ZwlrForeignToplevelHandleV1EventClosed: | |
| 61 | + | p.events <- "closed" | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // takeEvents returns the handle events received up to the next done or | |
| 66 | + | // closed, after a roundtrip. | |
| 67 | + | func takeEvents(t *testing.T, c *wlturbo.Display, events chan string) []string { | |
| 68 | + | t.Helper() | |
| 69 | + | var got []string | |
| 70 | + | deadline := time.Now().Add(2 * time.Second) | |
| 71 | + | for time.Now().Before(deadline) { | |
| 72 | + | if err := c.Roundtrip(); err != nil { | |
| 73 | + | t.Fatal(err) | |
| 74 | + | } | |
| 75 | + | for len(events) > 0 { | |
| 76 | + | ev := <-events | |
| 77 | + | got = append(got, ev) | |
| 78 | + | if ev == "done" || ev == "closed" { | |
| 79 | + | return got | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | time.Sleep(10 * time.Millisecond) | |
| 83 | + | } | |
| 84 | + | t.Fatalf("no done, got %q", got) | |
| 85 | + | return nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestForeignToplevel(t *testing.T) { | |
| 89 | + | s, events, commands, dir := lifecycleServer(t) | |
| 90 | + | c := protocolClient(t, s, dir) | |
| 91 | + | out := bindVersion(t, c, "wl_output", 4) | |
| 92 | + | registerProtocol(t, c, out) | |
| 93 | + | seat := bindProtocol(t, c, "wl_seat") | |
| 94 | + | registerProtocol(t, c, seat) | |
| 95 | + | mapWindow := surfaceMapper(t, c, events) | |
| 96 | + | w, surf, xdg := mapWindow() | |
| 97 | + | // Core configures below are not acked: drop them. | |
| 98 | + | registerProtocol(t, c, xdg) | |
| 99 | + | // surfaceMapper allocates the wl_surface, xdg_surface and xdg_toplevel in order. | |
| 100 | + | top := xdg + 1 | |
| 101 | + | requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor") | |
| 102 | + | requestProtocol(t, c, top, xdgshell.ToplevelRequestSetAppId, "app.editor") | |
| 103 | + | ||
| 104 | + | // A window mapped before the bind is announced. | |
| 105 | + | manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3) | |
| 106 | + | p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)} | |
| 107 | + | p.SetID(manager) | |
| 108 | + | c.Context().Register(p) | |
| 109 | + | if got, want := takeEvents(t, c, p.events), []string{"title editor", "app_id app.editor", "state []", "done"}; !slices.Equal(got, want) { | |
| 110 | + | t.Fatalf("announce = %q, want %q", got, want) | |
| 111 | + | } | |
| 112 | + | handle := <-p.handles | |
| 113 | + | ||
| 114 | + | // Core configures it fullscreen and focused on the output. | |
| 115 | + | commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Activated: true, Output: "HEADLESS-1"} | |
| 116 | + | want := []string{fmt.Sprint("state ", []uint32{uint32(wlr.ZwlrForeignToplevelHandleV1StateActivated), uint32(wlr.ZwlrForeignToplevelHandleV1StateFullscreen)}), "output_enter", "done"} | |
| 117 | + | if got := takeEvents(t, c, p.events); !slices.Equal(got, want) { | |
| 118 | + | t.Fatalf("fullscreen = %q, want %q", got, want) | |
| 119 | + | } | |
| 120 | + | commands <- ports.ConfigureWindow{ID: w.ID, Width: 960, Height: 1080, Output: "HEADLESS-1"} | |
| 121 | + | want = []string{fmt.Sprint("state ", []uint32{uint32(wlr.ZwlrForeignToplevelHandleV1StateMaximized)}), "done"} | |
| 122 | + | if got := takeEvents(t, c, p.events); !slices.Equal(got, want) { | |
| 123 | + | t.Fatalf("tiled = %q, want %q", got, want) | |
| 124 | + | } | |
| 125 | + | requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor - file") | |
| 126 | + | if got, want := takeEvents(t, c, p.events), []string{"title editor - file", "done"}; !slices.Equal(got, want) { | |
| 127 | + | t.Fatalf("title = %q, want %q", got, want) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // Requests reach core as the xdg-shell and xdg-activation messages. | |
| 131 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetFullscreen, uint32(0)) | |
| 132 | + | if ev := clientEvent[ports.WindowFullscreenRequest](t, events); ev != (ports.WindowFullscreenRequest{ID: w.ID, Fullscreen: true}) { | |
| 133 | + | t.Fatalf("set_fullscreen = %#v", ev) | |
| 134 | + | } | |
| 135 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestUnsetFullscreen) | |
| 136 | + | if ev := clientEvent[ports.WindowFullscreenRequest](t, events); ev != (ports.WindowFullscreenRequest{ID: w.ID}) { | |
| 137 | + | t.Fatalf("unset_fullscreen = %#v", ev) | |
| 138 | + | } | |
| 139 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestActivate, seat) | |
| 140 | + | if ev := clientEvent[ports.WindowActivate](t, events); ev.ID != w.ID { | |
| 141 | + | t.Fatalf("activate = %#v", ev) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // Unmapping closes the handle; requests on it are then ignored. | |
| 145 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, uint32(0), int32(0), int32(0)) | |
| 146 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 147 | + | if got, want := takeEvents(t, c, p.events), []string{"closed"}; !slices.Equal(got, want) { | |
| 148 | + | t.Fatalf("unmap = %q, want %q", got, want) | |
| 149 | + | } | |
| 150 | + | unmapped(t, events, w.ID) | |
| 151 | + | ||
| 152 | + | // Remapping the same toplevel announces a new handle; the closed one | |
| 153 | + | // stays inert although the window ID is the same. | |
| 154 | + | serials := make(chan uint32, 8) | |
| 155 | + | xp := &configureProxy{serial: serials} | |
| 156 | + | xp.SetID(xdg) | |
| 157 | + | c.Context().Register(xp) | |
| 158 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 159 | + | ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials) | |
| 160 | + | if again := mapped(t, events, 2*time.Second); again.ID != w.ID { | |
| 161 | + | t.Fatalf("remapped as %d, want %d", again.ID, w.ID) | |
| 162 | + | } | |
| 163 | + | if got := takeEvents(t, c, p.events); len(got) == 0 || got[len(got)-1] != "done" { | |
| 164 | + | t.Fatalf("remap = %q", got) | |
| 165 | + | } | |
| 166 | + | remapped := <-p.handles | |
| 167 | + | if remapped == handle { | |
| 168 | + | t.Fatal("remap reused the closed handle") | |
| 169 | + | } | |
| 170 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestActivate, seat) | |
| 171 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestClose) | |
| 172 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetRectangle, surf, int32(0), int32(0), int32(-1), int32(1)) | |
| 173 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestDestroy) | |
| 174 | + | if err := c.Roundtrip(); err != nil { | |
| 175 | + | t.Fatal(err) | |
| 176 | + | } | |
| 177 | + | select { | |
| 178 | + | case ev := <-events: | |
| 179 | + | t.Fatalf("event from a closed handle: %#v", ev) | |
| 180 | + | case <-time.After(100 * time.Millisecond): | |
| 181 | + | } | |
| 182 | + | requestProtocol(t, c, remapped, wlr.ZwlrForeignToplevelHandleV1RequestActivate, seat) | |
| 183 | + | if ev := clientEvent[ports.WindowActivate](t, events); ev.ID != w.ID { | |
| 184 | + | t.Fatalf("activate remapped = %#v", ev) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // A window mapped after the bind is announced too. | |
| 188 | + | mapWindow() | |
| 189 | + | if got := takeEvents(t, c, p.events); len(got) == 0 || got[len(got)-1] != "done" { | |
| 190 | + | t.Fatalf("new window = %q", got) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // Fullscreen is a version 2 state: version 1 clients never see it. | |
| 195 | + | func TestForeignToplevelV1HidesFullscreen(t *testing.T) { | |
| 196 | + | s, events, commands, dir := lifecycleServer(t) | |
| 197 | + | c := protocolClient(t, s, dir) | |
| 198 | + | w, _, xdg := surfaceMapper(t, c, events)() | |
| 199 | + | registerProtocol(t, c, xdg) | |
| 200 | + | manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 1) | |
| 201 | + | p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)} | |
| 202 | + | p.SetID(manager) | |
| 203 | + | c.Context().Register(p) | |
| 204 | + | takeEvents(t, c, p.events) | |
| 205 | + | commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Activated: true} | |
| 206 | + | want := []string{fmt.Sprint("state ", []uint32{uint32(wlr.ZwlrForeignToplevelHandleV1StateActivated)}), "done"} | |
| 207 | + | if got := takeEvents(t, c, p.events); !slices.Equal(got, want) { | |
| 208 | + | t.Fatalf("v1 fullscreen = %q, want %q", got, want) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func TestForeignToplevelInvalidRectangle(t *testing.T) { | |
| 213 | + | s, events, _, dir := lifecycleServer(t) | |
| 214 | + | c := protocolClient(t, s, dir) | |
| 215 | + | _, surf, _ := surfaceMapper(t, c, events)() | |
| 216 | + | manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3) | |
| 217 | + | p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)} | |
| 218 | + | p.SetID(manager) | |
| 219 | + | c.Context().Register(p) | |
| 220 | + | takeEvents(t, c, p.events) | |
| 221 | + | handle := <-p.handles | |
| 222 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetRectangle, surf, int32(0), int32(0), int32(-1), int32(1)) | |
| 223 | + | expectProtocolError(t, c, handle, uint32(wlr.ZwlrForeignToplevelHandleV1ErrorInvalidRectangle)) | |
| 224 | + | } | |
| @@ -0,0 +1,224 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "fmt" | |||
| 6 | + | "slices" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 12 | + | wlr "github.com/bnema/purego-libwayland/protocol/wlrforeigntoplevel" | |||
| 13 | + | "github.com/bnema/purego-libwayland/protocol/xdgshell" | |||
| 14 | + | "github.com/bnema/wlturbo" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // toplevelManagerEvents registers a handle proxy for each announced toplevel. | |||
| 18 | + | type toplevelManagerEvents struct { | |||
| 19 | + | wlturbo.BaseProxy | |||
| 20 | + | client *wlturbo.Display | |||
| 21 | + | handles chan uint32 | |||
| 22 | + | events chan string | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (p *toplevelManagerEvents) Dispatch(e *wlturbo.Event) { | |||
| 26 | + | if e.Opcode != uint16(wlr.ZwlrForeignToplevelManagerV1EventToplevel) { | |||
| 27 | + | return | |||
| 28 | + | } | |||
| 29 | + | id := e.Uint32() | |||
| 30 | + | h := &toplevelHandleEvents{events: p.events} | |||
| 31 | + | h.SetID(id) | |||
| 32 | + | p.client.Context().Register(h) | |||
| 33 | + | p.handles <- id | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // toplevelHandleEvents records handle events as text. | |||
| 37 | + | type toplevelHandleEvents struct { | |||
| 38 | + | wlturbo.BaseProxy | |||
| 39 | + | events chan string | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (p *toplevelHandleEvents) Dispatch(e *wlturbo.Event) { | |||
| 43 | + | switch uint32(e.Opcode) { | |||
| 44 | + | case wlr.ZwlrForeignToplevelHandleV1EventTitle: | |||
| 45 | + | p.events <- "title " + e.String() | |||
| 46 | + | case wlr.ZwlrForeignToplevelHandleV1EventAppId: | |||
| 47 | + | p.events <- "app_id " + e.String() | |||
| 48 | + | case wlr.ZwlrForeignToplevelHandleV1EventOutputEnter: | |||
| 49 | + | p.events <- "output_enter" | |||
| 50 | + | case wlr.ZwlrForeignToplevelHandleV1EventOutputLeave: | |||
| 51 | + | p.events <- "output_leave" | |||
| 52 | + | case wlr.ZwlrForeignToplevelHandleV1EventState: | |||
| 53 | + | var states []uint32 | |||
| 54 | + | for b := e.Array(); len(b) >= 4; b = b[4:] { | |||
| 55 | + | states = append(states, binary.NativeEndian.Uint32(b)) | |||
| 56 | + | } | |||
| 57 | + | p.events <- fmt.Sprint("state ", states) | |||
| 58 | + | case wlr.ZwlrForeignToplevelHandleV1EventDone: | |||
| 59 | + | p.events <- "done" | |||
| 60 | + | case wlr.ZwlrForeignToplevelHandleV1EventClosed: | |||
| 61 | + | p.events <- "closed" | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // takeEvents returns the handle events received up to the next done or | |||
| 66 | + | // closed, after a roundtrip. | |||
| 67 | + | func takeEvents(t *testing.T, c *wlturbo.Display, events chan string) []string { | |||
| 68 | + | t.Helper() | |||
| 69 | + | var got []string | |||
| 70 | + | deadline := time.Now().Add(2 * time.Second) | |||
| 71 | + | for time.Now().Before(deadline) { | |||
| 72 | + | if err := c.Roundtrip(); err != nil { | |||
| 73 | + | t.Fatal(err) | |||
| 74 | + | } | |||
| 75 | + | for len(events) > 0 { | |||
| 76 | + | ev := <-events | |||
| 77 | + | got = append(got, ev) | |||
| 78 | + | if ev == "done" || ev == "closed" { | |||
| 79 | + | return got | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | time.Sleep(10 * time.Millisecond) | |||
| 83 | + | } | |||
| 84 | + | t.Fatalf("no done, got %q", got) | |||
| 85 | + | return nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestForeignToplevel(t *testing.T) { | |||
| 89 | + | s, events, commands, dir := lifecycleServer(t) | |||
| 90 | + | c := protocolClient(t, s, dir) | |||
| 91 | + | out := bindVersion(t, c, "wl_output", 4) | |||
| 92 | + | registerProtocol(t, c, out) | |||
| 93 | + | seat := bindProtocol(t, c, "wl_seat") | |||
| 94 | + | registerProtocol(t, c, seat) | |||
| 95 | + | mapWindow := surfaceMapper(t, c, events) | |||
| 96 | + | w, surf, xdg := mapWindow() | |||
| 97 | + | // Core configures below are not acked: drop them. | |||
| 98 | + | registerProtocol(t, c, xdg) | |||
| 99 | + | // surfaceMapper allocates the wl_surface, xdg_surface and xdg_toplevel in order. | |||
| 100 | + | top := xdg + 1 | |||
| 101 | + | requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor") | |||
| 102 | + | requestProtocol(t, c, top, xdgshell.ToplevelRequestSetAppId, "app.editor") | |||
| 103 | + | ||||
| 104 | + | // A window mapped before the bind is announced. | |||
| 105 | + | manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3) | |||
| 106 | + | p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)} | |||
| 107 | + | p.SetID(manager) | |||
| 108 | + | c.Context().Register(p) | |||
| 109 | + | if got, want := takeEvents(t, c, p.events), []string{"title editor", "app_id app.editor", "state []", "done"}; !slices.Equal(got, want) { | |||
| 110 | + | t.Fatalf("announce = %q, want %q", got, want) | |||
| 111 | + | } | |||
| 112 | + | handle := <-p.handles | |||
| 113 | + | ||||
| 114 | + | // Core configures it fullscreen and focused on the output. | |||
| 115 | + | commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Activated: true, Output: "HEADLESS-1"} | |||
| 116 | + | want := []string{fmt.Sprint("state ", []uint32{uint32(wlr.ZwlrForeignToplevelHandleV1StateActivated), uint32(wlr.ZwlrForeignToplevelHandleV1StateFullscreen)}), "output_enter", "done"} | |||
| 117 | + | if got := takeEvents(t, c, p.events); !slices.Equal(got, want) { | |||
| 118 | + | t.Fatalf("fullscreen = %q, want %q", got, want) | |||
| 119 | + | } | |||
| 120 | + | commands <- ports.ConfigureWindow{ID: w.ID, Width: 960, Height: 1080, Output: "HEADLESS-1"} | |||
| 121 | + | want = []string{fmt.Sprint("state ", []uint32{uint32(wlr.ZwlrForeignToplevelHandleV1StateMaximized)}), "done"} | |||
| 122 | + | if got := takeEvents(t, c, p.events); !slices.Equal(got, want) { | |||
| 123 | + | t.Fatalf("tiled = %q, want %q", got, want) | |||
| 124 | + | } | |||
| 125 | + | requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor - file") | |||
| 126 | + | if got, want := takeEvents(t, c, p.events), []string{"title editor - file", "done"}; !slices.Equal(got, want) { | |||
| 127 | + | t.Fatalf("title = %q, want %q", got, want) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // Requests reach core as the xdg-shell and xdg-activation messages. | |||
| 131 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetFullscreen, uint32(0)) | |||
| 132 | + | if ev := clientEvent[ports.WindowFullscreenRequest](t, events); ev != (ports.WindowFullscreenRequest{ID: w.ID, Fullscreen: true}) { | |||
| 133 | + | t.Fatalf("set_fullscreen = %#v", ev) | |||
| 134 | + | } | |||
| 135 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestUnsetFullscreen) | |||
| 136 | + | if ev := clientEvent[ports.WindowFullscreenRequest](t, events); ev != (ports.WindowFullscreenRequest{ID: w.ID}) { | |||
| 137 | + | t.Fatalf("unset_fullscreen = %#v", ev) | |||
| 138 | + | } | |||
| 139 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestActivate, seat) | |||
| 140 | + | if ev := clientEvent[ports.WindowActivate](t, events); ev.ID != w.ID { | |||
| 141 | + | t.Fatalf("activate = %#v", ev) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // Unmapping closes the handle; requests on it are then ignored. | |||
| 145 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, uint32(0), int32(0), int32(0)) | |||
| 146 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |||
| 147 | + | if got, want := takeEvents(t, c, p.events), []string{"closed"}; !slices.Equal(got, want) { | |||
| 148 | + | t.Fatalf("unmap = %q, want %q", got, want) | |||
| 149 | + | } | |||
| 150 | + | unmapped(t, events, w.ID) | |||
| 151 | + | ||||
| 152 | + | // Remapping the same toplevel announces a new handle; the closed one | |||
| 153 | + | // stays inert although the window ID is the same. | |||
| 154 | + | serials := make(chan uint32, 8) | |||
| 155 | + | xp := &configureProxy{serial: serials} | |||
| 156 | + | xp.SetID(xdg) | |||
| 157 | + | c.Context().Register(xp) | |||
| 158 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |||
| 159 | + | ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials) | |||
| 160 | + | if again := mapped(t, events, 2*time.Second); again.ID != w.ID { | |||
| 161 | + | t.Fatalf("remapped as %d, want %d", again.ID, w.ID) | |||
| 162 | + | } | |||
| 163 | + | if got := takeEvents(t, c, p.events); len(got) == 0 || got[len(got)-1] != "done" { | |||
| 164 | + | t.Fatalf("remap = %q", got) | |||
| 165 | + | } | |||
| 166 | + | remapped := <-p.handles | |||
| 167 | + | if remapped == handle { | |||
| 168 | + | t.Fatal("remap reused the closed handle") | |||
| 169 | + | } | |||
| 170 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestActivate, seat) | |||
| 171 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestClose) | |||
| 172 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetRectangle, surf, int32(0), int32(0), int32(-1), int32(1)) | |||
| 173 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestDestroy) | |||
| 174 | + | if err := c.Roundtrip(); err != nil { | |||
| 175 | + | t.Fatal(err) | |||
| 176 | + | } | |||
| 177 | + | select { | |||
| 178 | + | case ev := <-events: | |||
| 179 | + | t.Fatalf("event from a closed handle: %#v", ev) | |||
| 180 | + | case <-time.After(100 * time.Millisecond): | |||
| 181 | + | } | |||
| 182 | + | requestProtocol(t, c, remapped, wlr.ZwlrForeignToplevelHandleV1RequestActivate, seat) | |||
| 183 | + | if ev := clientEvent[ports.WindowActivate](t, events); ev.ID != w.ID { | |||
| 184 | + | t.Fatalf("activate remapped = %#v", ev) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // A window mapped after the bind is announced too. | |||
| 188 | + | mapWindow() | |||
| 189 | + | if got := takeEvents(t, c, p.events); len(got) == 0 || got[len(got)-1] != "done" { | |||
| 190 | + | t.Fatalf("new window = %q", got) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // Fullscreen is a version 2 state: version 1 clients never see it. | |||
| 195 | + | func TestForeignToplevelV1HidesFullscreen(t *testing.T) { | |||
| 196 | + | s, events, commands, dir := lifecycleServer(t) | |||
| 197 | + | c := protocolClient(t, s, dir) | |||
| 198 | + | w, _, xdg := surfaceMapper(t, c, events)() | |||
| 199 | + | registerProtocol(t, c, xdg) | |||
| 200 | + | manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 1) | |||
| 201 | + | p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)} | |||
| 202 | + | p.SetID(manager) | |||
| 203 | + | c.Context().Register(p) | |||
| 204 | + | takeEvents(t, c, p.events) | |||
| 205 | + | commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Activated: true} | |||
| 206 | + | want := []string{fmt.Sprint("state ", []uint32{uint32(wlr.ZwlrForeignToplevelHandleV1StateActivated)}), "done"} | |||
| 207 | + | if got := takeEvents(t, c, p.events); !slices.Equal(got, want) { | |||
| 208 | + | t.Fatalf("v1 fullscreen = %q, want %q", got, want) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func TestForeignToplevelInvalidRectangle(t *testing.T) { | |||
| 213 | + | s, events, _, dir := lifecycleServer(t) | |||
| 214 | + | c := protocolClient(t, s, dir) | |||
| 215 | + | _, surf, _ := surfaceMapper(t, c, events)() | |||
| 216 | + | manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3) | |||
| 217 | + | p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)} | |||
| 218 | + | p.SetID(manager) | |||
| 219 | + | c.Context().Register(p) | |||
| 220 | + | takeEvents(t, c, p.events) | |||
| 221 | + | handle := <-p.handles | |||
| 222 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetRectangle, surf, int32(0), int32(0), int32(-1), int32(1)) | |||
| 223 | + | expectProtocolError(t, c, handle, uint32(wlr.ZwlrForeignToplevelHandleV1ErrorInvalidRectangle)) | |||
| 224 | + | } | |||
M internal/adapters/wayland/globals.go +1 -0
| @@ -14,6 +14,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||
| 14 | 14 | func() error { return registerXDGOutput(d, s) }, | |
| 15 | 15 | func() error { return registerOutputManagement(d, s) }, | |
| 16 | 16 | func() error { return registerWorkspaces(d, s) }, | |
| 17 | + | func() error { return registerForeignToplevel(d, s) }, | |
| 17 | 18 | func() error { return registerDecoration(d, s) }, | |
| 18 | 19 | func() error { return registerScale(d, s) }, | |
| 19 | 20 | func() error { return registerCursorShape(d, s) }, | |
| @@ -14,6 +14,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||||
| 14 | func() error { return registerXDGOutput(d, s) }, | 14 | func() error { return registerXDGOutput(d, s) }, | ||
| 15 | func() error { return registerOutputManagement(d, s) }, | 15 | func() error { return registerOutputManagement(d, s) }, | ||
| 16 | func() error { return registerWorkspaces(d, s) }, | 16 | func() error { return registerWorkspaces(d, s) }, | ||
| 17 | + | func() error { return registerForeignToplevel(d, s) }, | |||
| 17 | func() error { return registerDecoration(d, s) }, | 18 | func() error { return registerDecoration(d, s) }, | ||
| 18 | func() error { return registerScale(d, s) }, | 19 | func() error { return registerScale(d, s) }, | ||
| 19 | func() error { return registerCursorShape(d, s) }, | 20 | func() error { return registerCursorShape(d, s) }, | ||
M internal/adapters/wayland/outputs.go +3 -0
| @@ -144,6 +144,7 @@ func (s *Server) addOutput(p ports.OutputPlacement) { | |||
| 144 | 144 | m.res.SendDone() | |
| 145 | 145 | } | |
| 146 | 146 | } | |
| 147 | + | s.refreshToplevels() | |
| 147 | 148 | }) | |
| 148 | 149 | if err != nil { | |
| 149 | 150 | s.log.Warn().Err(err).Str("output", p.Info.Name).Msg("add wl_output") | |
| @@ -196,6 +197,8 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||
| 196 | 197 | } | |
| 197 | 198 | } | |
| 198 | 199 | s.outputs = kept | |
| 200 | + | // Windows of a removed output leave it until core moves them. | |
| 201 | + | s.refreshToplevels() | |
| 199 | 202 | for _, p := range c.Outputs { | |
| 200 | 203 | o := s.outputByNameExact(p.Info.Name) | |
| 201 | 204 | if o == nil { | |
| @@ -144,6 +144,7 @@ func (s *Server) addOutput(p ports.OutputPlacement) { | |||||
| 144 | m.res.SendDone() | 144 | m.res.SendDone() | ||
| 145 | } | 145 | } | ||
| 146 | } | 146 | } | ||
| 147 | + | s.refreshToplevels() | |||
| 147 | }) | 148 | }) | ||
| 148 | if err != nil { | 149 | if err != nil { | ||
| 149 | s.log.Warn().Err(err).Str("output", p.Info.Name).Msg("add wl_output") | 150 | s.log.Warn().Err(err).Str("output", p.Info.Name).Msg("add wl_output") | ||
| @@ -196,6 +197,8 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||||
| 196 | } | 197 | } | ||
| 197 | } | 198 | } | ||
| 198 | s.outputs = kept | 199 | s.outputs = kept | ||
| 200 | + | // Windows of a removed output leave it until core moves them. | |||
| 201 | + | s.refreshToplevels() | |||
| 199 | for _, p := range c.Outputs { | 202 | for _, p := range c.Outputs { | ||
| 200 | o := s.outputByNameExact(p.Info.Name) | 203 | o := s.outputByNameExact(p.Info.Name) | ||
| 201 | if o == nil { | 204 | if o == nil { | ||
M internal/adapters/wayland/server.go +2 -0
| @@ -166,6 +166,7 @@ type Server struct { | |||
| 166 | 166 | outputs []*output | |
| 167 | 167 | outputManagers []*outputManager | |
| 168 | 168 | workspaceManagers []*workspaceManager | |
| 169 | + | toplevelManagers []*toplevelManager | |
| 169 | 170 | workspaceSnapshot ports.Workspaces | |
| 170 | 171 | outputHeads ports.OutputHeads | |
| 171 | 172 | outputPlaces ports.Layout | |
| @@ -684,6 +685,7 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 684 | 685 | scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output | |
| 685 | 686 | w.last, w.hasLast = c, true | |
| 686 | 687 | w.sendConfigure() | |
| 688 | + | s.toplevelChanged(w) | |
| 687 | 689 | if scanoutChanged && w.xdg.surface != nil { | |
| 688 | 690 | s.dmabuf.resendSurface(w.xdg.surface) | |
| 689 | 691 | } | |
| @@ -166,6 +166,7 @@ type Server struct { | |||||
| 166 | outputs []*output | 166 | outputs []*output | ||
| 167 | outputManagers []*outputManager | 167 | outputManagers []*outputManager | ||
| 168 | workspaceManagers []*workspaceManager | 168 | workspaceManagers []*workspaceManager | ||
| 169 | + | toplevelManagers []*toplevelManager | |||
| 169 | workspaceSnapshot ports.Workspaces | 170 | workspaceSnapshot ports.Workspaces | ||
| 170 | outputHeads ports.OutputHeads | 171 | outputHeads ports.OutputHeads | ||
| 171 | outputPlaces ports.Layout | 172 | outputPlaces ports.Layout | ||
| @@ -684,6 +685,7 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 684 | scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output | 685 | scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output | ||
| 685 | w.last, w.hasLast = c, true | 686 | w.last, w.hasLast = c, true | ||
| 686 | w.sendConfigure() | 687 | w.sendConfigure() | ||
| 688 | + | s.toplevelChanged(w) | |||
| 687 | if scanoutChanged && w.xdg.surface != nil { | 689 | if scanoutChanged && w.xdg.surface != nil { | ||
| 688 | s.dmabuf.resendSurface(w.xdg.surface) | 690 | s.dmabuf.resendSurface(w.xdg.surface) | ||
| 689 | } | 691 | } | ||
M internal/adapters/wayland/xdg.go +10 -1
| @@ -192,6 +192,7 @@ func (w *window) unmap() { | |||
| 192 | 192 | return | |
| 193 | 193 | } | |
| 194 | 194 | w.mapped = false | |
| 195 | + | w.xdg.server.toplevelClosed(w.id) | |
| 195 | 196 | w.xdg.surface.resetInputEmission() | |
| 196 | 197 | w.xdg.configured = false | |
| 197 | 198 | w.xdg.acked = false | |
| @@ -232,6 +233,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||
| 232 | 233 | w.floating = w.floats() | |
| 233 | 234 | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH}) | |
| 234 | 235 | x.server.syncInhibitors() | |
| 236 | + | x.server.toplevelChanged(w) | |
| 235 | 237 | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped") | |
| 236 | 238 | } else if !buffer && w.mapped { | |
| 237 | 239 | w.unmap() | |
| @@ -298,7 +300,13 @@ func (t top) SetParent(_ *xdgshell.Toplevel, parent *xdgshell.Toplevel) { | |||
| 298 | 300 | } | |
| 299 | 301 | } | |
| 300 | 302 | } | |
| 301 | - | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title } | |
| 303 | + | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { | |
| 304 | + | if title == t.w.title { | |
| 305 | + | return | |
| 306 | + | } | |
| 307 | + | t.w.title = title | |
| 308 | + | t.w.xdg.server.toplevelChanged(t.w) | |
| 309 | + | } | |
| 302 | 310 | func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | |
| 303 | 311 | if appID == t.w.appID { | |
| 304 | 312 | return | |
| @@ -306,6 +314,7 @@ func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | |||
| 306 | 314 | t.w.appID = appID | |
| 307 | 315 | if t.w.mapped { | |
| 308 | 316 | t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID}) | |
| 317 | + | t.w.xdg.server.toplevelChanged(t.w) | |
| 309 | 318 | } | |
| 310 | 319 | } | |
| 311 | 320 | func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {} | |
| @@ -192,6 +192,7 @@ func (w *window) unmap() { | |||||
| 192 | return | 192 | return | ||
| 193 | } | 193 | } | ||
| 194 | w.mapped = false | 194 | w.mapped = false | ||
| 195 | + | w.xdg.server.toplevelClosed(w.id) | |||
| 195 | w.xdg.surface.resetInputEmission() | 196 | w.xdg.surface.resetInputEmission() | ||
| 196 | w.xdg.configured = false | 197 | w.xdg.configured = false | ||
| 197 | w.xdg.acked = false | 198 | w.xdg.acked = false | ||
| @@ -232,6 +233,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||||
| 232 | w.floating = w.floats() | 233 | w.floating = w.floats() | ||
| 233 | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH}) | 234 | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH}) | ||
| 234 | x.server.syncInhibitors() | 235 | x.server.syncInhibitors() | ||
| 236 | + | x.server.toplevelChanged(w) | |||
| 235 | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped") | 237 | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped") | ||
| 236 | } else if !buffer && w.mapped { | 238 | } else if !buffer && w.mapped { | ||
| 237 | w.unmap() | 239 | w.unmap() | ||
| @@ -298,7 +300,13 @@ func (t top) SetParent(_ *xdgshell.Toplevel, parent *xdgshell.Toplevel) { | |||||
| 298 | } | 300 | } | ||
| 299 | } | 301 | } | ||
| 300 | } | 302 | } | ||
| 301 | - | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title } | 303 | + | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { |
| 304 | + | if title == t.w.title { | |||
| 305 | + | return | |||
| 306 | + | } | |||
| 307 | + | t.w.title = title | |||
| 308 | + | t.w.xdg.server.toplevelChanged(t.w) | |||
| 309 | + | } | |||
| 302 | func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | 310 | func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | ||
| 303 | if appID == t.w.appID { | 311 | if appID == t.w.appID { | ||
| 304 | return | 312 | return | ||
| @@ -306,6 +314,7 @@ func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | |||||
| 306 | t.w.appID = appID | 314 | t.w.appID = appID | ||
| 307 | if t.w.mapped { | 315 | if t.w.mapped { | ||
| 308 | t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID}) | 316 | t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID}) | ||
| 317 | + | t.w.xdg.server.toplevelChanged(t.w) | |||
| 309 | } | 318 | } | ||
| 310 | } | 319 | } | ||
| 311 | func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {} | 320 | func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {} | ||