detached 3aaf9f2

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 UnverifiedB5690EEEBB952194 The key is unknown
7 files · +498 -1
M docs/desktop.md +2 -0
@@ -78,6 +78,8 @@ exec wlopm --off '*'
7878
7979 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.
8080
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+
8183 ## State for scripts
8284
8385 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 scripts83 ## 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 {
1414 func() error { return registerXDGOutput(d, s) },
1515 func() error { return registerOutputManagement(d, s) },
1616 func() error { return registerWorkspaces(d, s) },
17+ func() error { return registerForeignToplevel(d, s) },
1718 func() error { return registerDecoration(d, s) },
1819 func() error { return registerScale(d, s) },
1920 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) {
144144 m.res.SendDone()
145145 }
146146 }
147+ s.refreshToplevels()
147148 })
148149 if err != nil {
149150 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) {
196197 }
197198 }
198199 s.outputs = kept
200+ // Windows of a removed output leave it until core moves them.
201+ s.refreshToplevels()
199202 for _, p := range c.Outputs {
200203 o := s.outputByNameExact(p.Info.Name)
201204 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 = kept199 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 {
166166 outputs []*output
167167 outputManagers []*outputManager
168168 workspaceManagers []*workspaceManager
169+ toplevelManagers []*toplevelManager
169170 workspaceSnapshot ports.Workspaces
170171 outputHeads ports.OutputHeads
171172 outputPlaces ports.Layout
@@ -684,6 +685,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
684685 scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output
685686 w.last, w.hasLast = c, true
686687 w.sendConfigure()
688+ s.toplevelChanged(w)
687689 if scanoutChanged && w.xdg.surface != nil {
688690 s.dmabuf.resendSurface(w.xdg.surface)
689691 }
@@ -166,6 +166,7 @@ type Server struct {
166 outputs []*output166 outputs []*output
167 outputManagers []*outputManager167 outputManagers []*outputManager
168 workspaceManagers []*workspaceManager168 workspaceManagers []*workspaceManager
169+ toplevelManagers []*toplevelManager
169 workspaceSnapshot ports.Workspaces170 workspaceSnapshot ports.Workspaces
170 outputHeads ports.OutputHeads171 outputHeads ports.OutputHeads
171 outputPlaces ports.Layout172 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.Output685 scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output
685 w.last, w.hasLast = c, true686 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() {
192192 return
193193 }
194194 w.mapped = false
195+ w.xdg.server.toplevelClosed(w.id)
195196 w.xdg.surface.resetInputEmission()
196197 w.xdg.configured = false
197198 w.xdg.acked = false
@@ -232,6 +233,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
232233 w.floating = w.floats()
233234 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})
234235 x.server.syncInhibitors()
236+ x.server.toplevelChanged(w)
235237 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
236238 } else if !buffer && w.mapped {
237239 w.unmap()
@@ -298,7 +300,13 @@ func (t top) SetParent(_ *xdgshell.Toplevel, parent *xdgshell.Toplevel) {
298300 }
299301 }
300302 }
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+}
302310 func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {
303311 if appID == t.w.appID {
304312 return
@@ -306,6 +314,7 @@ func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {
306314 t.w.appID = appID
307315 if t.w.mapped {
308316 t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID})
317+ t.w.xdg.server.toplevelChanged(t.w)
309318 }
310319 }
311320 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}
@@ -192,6 +192,7 @@ func (w *window) unmap() {
192 return192 return
193 }193 }
194 w.mapped = false194 w.mapped = false
195+ w.xdg.server.toplevelClosed(w.id)
195 w.xdg.surface.resetInputEmission()196 w.xdg.surface.resetInputEmission()
196 w.xdg.configured = false197 w.xdg.configured = false
197 w.xdg.acked = false198 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 return312 return
@@ -306,6 +314,7 @@ func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {
306 t.w.appID = appID314 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) {}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close