detached e16ebd1

feat(wayland): support ext-workspace-v1

commit
e16ebd124795c1ebe0ead9e50ea9617592290317
author
brice · · 2026-09-27 13:38 UTC
parent
5731344
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +596 -24
M README.md +2 -0
@@ -159,6 +159,8 @@ startup = swayidle -w timeout 300 wlopm-off resume wlopm-on
159159 exec wlopm --off '*'
160160 ```
161161
162+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.
163+
162164 ## State for scripts
163165
164166 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:
@@ -159,6 +159,8 @@ startup = swayidle -w timeout 300 wlopm-off resume wlopm-on
159 exec wlopm --off '*'159 exec wlopm --off '*'
160 ```160 ```
161 161
162+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.
163+
162 ## State for scripts164 ## State for scripts
163 165
164 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:166 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:
M internal/adapters/wayland/globals.go +1 -0
@@ -13,6 +13,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
1313 func() error { return registerLayer(d, s) },
1414 func() error { return registerXDGOutput(d, s) },
1515 func() error { return registerOutputManagement(d, s) },
16+ func() error { return registerWorkspaces(d, s) },
1617 func() error { return registerDecoration(d, s) },
1718 func() error { return registerScale(d, s) },
1819 func() error { return registerCursorShape(d, s) },
@@ -13,6 +13,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
13 func() error { return registerLayer(d, s) },13 func() error { return registerLayer(d, s) },
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 registerDecoration(d, s) },17 func() error { return registerDecoration(d, s) },
17 func() error { return registerScale(d, s) },18 func() error { return registerScale(d, s) },
18 func() error { return registerCursorShape(d, s) },19 func() error { return registerCursorShape(d, s) },
M internal/adapters/wayland/outputs.go +3 -0
@@ -139,6 +139,9 @@ func (s *Server) addOutput(p ports.OutputPlacement) {
139139 o.resources = append(o.resources, r)
140140 r.OnDestroy = func() { o.resources = removeItem(o.resources, r) }
141141 o.sendAll(r)
142+ for _, m := range s.workspaceManagers {
143+ m.outputBound(o.name())
144+ }
142145 })
143146 if err != nil {
144147 s.log.Warn().Err(err).Str("output", p.Info.Name).Msg("add wl_output")
@@ -139,6 +139,9 @@ func (s *Server) addOutput(p ports.OutputPlacement) {
139 o.resources = append(o.resources, r)139 o.resources = append(o.resources, r)
140 r.OnDestroy = func() { o.resources = removeItem(o.resources, r) }140 r.OnDestroy = func() { o.resources = removeItem(o.resources, r) }
141 o.sendAll(r)141 o.sendAll(r)
142+ for _, m := range s.workspaceManagers {
143+ m.outputBound(o.name())
144+ }
142 })145 })
143 if err != nil {146 if err != nil {
144 s.log.Warn().Err(err).Str("output", p.Info.Name).Msg("add wl_output")147 s.log.Warn().Err(err).Str("output", p.Info.Name).Msg("add wl_output")
M internal/adapters/wayland/server.go +22 -18
@@ -37,9 +37,10 @@ type Options struct {
3737
3838 // Channels carries client notifications and commands. Events may be unbuffered.
3939 type Channels struct {
40- Events chan<- ports.ClientEvent
41- Contents chan<- ports.SurfaceContent
42- Commands <-chan ports.ClientCommand
40+ Events chan<- ports.ClientEvent
41+ Contents chan<- ports.SurfaceContent
42+ Commands <-chan ports.ClientCommand
43+ Workspaces <-chan ports.Workspaces
4344 // Cursors receives the cursor the client under the pointer asks for.
4445 Cursors chan<- ports.CursorChange
4546 // Presented paces frame callbacks on the outputs' page flips; outputs
@@ -148,20 +149,22 @@ type Server struct {
148149 shortcutWindows, idleWindows map[ports.WindowID]bool
149150 // idleNotes and powers are the idle notifications and output power
150151 // objects (idle.go); outputsOff the outputs core turned off.
151- idleNotes []*idleNotification
152- powers []*outputPower
153- outputsOff map[string]bool
154- contentNotify chan struct{}
155- contentReady chan struct{}
156- outputs []*output
157- outputManagers []*outputManager
158- outputHeads ports.OutputHeads
159- outputPlaces ports.Layout
160- managementSerial uint32
161- nextOutputApply uint64
162- outputReplies map[uint64]*outputConfiguration
163- focusedOutput string
164- fractions map[*surface]*fractionalscale.WpFractionalScaleV1
152+ idleNotes []*idleNotification
153+ powers []*outputPower
154+ outputsOff map[string]bool
155+ contentNotify chan struct{}
156+ contentReady chan struct{}
157+ outputs []*output
158+ outputManagers []*outputManager
159+ workspaceManagers []*workspaceManager
160+ workspaceSnapshot ports.Workspaces
161+ outputHeads ports.OutputHeads
162+ outputPlaces ports.Layout
163+ managementSerial uint32
164+ nextOutputApply uint64
165+ outputReplies map[uint64]*outputConfiguration
166+ focusedOutput string
167+ fractions map[*surface]*fractionalscale.WpFractionalScaleV1
165168 // cursorSurface is the wl_pointer.set_cursor surface in use.
166169 cursorSurface *surface
167170 // cursorMu guards only the latest cursor change for forwardCursors.
@@ -285,7 +288,8 @@ func (s *Server) Run(ctx context.Context) error {
285288 s.started = time.Now()
286289 s.ctx = ctx
287290 var wg sync.WaitGroup
288- wg.Add(9)
291+ wg.Add(10)
292+ go func() { defer wg.Done(); s.forwardWorkspaces(ctx) }()
289293 go func() { defer wg.Done(); s.forward(ctx) }()
290294 go func() { defer wg.Done(); s.forwardCursors(ctx) }()
291295 go func() { defer wg.Done(); s.forwardContents(ctx) }()
@@ -37,9 +37,10 @@ type Options struct {
37 37
38 // Channels carries client notifications and commands. Events may be unbuffered.38 // Channels carries client notifications and commands. Events may be unbuffered.
39 type Channels struct {39 type Channels struct {
40- Events chan<- ports.ClientEvent40+ Events chan<- ports.ClientEvent
41- Contents chan<- ports.SurfaceContent41+ Contents chan<- ports.SurfaceContent
42- Commands <-chan ports.ClientCommand42+ Commands <-chan ports.ClientCommand
43+ Workspaces <-chan ports.Workspaces
43 // Cursors receives the cursor the client under the pointer asks for.44 // Cursors receives the cursor the client under the pointer asks for.
44 Cursors chan<- ports.CursorChange45 Cursors chan<- ports.CursorChange
45 // Presented paces frame callbacks on the outputs' page flips; outputs46 // Presented paces frame callbacks on the outputs' page flips; outputs
@@ -148,20 +149,22 @@ type Server struct {
148 shortcutWindows, idleWindows map[ports.WindowID]bool149 shortcutWindows, idleWindows map[ports.WindowID]bool
149 // idleNotes and powers are the idle notifications and output power150 // idleNotes and powers are the idle notifications and output power
150 // objects (idle.go); outputsOff the outputs core turned off.151 // objects (idle.go); outputsOff the outputs core turned off.
151- idleNotes []*idleNotification152+ idleNotes []*idleNotification
152- powers []*outputPower153+ powers []*outputPower
153- outputsOff map[string]bool154+ outputsOff map[string]bool
154- contentNotify chan struct{}155+ contentNotify chan struct{}
155- contentReady chan struct{}156+ contentReady chan struct{}
156- outputs []*output157+ outputs []*output
157- outputManagers []*outputManager158+ outputManagers []*outputManager
158- outputHeads ports.OutputHeads159+ workspaceManagers []*workspaceManager
159- outputPlaces ports.Layout160+ workspaceSnapshot ports.Workspaces
160- managementSerial uint32161+ outputHeads ports.OutputHeads
161- nextOutputApply uint64162+ outputPlaces ports.Layout
162- outputReplies map[uint64]*outputConfiguration163+ managementSerial uint32
163- focusedOutput string164+ nextOutputApply uint64
164- fractions map[*surface]*fractionalscale.WpFractionalScaleV1165+ outputReplies map[uint64]*outputConfiguration
166+ focusedOutput string
167+ fractions map[*surface]*fractionalscale.WpFractionalScaleV1
165 // cursorSurface is the wl_pointer.set_cursor surface in use.168 // cursorSurface is the wl_pointer.set_cursor surface in use.
166 cursorSurface *surface169 cursorSurface *surface
167 // cursorMu guards only the latest cursor change for forwardCursors.170 // cursorMu guards only the latest cursor change for forwardCursors.
@@ -285,7 +288,8 @@ func (s *Server) Run(ctx context.Context) error {
285 s.started = time.Now()288 s.started = time.Now()
286 s.ctx = ctx289 s.ctx = ctx
287 var wg sync.WaitGroup290 var wg sync.WaitGroup
288- wg.Add(9)291+ wg.Add(10)
292+ go func() { defer wg.Done(); s.forwardWorkspaces(ctx) }()
289 go func() { defer wg.Done(); s.forward(ctx) }()293 go func() { defer wg.Done(); s.forward(ctx) }()
290 go func() { defer wg.Done(); s.forwardCursors(ctx) }()294 go func() { defer wg.Done(); s.forwardCursors(ctx) }()
291 go func() { defer wg.Done(); s.forwardContents(ctx) }()295 go func() { defer wg.Done(); s.forwardContents(ctx) }()
A internal/adapters/wayland/workspaces.go +232 -0
@@ -0,0 +1,232 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "reflect"
7+ "strconv"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+ ext "github.com/bnema/purego-libwayland/protocol/extworkspace"
11+ "github.com/bnema/purego-libwayland/protocol/wayland"
12+ "github.com/bnema/purego-libwayland/server"
13+)
14+
15+type workspaceManager struct {
16+ s *Server
17+ res *ext.ExtWorkspaceManagerV1
18+ groups map[string]*workspaceGroup
19+ handles map[uint64]*workspaceHandle
20+ pending []uint64
21+}
22+type workspaceGroup struct {
23+ res *ext.ExtWorkspaceGroupHandleV1
24+ output *wayland.Output
25+}
26+type workspaceHandle struct {
27+ res *ext.ExtWorkspaceHandleV1
28+ info ports.WorkspaceInfo
29+ group string
30+}
31+
32+func registerWorkspaces(d *server.Display, s *Server) error {
33+ return ext.NewExtWorkspaceManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
34+ m := &workspaceManager{s: s, groups: map[string]*workspaceGroup{}, handles: map[uint64]*workspaceHandle{}}
35+ r, err := ext.NewExtWorkspaceManagerV1(c, int32(v), id, m)
36+ if err != nil {
37+ return
38+ }
39+ m.res = r
40+ s.workspaceManagers = append(s.workspaceManagers, m)
41+ r.OnDestroy = func() { s.workspaceManagers = removeItem(s.workspaceManagers, m) }
42+ m.update(s.workspaceSnapshot)
43+ })
44+}
45+func (m *workspaceManager) Commit(*ext.ExtWorkspaceManagerV1) {
46+ for _, id := range m.pending {
47+ if h := m.handles[id]; h != nil && h.res.Alive() {
48+ m.s.emit(ports.WorkspaceActivate{ID: id})
49+ }
50+ }
51+ m.pending = nil
52+}
53+func (m *workspaceManager) Stop(*ext.ExtWorkspaceManagerV1) {
54+ m.res.SendFinished()
55+ m.res.Destroy()
56+}
57+func (*workspaceManager) CreateWorkspace(*ext.ExtWorkspaceGroupHandleV1, string) {}
58+func (*workspaceManager) Destroy(*ext.ExtWorkspaceGroupHandleV1) {}
59+func (m *workspaceManager) Activate(r *ext.ExtWorkspaceHandleV1) {
60+ for id, h := range m.handles {
61+ if h.res.Resource == r.Resource {
62+ m.pending = append(m.pending, id)
63+ return
64+ }
65+ }
66+}
67+
68+// workspaceRequests adapts the workspace destroy request separately from group destroy.
69+type workspaceRequests struct{ m *workspaceManager }
70+
71+func (*workspaceRequests) Destroy(*ext.ExtWorkspaceHandleV1) {}
72+func (w *workspaceRequests) Activate(r *ext.ExtWorkspaceHandleV1) { w.m.Activate(r) }
73+func (*workspaceRequests) Deactivate(*ext.ExtWorkspaceHandleV1) {}
74+func (*workspaceRequests) Assign(*ext.ExtWorkspaceHandleV1, *ext.ExtWorkspaceGroupHandleV1) {}
75+func (*workspaceRequests) Remove(*ext.ExtWorkspaceHandleV1) {}
76+
77+func (m *workspaceManager) outputResource(name string) *wayland.Output {
78+ o := m.s.outputByNameExact(name)
79+ if o == nil {
80+ return nil
81+ }
82+ for _, r := range o.resources {
83+ if r.Client() == m.res.Client() && r.Alive() {
84+ return r
85+ }
86+ }
87+ return nil
88+}
89+func (m *workspaceManager) outputBound(name string) {
90+ g := m.groups[name]
91+ if g == nil || !g.res.Alive() {
92+ return
93+ }
94+ r := m.outputResource(name)
95+ if r == g.output {
96+ return
97+ }
98+ if g.output != nil && g.output.Alive() {
99+ g.res.SendOutputLeave(g.output)
100+ }
101+ g.output = r
102+ if r != nil {
103+ g.res.SendOutputEnter(r)
104+ }
105+ m.res.SendDone()
106+}
107+func (s *Server) forwardWorkspaces(ctx context.Context) {
108+ for {
109+ select {
110+ case <-ctx.Done():
111+ return
112+ case <-s.display.Stopped():
113+ return
114+ case snapshot, ok := <-s.channels.Workspaces:
115+ if !ok {
116+ return
117+ }
118+ if !s.display.Do(func() { s.updateWorkspaces(snapshot) }) {
119+ return
120+ }
121+ }
122+ }
123+}
124+func (s *Server) updateWorkspaces(snapshot ports.Workspaces) {
125+ s.workspaceSnapshot = snapshot
126+ for _, m := range s.workspaceManagers {
127+ m.update(snapshot)
128+ }
129+}
130+func (m *workspaceManager) update(snapshot ports.Workspaces) {
131+ if !m.res.Alive() {
132+ return
133+ }
134+ wanted := map[string]bool{}
135+ ids := map[uint64]bool{}
136+ for _, out := range snapshot.Outputs {
137+ wanted[out.Name] = true
138+ for _, w := range out.Workspaces {
139+ ids[w.ID] = true
140+ }
141+ }
142+ // Move existing workspaces before retiring their old output group.
143+ for id, h := range m.handles {
144+ if !ids[id] {
145+ if g := m.groups[h.group]; g != nil && g.res.Alive() && h.res.Alive() {
146+ g.res.SendWorkspaceLeave(h.res)
147+ }
148+ if h.res.Alive() {
149+ h.res.SendRemoved()
150+ }
151+ delete(m.handles, id)
152+ }
153+ }
154+ for _, out := range snapshot.Outputs {
155+ g := m.groups[out.Name]
156+ if g != nil && !g.res.Alive() {
157+ delete(m.groups, out.Name)
158+ g = nil
159+ }
160+ if g == nil {
161+ r, err := ext.NewExtWorkspaceGroupHandleV1(m.res.Client(), 1, 0, m)
162+ if err != nil {
163+ continue
164+ }
165+ g = &workspaceGroup{res: r}
166+ m.groups[out.Name] = g
167+ m.res.SendWorkspaceGroup(r)
168+ r.SendCapabilities(0)
169+ }
170+ m.outputBound(out.Name)
171+ for _, w := range out.Workspaces {
172+ h := m.handles[w.ID]
173+ if h != nil && !h.res.Alive() {
174+ delete(m.handles, w.ID)
175+ h = nil
176+ }
177+ if h == nil {
178+ r, err := ext.NewExtWorkspaceHandleV1(m.res.Client(), 1, 0, &workspaceRequests{m})
179+ if err != nil {
180+ continue
181+ }
182+ h = &workspaceHandle{res: r}
183+ m.handles[w.ID] = h
184+ m.res.SendWorkspace(r)
185+ r.SendCapabilities(uint32(ext.ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate))
186+ }
187+ if h.group != out.Name {
188+ if old := m.groups[h.group]; old != nil && old.res.Alive() && h.res.Alive() {
189+ old.res.SendWorkspaceLeave(h.res)
190+ }
191+ g.res.SendWorkspaceEnter(h.res)
192+ h.group = out.Name
193+ }
194+ if !reflect.DeepEqual(h.info, w) {
195+ if h.info.Name != w.Name {
196+ h.res.SendName(w.Name)
197+ }
198+ if h.info.Index != w.Index || h.info.ID == 0 {
199+ var b [4]byte
200+ binary.NativeEndian.PutUint32(b[:], uint32(w.Index))
201+ h.res.SendCoordinates(b[:])
202+ }
203+ if h.info.Active != w.Active || h.info.Hidden != w.Hidden || h.info.ID == 0 {
204+ var state uint32
205+ if w.Active {
206+ state |= uint32(ext.ExtWorkspaceHandleV1StateActive)
207+ }
208+ if w.Hidden {
209+ state |= uint32(ext.ExtWorkspaceHandleV1StateHidden)
210+ }
211+ h.res.SendState(state)
212+ }
213+ if h.info.ID == 0 && w.Name != "" {
214+ h.res.SendId(strconv.FormatUint(w.ID, 10))
215+ }
216+ h.info = w
217+ }
218+ }
219+ }
220+ for name, g := range m.groups {
221+ if !wanted[name] {
222+ if g.output != nil && g.output.Alive() && g.res.Alive() {
223+ g.res.SendOutputLeave(g.output)
224+ }
225+ if g.res.Alive() {
226+ g.res.SendRemoved()
227+ }
228+ delete(m.groups, name)
229+ }
230+ }
231+ m.res.SendDone()
232+}
@@ -0,0 +1,232 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "reflect"
7+ "strconv"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+ ext "github.com/bnema/purego-libwayland/protocol/extworkspace"
11+ "github.com/bnema/purego-libwayland/protocol/wayland"
12+ "github.com/bnema/purego-libwayland/server"
13+)
14+
15+type workspaceManager struct {
16+ s *Server
17+ res *ext.ExtWorkspaceManagerV1
18+ groups map[string]*workspaceGroup
19+ handles map[uint64]*workspaceHandle
20+ pending []uint64
21+}
22+type workspaceGroup struct {
23+ res *ext.ExtWorkspaceGroupHandleV1
24+ output *wayland.Output
25+}
26+type workspaceHandle struct {
27+ res *ext.ExtWorkspaceHandleV1
28+ info ports.WorkspaceInfo
29+ group string
30+}
31+
32+func registerWorkspaces(d *server.Display, s *Server) error {
33+ return ext.NewExtWorkspaceManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
34+ m := &workspaceManager{s: s, groups: map[string]*workspaceGroup{}, handles: map[uint64]*workspaceHandle{}}
35+ r, err := ext.NewExtWorkspaceManagerV1(c, int32(v), id, m)
36+ if err != nil {
37+ return
38+ }
39+ m.res = r
40+ s.workspaceManagers = append(s.workspaceManagers, m)
41+ r.OnDestroy = func() { s.workspaceManagers = removeItem(s.workspaceManagers, m) }
42+ m.update(s.workspaceSnapshot)
43+ })
44+}
45+func (m *workspaceManager) Commit(*ext.ExtWorkspaceManagerV1) {
46+ for _, id := range m.pending {
47+ if h := m.handles[id]; h != nil && h.res.Alive() {
48+ m.s.emit(ports.WorkspaceActivate{ID: id})
49+ }
50+ }
51+ m.pending = nil
52+}
53+func (m *workspaceManager) Stop(*ext.ExtWorkspaceManagerV1) {
54+ m.res.SendFinished()
55+ m.res.Destroy()
56+}
57+func (*workspaceManager) CreateWorkspace(*ext.ExtWorkspaceGroupHandleV1, string) {}
58+func (*workspaceManager) Destroy(*ext.ExtWorkspaceGroupHandleV1) {}
59+func (m *workspaceManager) Activate(r *ext.ExtWorkspaceHandleV1) {
60+ for id, h := range m.handles {
61+ if h.res.Resource == r.Resource {
62+ m.pending = append(m.pending, id)
63+ return
64+ }
65+ }
66+}
67+
68+// workspaceRequests adapts the workspace destroy request separately from group destroy.
69+type workspaceRequests struct{ m *workspaceManager }
70+
71+func (*workspaceRequests) Destroy(*ext.ExtWorkspaceHandleV1) {}
72+func (w *workspaceRequests) Activate(r *ext.ExtWorkspaceHandleV1) { w.m.Activate(r) }
73+func (*workspaceRequests) Deactivate(*ext.ExtWorkspaceHandleV1) {}
74+func (*workspaceRequests) Assign(*ext.ExtWorkspaceHandleV1, *ext.ExtWorkspaceGroupHandleV1) {}
75+func (*workspaceRequests) Remove(*ext.ExtWorkspaceHandleV1) {}
76+
77+func (m *workspaceManager) outputResource(name string) *wayland.Output {
78+ o := m.s.outputByNameExact(name)
79+ if o == nil {
80+ return nil
81+ }
82+ for _, r := range o.resources {
83+ if r.Client() == m.res.Client() && r.Alive() {
84+ return r
85+ }
86+ }
87+ return nil
88+}
89+func (m *workspaceManager) outputBound(name string) {
90+ g := m.groups[name]
91+ if g == nil || !g.res.Alive() {
92+ return
93+ }
94+ r := m.outputResource(name)
95+ if r == g.output {
96+ return
97+ }
98+ if g.output != nil && g.output.Alive() {
99+ g.res.SendOutputLeave(g.output)
100+ }
101+ g.output = r
102+ if r != nil {
103+ g.res.SendOutputEnter(r)
104+ }
105+ m.res.SendDone()
106+}
107+func (s *Server) forwardWorkspaces(ctx context.Context) {
108+ for {
109+ select {
110+ case <-ctx.Done():
111+ return
112+ case <-s.display.Stopped():
113+ return
114+ case snapshot, ok := <-s.channels.Workspaces:
115+ if !ok {
116+ return
117+ }
118+ if !s.display.Do(func() { s.updateWorkspaces(snapshot) }) {
119+ return
120+ }
121+ }
122+ }
123+}
124+func (s *Server) updateWorkspaces(snapshot ports.Workspaces) {
125+ s.workspaceSnapshot = snapshot
126+ for _, m := range s.workspaceManagers {
127+ m.update(snapshot)
128+ }
129+}
130+func (m *workspaceManager) update(snapshot ports.Workspaces) {
131+ if !m.res.Alive() {
132+ return
133+ }
134+ wanted := map[string]bool{}
135+ ids := map[uint64]bool{}
136+ for _, out := range snapshot.Outputs {
137+ wanted[out.Name] = true
138+ for _, w := range out.Workspaces {
139+ ids[w.ID] = true
140+ }
141+ }
142+ // Move existing workspaces before retiring their old output group.
143+ for id, h := range m.handles {
144+ if !ids[id] {
145+ if g := m.groups[h.group]; g != nil && g.res.Alive() && h.res.Alive() {
146+ g.res.SendWorkspaceLeave(h.res)
147+ }
148+ if h.res.Alive() {
149+ h.res.SendRemoved()
150+ }
151+ delete(m.handles, id)
152+ }
153+ }
154+ for _, out := range snapshot.Outputs {
155+ g := m.groups[out.Name]
156+ if g != nil && !g.res.Alive() {
157+ delete(m.groups, out.Name)
158+ g = nil
159+ }
160+ if g == nil {
161+ r, err := ext.NewExtWorkspaceGroupHandleV1(m.res.Client(), 1, 0, m)
162+ if err != nil {
163+ continue
164+ }
165+ g = &workspaceGroup{res: r}
166+ m.groups[out.Name] = g
167+ m.res.SendWorkspaceGroup(r)
168+ r.SendCapabilities(0)
169+ }
170+ m.outputBound(out.Name)
171+ for _, w := range out.Workspaces {
172+ h := m.handles[w.ID]
173+ if h != nil && !h.res.Alive() {
174+ delete(m.handles, w.ID)
175+ h = nil
176+ }
177+ if h == nil {
178+ r, err := ext.NewExtWorkspaceHandleV1(m.res.Client(), 1, 0, &workspaceRequests{m})
179+ if err != nil {
180+ continue
181+ }
182+ h = &workspaceHandle{res: r}
183+ m.handles[w.ID] = h
184+ m.res.SendWorkspace(r)
185+ r.SendCapabilities(uint32(ext.ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate))
186+ }
187+ if h.group != out.Name {
188+ if old := m.groups[h.group]; old != nil && old.res.Alive() && h.res.Alive() {
189+ old.res.SendWorkspaceLeave(h.res)
190+ }
191+ g.res.SendWorkspaceEnter(h.res)
192+ h.group = out.Name
193+ }
194+ if !reflect.DeepEqual(h.info, w) {
195+ if h.info.Name != w.Name {
196+ h.res.SendName(w.Name)
197+ }
198+ if h.info.Index != w.Index || h.info.ID == 0 {
199+ var b [4]byte
200+ binary.NativeEndian.PutUint32(b[:], uint32(w.Index))
201+ h.res.SendCoordinates(b[:])
202+ }
203+ if h.info.Active != w.Active || h.info.Hidden != w.Hidden || h.info.ID == 0 {
204+ var state uint32
205+ if w.Active {
206+ state |= uint32(ext.ExtWorkspaceHandleV1StateActive)
207+ }
208+ if w.Hidden {
209+ state |= uint32(ext.ExtWorkspaceHandleV1StateHidden)
210+ }
211+ h.res.SendState(state)
212+ }
213+ if h.info.ID == 0 && w.Name != "" {
214+ h.res.SendId(strconv.FormatUint(w.ID, 10))
215+ }
216+ h.info = w
217+ }
218+ }
219+ }
220+ for name, g := range m.groups {
221+ if !wanted[name] {
222+ if g.output != nil && g.output.Alive() && g.res.Alive() {
223+ g.res.SendOutputLeave(g.output)
224+ }
225+ if g.res.Alive() {
226+ g.res.SendRemoved()
227+ }
228+ delete(m.groups, name)
229+ }
230+ }
231+ m.res.SendDone()
232+}
A internal/adapters/wayland/workspaces_test.go +165 -0
@@ -0,0 +1,165 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/logging"
9+ "github.com/bnema/neferwl/internal/ports"
10+ ext "github.com/bnema/purego-libwayland/protocol/extworkspace"
11+ "github.com/bnema/wlturbo"
12+)
13+
14+type workspaceEvents struct {
15+ wlturbo.BaseProxy
16+ client *wlturbo.Display
17+ events chan [2]uint32
18+}
19+
20+func (p *workspaceEvents) Dispatch(e *wlturbo.Event) {
21+ v := [2]uint32{uint32(e.Opcode), 0}
22+ if e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup) || e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspace) {
23+ v[1] = e.Uint32()
24+ child := &workspaceChildEvents{events: p.events, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)}
25+ child.SetID(v[1])
26+ p.client.Context().Register(child)
27+ }
28+ p.events <- v
29+}
30+
31+type workspaceChildEvents struct {
32+ wlturbo.BaseProxy
33+ events chan [2]uint32
34+ group bool
35+}
36+
37+func (p *workspaceChildEvents) Dispatch(e *wlturbo.Event) {
38+ // Encode the interface in the upper byte to distinguish manager, group and handle.
39+ kind := uint32(2)
40+ if p.group {
41+ kind = 1
42+ }
43+ v := [2]uint32{kind<<8 | uint32(e.Opcode), 0}
44+ switch {
45+ case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputLeave)):
46+ v[1] = e.Uint32()
47+ case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceLeave)):
48+ v[1] = e.Uint32()
49+ case !p.group && (e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventState) || e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventCapabilities)):
50+ v[1] = e.Uint32()
51+ }
52+ p.events <- v
53+}
54+func TestWorkspaceProtocol(t *testing.T) {
55+ dir := t.TempDir()
56+ events := make(chan ports.ClientEvent, 16)
57+ snapshots := make(chan ports.Workspaces, 1)
58+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ ctx, cancel := context.WithCancel(context.Background())
63+ done := make(chan error, 1)
64+ go func() { done <- s.Run(ctx) }()
65+ t.Cleanup(func() {
66+ cancel()
67+ if err := <-done; err != nil {
68+ t.Error(err)
69+ }
70+ })
71+ c := protocolClient(t, s, dir)
72+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}}}}}
73+ if !s.display.Do(func() {}) {
74+ t.Fatal("display stopped")
75+ }
76+ managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
77+ p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128)}
78+ p.SetID(managerID)
79+ c.Context().Register(p)
80+ var handle uint32
81+ for i := 0; i < 8 && handle == 0; i++ {
82+ if err := c.Roundtrip(); err != nil {
83+ t.Fatal(err)
84+ }
85+ for len(p.events) > 0 {
86+ v := <-p.events
87+ if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace {
88+ handle = v[1]
89+ }
90+ }
91+ }
92+ if handle == 0 {
93+ t.Fatal("workspace not announced")
94+ }
95+ out := bindProtocol(t, c, "wl_output")
96+ registerProtocol(t, c, out)
97+ entered := false
98+ for i := 0; i < 8 && !entered; i++ {
99+ if err := c.Roundtrip(); err != nil {
100+ t.Fatal(err)
101+ }
102+ for len(p.events) > 0 {
103+ v := <-p.events
104+ if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} {
105+ entered = true
106+ }
107+ }
108+ }
109+ if !entered {
110+ t.Fatal("late wl_output did not enter group")
111+ }
112+ requestProtocol(t, c, handle, ext.ExtWorkspaceHandleV1RequestActivate)
113+ if err := c.Roundtrip(); err != nil {
114+ t.Fatal(err)
115+ }
116+ select {
117+ case v := <-events:
118+ t.Fatalf("activation before commit: %v", v)
119+ default:
120+ }
121+ requestProtocol(t, c, managerID, ext.ExtWorkspaceManagerV1RequestCommit)
122+ if err := c.Roundtrip(); err != nil {
123+ t.Fatal(err)
124+ }
125+ select {
126+ case v := <-events:
127+ if v != (ports.WorkspaceActivate{ID: 42}) {
128+ t.Fatal(v)
129+ }
130+ case <-time.After(time.Second):
131+ t.Fatal("no activation")
132+ }
133+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Hidden: true}}}}}
134+ found := false
135+ for i := 0; i < 8 && !found; i++ {
136+ if err := c.Roundtrip(); err != nil {
137+ t.Fatal(err)
138+ }
139+ for len(p.events) > 0 {
140+ v := <-p.events
141+ if v == [2]uint32{2<<8 | ext.ExtWorkspaceHandleV1EventState, uint32(ext.ExtWorkspaceHandleV1StateHidden)} {
142+ found = true
143+ }
144+ }
145+ }
146+ if !found {
147+ t.Fatal("no state change")
148+ }
149+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
150+ found = false
151+ for i := 0; i < 8 && !found; i++ {
152+ if err := c.Roundtrip(); err != nil {
153+ t.Fatal(err)
154+ }
155+ for len(p.events) > 0 {
156+ v := <-p.events
157+ if v[0] == 1<<8|ext.ExtWorkspaceGroupHandleV1EventRemoved {
158+ found = true
159+ }
160+ }
161+ }
162+ if !found {
163+ t.Fatal("group not removed")
164+ }
165+}
@@ -0,0 +1,165 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/logging"
9+ "github.com/bnema/neferwl/internal/ports"
10+ ext "github.com/bnema/purego-libwayland/protocol/extworkspace"
11+ "github.com/bnema/wlturbo"
12+)
13+
14+type workspaceEvents struct {
15+ wlturbo.BaseProxy
16+ client *wlturbo.Display
17+ events chan [2]uint32
18+}
19+
20+func (p *workspaceEvents) Dispatch(e *wlturbo.Event) {
21+ v := [2]uint32{uint32(e.Opcode), 0}
22+ if e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup) || e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspace) {
23+ v[1] = e.Uint32()
24+ child := &workspaceChildEvents{events: p.events, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)}
25+ child.SetID(v[1])
26+ p.client.Context().Register(child)
27+ }
28+ p.events <- v
29+}
30+
31+type workspaceChildEvents struct {
32+ wlturbo.BaseProxy
33+ events chan [2]uint32
34+ group bool
35+}
36+
37+func (p *workspaceChildEvents) Dispatch(e *wlturbo.Event) {
38+ // Encode the interface in the upper byte to distinguish manager, group and handle.
39+ kind := uint32(2)
40+ if p.group {
41+ kind = 1
42+ }
43+ v := [2]uint32{kind<<8 | uint32(e.Opcode), 0}
44+ switch {
45+ case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputLeave)):
46+ v[1] = e.Uint32()
47+ case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceLeave)):
48+ v[1] = e.Uint32()
49+ case !p.group && (e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventState) || e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventCapabilities)):
50+ v[1] = e.Uint32()
51+ }
52+ p.events <- v
53+}
54+func TestWorkspaceProtocol(t *testing.T) {
55+ dir := t.TempDir()
56+ events := make(chan ports.ClientEvent, 16)
57+ snapshots := make(chan ports.Workspaces, 1)
58+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ ctx, cancel := context.WithCancel(context.Background())
63+ done := make(chan error, 1)
64+ go func() { done <- s.Run(ctx) }()
65+ t.Cleanup(func() {
66+ cancel()
67+ if err := <-done; err != nil {
68+ t.Error(err)
69+ }
70+ })
71+ c := protocolClient(t, s, dir)
72+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}}}}}
73+ if !s.display.Do(func() {}) {
74+ t.Fatal("display stopped")
75+ }
76+ managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
77+ p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128)}
78+ p.SetID(managerID)
79+ c.Context().Register(p)
80+ var handle uint32
81+ for i := 0; i < 8 && handle == 0; i++ {
82+ if err := c.Roundtrip(); err != nil {
83+ t.Fatal(err)
84+ }
85+ for len(p.events) > 0 {
86+ v := <-p.events
87+ if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace {
88+ handle = v[1]
89+ }
90+ }
91+ }
92+ if handle == 0 {
93+ t.Fatal("workspace not announced")
94+ }
95+ out := bindProtocol(t, c, "wl_output")
96+ registerProtocol(t, c, out)
97+ entered := false
98+ for i := 0; i < 8 && !entered; i++ {
99+ if err := c.Roundtrip(); err != nil {
100+ t.Fatal(err)
101+ }
102+ for len(p.events) > 0 {
103+ v := <-p.events
104+ if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} {
105+ entered = true
106+ }
107+ }
108+ }
109+ if !entered {
110+ t.Fatal("late wl_output did not enter group")
111+ }
112+ requestProtocol(t, c, handle, ext.ExtWorkspaceHandleV1RequestActivate)
113+ if err := c.Roundtrip(); err != nil {
114+ t.Fatal(err)
115+ }
116+ select {
117+ case v := <-events:
118+ t.Fatalf("activation before commit: %v", v)
119+ default:
120+ }
121+ requestProtocol(t, c, managerID, ext.ExtWorkspaceManagerV1RequestCommit)
122+ if err := c.Roundtrip(); err != nil {
123+ t.Fatal(err)
124+ }
125+ select {
126+ case v := <-events:
127+ if v != (ports.WorkspaceActivate{ID: 42}) {
128+ t.Fatal(v)
129+ }
130+ case <-time.After(time.Second):
131+ t.Fatal("no activation")
132+ }
133+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Hidden: true}}}}}
134+ found := false
135+ for i := 0; i < 8 && !found; i++ {
136+ if err := c.Roundtrip(); err != nil {
137+ t.Fatal(err)
138+ }
139+ for len(p.events) > 0 {
140+ v := <-p.events
141+ if v == [2]uint32{2<<8 | ext.ExtWorkspaceHandleV1EventState, uint32(ext.ExtWorkspaceHandleV1StateHidden)} {
142+ found = true
143+ }
144+ }
145+ }
146+ if !found {
147+ t.Fatal("no state change")
148+ }
149+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
150+ found = false
151+ for i := 0; i < 8 && !found; i++ {
152+ if err := c.Roundtrip(); err != nil {
153+ t.Fatal(err)
154+ }
155+ for len(p.events) > 0 {
156+ v := <-p.events
157+ if v[0] == 1<<8|ext.ExtWorkspaceGroupHandleV1EventRemoved {
158+ found = true
159+ }
160+ }
161+ }
162+ if !found {
163+ t.Fatal("group not removed")
164+ }
165+}
M internal/app/app.go +3 -2
@@ -85,6 +85,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
8585 layouts := make(chan ports.Layout, 1)
8686 constraints := make(chan ports.PointerConstraint, 1)
8787 states := make(chan ports.State, 1)
88+ workspaces := make(chan ports.Workspaces, 1)
8889 configErrors := make(chan error, 8)
8990 renderScenes := make(chan []ports.Scene, 1)
9091 contents := make(chan ports.SurfaceContent, 64)
@@ -99,7 +100,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
99100 appliedOutput := make(chan ports.OutputApplied, 8)
100101 backendConfig := make(chan ports.Config, 8)
101102 backendApplied := make(chan error, 8)
102- ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
103+ ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
103104 c, err := core.New(opts.Config, ch)
104105 if err != nil {
105106 return err
@@ -113,7 +114,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
113114 // Every output renders on the same GPU: its formats are the clients'.
114115 dmabuf := vulkan.Probe()
115116 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
116- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland"))
117+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Workspaces: workspaces, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland"))
117118 if err != nil {
118119 km.Close()
119120 return err
@@ -85,6 +85,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
85 layouts := make(chan ports.Layout, 1)85 layouts := make(chan ports.Layout, 1)
86 constraints := make(chan ports.PointerConstraint, 1)86 constraints := make(chan ports.PointerConstraint, 1)
87 states := make(chan ports.State, 1)87 states := make(chan ports.State, 1)
88+ workspaces := make(chan ports.Workspaces, 1)
88 configErrors := make(chan error, 8)89 configErrors := make(chan error, 8)
89 renderScenes := make(chan []ports.Scene, 1)90 renderScenes := make(chan []ports.Scene, 1)
90 contents := make(chan ports.SurfaceContent, 64)91 contents := make(chan ports.SurfaceContent, 64)
@@ -99,7 +100,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
99 appliedOutput := make(chan ports.OutputApplied, 8)100 appliedOutput := make(chan ports.OutputApplied, 8)
100 backendConfig := make(chan ports.Config, 8)101 backendConfig := make(chan ports.Config, 8)
101 backendApplied := make(chan error, 8)102 backendApplied := make(chan error, 8)
102- ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}103+ ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
103 c, err := core.New(opts.Config, ch)104 c, err := core.New(opts.Config, ch)
104 if err != nil {105 if err != nil {
105 return err106 return err
@@ -113,7 +114,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
113 // Every output renders on the same GPU: its formats are the clients'.114 // Every output renders on the same GPU: its formats are the clients'.
114 dmabuf := vulkan.Probe()115 dmabuf := vulkan.Probe()
115 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")116 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
116- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland"))117+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Workspaces: workspaces, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland"))
117 if err != nil {118 if err != nil {
118 km.Close()119 km.Close()
119 return err120 return err
M internal/core/core.go +19 -3
@@ -36,7 +36,8 @@ type Channels struct {
3636 ConfigErrors chan<- error
3737 // State, when set, receives a snapshot for scripts whenever it changes,
3838 // latest first (capacity 1, drained like Scenes).
39- State chan ports.State
39+ State chan ports.State
40+ Workspaces chan ports.Workspaces
4041 // Terminal, when set, keeps a window on every workspace on screen: an
4142 // empty one gets the configured terminal (the terminal is the desktop).
4243 Terminal bool
@@ -77,8 +78,9 @@ type Core struct {
7778 // token, until their window maps.
7879 terms map[string]*termSpawn
7980 // clients holds the app ID and PID of mapped windows, for State.
80- clients map[WindowID]ports.WindowMapped
81- sentState ports.State
81+ clients map[WindowID]ports.WindowMapped
82+ sentState ports.State
83+ sentWorkspaces ports.Workspaces
8284 // popups are the placed xdg_popups; popupOrder stacks them, oldest first.
8385 popups map[WindowID]*popupState
8486 popupOrder []WindowID
@@ -471,6 +473,7 @@ func (c *Core) publish(ctx context.Context) error {
471473 c.resolveConstraint()
472474 latest(c.ch.Scenes, scenes)
473475 c.publishState()
476+ c.publishWorkspaces()
474477 return nil
475478 }
476479
@@ -623,6 +626,7 @@ func (c *Core) Run(ctx context.Context) error {
623626 return nil
624627 }
625628 c.publishState()
629+ c.publishWorkspaces()
626630 for {
627631 select {
628632 case <-ctx.Done():
@@ -714,6 +718,18 @@ func (c *Core) Run(ctx context.Context) error {
714718 if s, _ := c.screenOf(v.ID); s != nil {
715719 s.mon.SetFullscreen(v.ID, v.Fullscreen)
716720 }
721+ case ports.WorkspaceActivate:
722+ for _, sc := range c.screens {
723+ if sc.name() == "" {
724+ continue
725+ }
726+ for _, w := range sc.mon.all() {
727+ if w.ID == v.ID {
728+ sc.mon.show(w)
729+ break
730+ }
731+ }
732+ }
717733 case ports.WindowActivate:
718734 if c.activate(ctx, v.ID) != nil {
719735 return nil
@@ -36,7 +36,8 @@ type Channels struct {
36 ConfigErrors chan<- error36 ConfigErrors chan<- error
37 // State, when set, receives a snapshot for scripts whenever it changes,37 // State, when set, receives a snapshot for scripts whenever it changes,
38 // latest first (capacity 1, drained like Scenes).38 // latest first (capacity 1, drained like Scenes).
39- State chan ports.State39+ State chan ports.State
40+ Workspaces chan ports.Workspaces
40 // Terminal, when set, keeps a window on every workspace on screen: an41 // Terminal, when set, keeps a window on every workspace on screen: an
41 // empty one gets the configured terminal (the terminal is the desktop).42 // empty one gets the configured terminal (the terminal is the desktop).
42 Terminal bool43 Terminal bool
@@ -77,8 +78,9 @@ type Core struct {
77 // token, until their window maps.78 // token, until their window maps.
78 terms map[string]*termSpawn79 terms map[string]*termSpawn
79 // clients holds the app ID and PID of mapped windows, for State.80 // clients holds the app ID and PID of mapped windows, for State.
80- clients map[WindowID]ports.WindowMapped81+ clients map[WindowID]ports.WindowMapped
81- sentState ports.State82+ sentState ports.State
83+ sentWorkspaces ports.Workspaces
82 // popups are the placed xdg_popups; popupOrder stacks them, oldest first.84 // popups are the placed xdg_popups; popupOrder stacks them, oldest first.
83 popups map[WindowID]*popupState85 popups map[WindowID]*popupState
84 popupOrder []WindowID86 popupOrder []WindowID
@@ -471,6 +473,7 @@ func (c *Core) publish(ctx context.Context) error {
471 c.resolveConstraint()473 c.resolveConstraint()
472 latest(c.ch.Scenes, scenes)474 latest(c.ch.Scenes, scenes)
473 c.publishState()475 c.publishState()
476+ c.publishWorkspaces()
474 return nil477 return nil
475 }478 }
476 479
@@ -623,6 +626,7 @@ func (c *Core) Run(ctx context.Context) error {
623 return nil626 return nil
624 }627 }
625 c.publishState()628 c.publishState()
629+ c.publishWorkspaces()
626 for {630 for {
627 select {631 select {
628 case <-ctx.Done():632 case <-ctx.Done():
@@ -714,6 +718,18 @@ func (c *Core) Run(ctx context.Context) error {
714 if s, _ := c.screenOf(v.ID); s != nil {718 if s, _ := c.screenOf(v.ID); s != nil {
715 s.mon.SetFullscreen(v.ID, v.Fullscreen)719 s.mon.SetFullscreen(v.ID, v.Fullscreen)
716 }720 }
721+ case ports.WorkspaceActivate:
722+ for _, sc := range c.screens {
723+ if sc.name() == "" {
724+ continue
725+ }
726+ for _, w := range sc.mon.all() {
727+ if w.ID == v.ID {
728+ sc.mon.show(w)
729+ break
730+ }
731+ }
732+ }
717 case ports.WindowActivate:733 case ports.WindowActivate:
718 if c.activate(ctx, v.ID) != nil {734 if c.activate(ctx, v.ID) != nil {
719 return nil735 return nil
M internal/core/layout.go +2 -0
@@ -114,6 +114,8 @@ const (
114114 )
115115
116116 type Workspace struct {
117+ // ID stays with the workspace across reorder and monitor moves.
118+ ID uint64
117119 // Name is set for workspaces declared in config; empty for dynamic ones.
118120 Name string
119121 Overflow Overflow
@@ -114,6 +114,8 @@ const (
114 )114 )
115 115
116 type Workspace struct {116 type Workspace struct {
117+ // ID stays with the workspace across reorder and monitor moves.
118+ ID uint64
117 // Name is set for workspaces declared in config; empty for dynamic ones.119 // Name is set for workspaces declared in config; empty for dynamic ones.
118 Name string120 Name string
119 Overflow Overflow121 Overflow Overflow
M internal/core/monitor.go +7 -1
@@ -1,6 +1,11 @@
11 package core
22
3-import "slices"
3+import (
4+ "slices"
5+ "sync/atomic"
6+)
7+
8+var nextWorkspaceID atomic.Uint64
49
510 // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
611 // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits
@@ -120,6 +125,7 @@ func (m *Monitor) newWorkspace() *Workspace {
120125 w := m.template
121126 w.presets = append([]Width(nil), m.template.presets...)
122127 w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{}
128+ w.ID = nextWorkspaceID.Add(1)
123129 return &w
124130 }
125131
@@ -1,6 +1,11 @@
1 package core1 package core
2 2
3-import "slices"3+import (
4+ "slices"
5+ "sync/atomic"
6+)
7+
8+var nextWorkspaceID atomic.Uint64
4 9
5 // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers10 // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
6 // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits11 // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits
@@ -120,6 +125,7 @@ func (m *Monitor) newWorkspace() *Workspace {
120 w := m.template125 w := m.template
121 w.presets = append([]Width(nil), m.template.presets...)126 w.presets = append([]Width(nil), m.template.presets...)
122 w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{}127 w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{}
128+ w.ID = nextWorkspaceID.Add(1)
123 return &w129 return &w
124 }130 }
125 131
A internal/core/workspaces.go +37 -0
@@ -0,0 +1,37 @@
1+package core
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "reflect"
6+ "strconv"
7+)
8+
9+// publishWorkspaces describes the numbered vertical stack and configured hidden
10+// workspaces. Empty numbered spares are hidden unless currently shown; unlike
11+// the script State snapshot, hidden named workspaces remain addressable.
12+func (c *Core) publishWorkspaces() {
13+ if c.ch.Workspaces == nil {
14+ return
15+ }
16+ snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
17+ for _, sc := range c.screens {
18+ if sc.name() == "" {
19+ continue
20+ }
21+ m := sc.mon
22+ out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: []ports.WorkspaceInfo{}}
23+ for i, w := range m.all() {
24+ name := w.Name
25+ if name == "" {
26+ name = strconv.Itoa(i + 1)
27+ }
28+ out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: w.empty() && m.Current() != w})
29+ }
30+ snapshot.Outputs = append(snapshot.Outputs, out)
31+ }
32+ if reflect.DeepEqual(snapshot, c.sentWorkspaces) {
33+ return
34+ }
35+ c.sentWorkspaces = snapshot
36+ latest(c.ch.Workspaces, snapshot)
37+}
@@ -0,0 +1,37 @@
1+package core
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "reflect"
6+ "strconv"
7+)
8+
9+// publishWorkspaces describes the numbered vertical stack and configured hidden
10+// workspaces. Empty numbered spares are hidden unless currently shown; unlike
11+// the script State snapshot, hidden named workspaces remain addressable.
12+func (c *Core) publishWorkspaces() {
13+ if c.ch.Workspaces == nil {
14+ return
15+ }
16+ snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
17+ for _, sc := range c.screens {
18+ if sc.name() == "" {
19+ continue
20+ }
21+ m := sc.mon
22+ out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: []ports.WorkspaceInfo{}}
23+ for i, w := range m.all() {
24+ name := w.Name
25+ if name == "" {
26+ name = strconv.Itoa(i + 1)
27+ }
28+ out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: w.empty() && m.Current() != w})
29+ }
30+ snapshot.Outputs = append(snapshot.Outputs, out)
31+ }
32+ if reflect.DeepEqual(snapshot, c.sentWorkspaces) {
33+ return
34+ }
35+ c.sentWorkspaces = snapshot
36+ latest(c.ch.Workspaces, snapshot)
37+}
A internal/core/workspaces_test.go +85 -0
@@ -0,0 +1,85 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/adapters/config"
8+ "github.com/bnema/neferwl/internal/core"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+func TestWorkspaceSnapshotsAndActivation(t *testing.T) {
13+ cfg := config.Defaults()
14+ client := make(chan ports.ClientEvent, 16)
15+ input := make(chan ports.InputEvent, 8)
16+ output := make(chan ports.OutputEvent, 8)
17+ commands := make(chan ports.ClientCommand, 32)
18+ snapshots := make(chan ports.Workspaces, 1)
19+ scenes := make(chan []ports.Scene, 1)
20+ spawn := make(chan ports.SpawnRequest, 8)
21+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, Scenes: scenes, Spawn: spawn})
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ ctx, cancel := context.WithCancel(context.Background())
26+ done := make(chan error, 1)
27+ go func() { done <- c.Run(ctx) }()
28+ t.Cleanup(func() {
29+ cancel()
30+ if err := <-done; err != nil {
31+ t.Error(err)
32+ }
33+ })
34+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 100, Height: 80}}
35+ first := receive(t, snapshots)
36+ for len(first.Outputs) == 0 {
37+ first = receive(t, snapshots)
38+ }
39+ if len(first.Outputs) != 1 || len(first.Outputs[0].Workspaces) != 1 || !first.Outputs[0].Workspaces[0].Active {
40+ t.Fatal(first)
41+ }
42+ id := first.Outputs[0].Workspaces[0].ID
43+ client <- ports.WindowMapped{ID: 1}
44+ added := receive(t, snapshots)
45+ if len(added.Outputs[0].Workspaces) < 2 || added.Outputs[0].Workspaces[0].ID != id || !added.Outputs[0].Workspaces[1].Hidden {
46+ t.Fatal(added)
47+ }
48+ spare := added.Outputs[0].Workspaces[1].ID
49+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "B", Width: 100, Height: 80}}
50+ two := receive(t, snapshots)
51+ if len(two.Outputs) != 2 || two.Outputs[0].Workspaces[0].ID != id {
52+ t.Fatal(two)
53+ }
54+ client <- ports.WorkspaceActivate{ID: spare}
55+ active := receive(t, snapshots)
56+ if !active.Outputs[0].Workspaces[1].Active || active.Outputs[1].Workspaces[0].Active != true {
57+ t.Fatal(active)
58+ }
59+ client <- ports.WorkspaceActivate{ID: 999999999}
60+ scene(t, scenes)
61+ select {
62+ case v := <-snapshots:
63+ t.Fatalf("unknown ID changed snapshot: %v", v)
64+ default:
65+ }
66+ // Move the populated workspace right; its ID follows it.
67+ client <- ports.WorkspaceActivate{ID: id}
68+ receive(t, snapshots)
69+ input <- ports.KeyEvent{Keysym: "Right", Mods: ports.ModSuper | ports.ModCtrl | ports.ModShift, Pressed: true}
70+ move := receive(t, snapshots)
71+ found := false
72+ for _, w := range move.Outputs[1].Workspaces {
73+ if w.ID == id {
74+ found = true
75+ }
76+ }
77+ if !found {
78+ t.Fatalf("workspace ID lost on move: %+v", move)
79+ }
80+ output <- ports.OutputRemoved{Name: "B"}
81+ moved := receive(t, snapshots)
82+ if len(moved.Outputs) != 1 || moved.Outputs[0].Name != "A" {
83+ t.Fatal(moved)
84+ }
85+}
@@ -0,0 +1,85 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/adapters/config"
8+ "github.com/bnema/neferwl/internal/core"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+func TestWorkspaceSnapshotsAndActivation(t *testing.T) {
13+ cfg := config.Defaults()
14+ client := make(chan ports.ClientEvent, 16)
15+ input := make(chan ports.InputEvent, 8)
16+ output := make(chan ports.OutputEvent, 8)
17+ commands := make(chan ports.ClientCommand, 32)
18+ snapshots := make(chan ports.Workspaces, 1)
19+ scenes := make(chan []ports.Scene, 1)
20+ spawn := make(chan ports.SpawnRequest, 8)
21+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, Scenes: scenes, Spawn: spawn})
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ ctx, cancel := context.WithCancel(context.Background())
26+ done := make(chan error, 1)
27+ go func() { done <- c.Run(ctx) }()
28+ t.Cleanup(func() {
29+ cancel()
30+ if err := <-done; err != nil {
31+ t.Error(err)
32+ }
33+ })
34+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 100, Height: 80}}
35+ first := receive(t, snapshots)
36+ for len(first.Outputs) == 0 {
37+ first = receive(t, snapshots)
38+ }
39+ if len(first.Outputs) != 1 || len(first.Outputs[0].Workspaces) != 1 || !first.Outputs[0].Workspaces[0].Active {
40+ t.Fatal(first)
41+ }
42+ id := first.Outputs[0].Workspaces[0].ID
43+ client <- ports.WindowMapped{ID: 1}
44+ added := receive(t, snapshots)
45+ if len(added.Outputs[0].Workspaces) < 2 || added.Outputs[0].Workspaces[0].ID != id || !added.Outputs[0].Workspaces[1].Hidden {
46+ t.Fatal(added)
47+ }
48+ spare := added.Outputs[0].Workspaces[1].ID
49+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "B", Width: 100, Height: 80}}
50+ two := receive(t, snapshots)
51+ if len(two.Outputs) != 2 || two.Outputs[0].Workspaces[0].ID != id {
52+ t.Fatal(two)
53+ }
54+ client <- ports.WorkspaceActivate{ID: spare}
55+ active := receive(t, snapshots)
56+ if !active.Outputs[0].Workspaces[1].Active || active.Outputs[1].Workspaces[0].Active != true {
57+ t.Fatal(active)
58+ }
59+ client <- ports.WorkspaceActivate{ID: 999999999}
60+ scene(t, scenes)
61+ select {
62+ case v := <-snapshots:
63+ t.Fatalf("unknown ID changed snapshot: %v", v)
64+ default:
65+ }
66+ // Move the populated workspace right; its ID follows it.
67+ client <- ports.WorkspaceActivate{ID: id}
68+ receive(t, snapshots)
69+ input <- ports.KeyEvent{Keysym: "Right", Mods: ports.ModSuper | ports.ModCtrl | ports.ModShift, Pressed: true}
70+ move := receive(t, snapshots)
71+ found := false
72+ for _, w := range move.Outputs[1].Workspaces {
73+ if w.ID == id {
74+ found = true
75+ }
76+ }
77+ if !found {
78+ t.Fatalf("workspace ID lost on move: %+v", move)
79+ }
80+ output <- ports.OutputRemoved{Name: "B"}
81+ moved := receive(t, snapshots)
82+ if len(moved.Outputs) != 1 || moved.Outputs[0].Name != "A" {
83+ t.Fatal(moved)
84+ }
85+}
M internal/ports/messages.go +18 -0
@@ -97,6 +97,24 @@ type WindowActivate struct{ ID WindowID }
9797
9898 func (WindowActivate) clientEvent() {}
9999
100+// WorkspaceActivate requests showing a workspace by its stable ID, on its owning monitor.
101+type WorkspaceActivate struct{ ID uint64 }
102+
103+func (WorkspaceActivate) clientEvent() {}
104+
105+// Workspaces is an immutable, latest-only inventory of output workspaces.
106+type Workspaces struct{ Outputs []WorkspaceOutput }
107+type WorkspaceOutput struct {
108+ Name string
109+ Workspaces []WorkspaceInfo
110+}
111+type WorkspaceInfo struct {
112+ ID uint64
113+ Name string
114+ Index int // zero-based vertical coordinate
115+ Active, Hidden bool
116+}
117+
100118 // WindowAppID carries wayland → core an app ID set after the window mapped.
101119 type WindowAppID struct {
102120 ID WindowID
@@ -97,6 +97,24 @@ type WindowActivate struct{ ID WindowID }
97 97
98 func (WindowActivate) clientEvent() {}98 func (WindowActivate) clientEvent() {}
99 99
100+// WorkspaceActivate requests showing a workspace by its stable ID, on its owning monitor.
101+type WorkspaceActivate struct{ ID uint64 }
102+
103+func (WorkspaceActivate) clientEvent() {}
104+
105+// Workspaces is an immutable, latest-only inventory of output workspaces.
106+type Workspaces struct{ Outputs []WorkspaceOutput }
107+type WorkspaceOutput struct {
108+ Name string
109+ Workspaces []WorkspaceInfo
110+}
111+type WorkspaceInfo struct {
112+ ID uint64
113+ Name string
114+ Index int // zero-based vertical coordinate
115+ Active, Hidden bool
116+}
117+
100 // WindowAppID carries wayland → core an app ID set after the window mapped.118 // WindowAppID carries wayland → core an app ID set after the window mapped.
101 type WindowAppID struct {119 type WindowAppID struct {
102 ID WindowID120 ID WindowID

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close