detached 0049441

Merge pull request #22: feat(wayland): support ext-workspace-v1

# Conflicts:
#	internal/core/core.go
commit
004944183af293d049ef481a345baa94cbfe8c9a
author
brice · · 2026-09-27 14:27 UTC
merge of
75e0f9c + d1df38d · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +928 -29
M README.md +2 -0
@@ -161,6 +161,8 @@ startup = swayidle -w timeout 300 wlopm-off resume wlopm-on
161161 exec wlopm --off '*'
162162 ```
163163
164+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.
165+
164166 ## State for scripts
165167
166168 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:
@@ -161,6 +161,8 @@ startup = swayidle -w timeout 300 wlopm-off resume wlopm-on
161 exec wlopm --off '*'161 exec wlopm --off '*'
162 ```162 ```
163 163
164+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.
165+
164 ## State for scripts166 ## State for scripts
165 167
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:168 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 +5 -0
@@ -139,6 +139,11 @@ 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+ if m.outputBound(o.name()) {
144+ m.res.SendDone()
145+ }
146+ }
142147 })
143148 if err != nil {
144149 s.log.Warn().Err(err).Str("output", p.Info.Name).Msg("add wl_output")
@@ -139,6 +139,11 @@ 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+ if m.outputBound(o.name()) {
144+ m.res.SendDone()
145+ }
146+ }
142 })147 })
143 if err != nil {148 if err != nil {
144 s.log.Warn().Err(err).Str("output", p.Info.Name).Msg("add wl_output")149 s.log.Warn().Err(err).Str("output", p.Info.Name).Msg("add wl_output")
M internal/adapters/wayland/server.go +22 -18
@@ -36,9 +36,10 @@ type Options struct {
3636
3737 // Channels carries client notifications and commands. Events may be unbuffered.
3838 type Channels struct {
39- Events chan<- ports.ClientEvent
40- Contents chan<- ports.SurfaceContent
41- Commands <-chan ports.ClientCommand
39+ Events chan<- ports.ClientEvent
40+ Contents chan<- ports.SurfaceContent
41+ Commands <-chan ports.ClientCommand
42+ Workspaces <-chan ports.Workspaces
4243 // Cursors receives the cursor the client under the pointer asks for.
4344 Cursors chan<- ports.CursorChange
4445 // Presented paces frame callbacks on the outputs' page flips; outputs
@@ -152,20 +153,22 @@ type Server struct {
152153 shortcutWindows, idleWindows map[ports.WindowID]bool
153154 // idleNotes and powers are the idle notifications and output power
154155 // objects (idle.go); outputsOff the outputs core turned off.
155- idleNotes []*idleNotification
156- powers []*outputPower
157- outputsOff map[string]bool
158- contentNotify chan struct{}
159- contentReady chan struct{}
160- outputs []*output
161- outputManagers []*outputManager
162- outputHeads ports.OutputHeads
163- outputPlaces ports.Layout
164- managementSerial uint32
165- nextOutputApply uint64
166- outputReplies map[uint64]*outputConfiguration
167- focusedOutput string
168- fractions map[*surface]*fractionalscale.WpFractionalScaleV1
156+ idleNotes []*idleNotification
157+ powers []*outputPower
158+ outputsOff map[string]bool
159+ contentNotify chan struct{}
160+ contentReady chan struct{}
161+ outputs []*output
162+ outputManagers []*outputManager
163+ workspaceManagers []*workspaceManager
164+ workspaceSnapshot ports.Workspaces
165+ outputHeads ports.OutputHeads
166+ outputPlaces ports.Layout
167+ managementSerial uint32
168+ nextOutputApply uint64
169+ outputReplies map[uint64]*outputConfiguration
170+ focusedOutput string
171+ fractions map[*surface]*fractionalscale.WpFractionalScaleV1
169172 // cursorSurface is the wl_pointer.set_cursor surface in use.
170173 cursorSurface *surface
171174 // cursorMu guards only the latest cursor change for forwardCursors.
@@ -289,7 +292,8 @@ func (s *Server) Run(ctx context.Context) error {
289292 s.started = time.Now()
290293 s.ctx = ctx
291294 var wg sync.WaitGroup
292- wg.Add(9)
295+ wg.Add(10)
296+ go func() { defer wg.Done(); s.forwardWorkspaces(ctx) }()
293297 go func() { defer wg.Done(); s.forward(ctx) }()
294298 go func() { defer wg.Done(); s.forwardCursors(ctx) }()
295299 go func() { defer wg.Done(); s.forwardContents(ctx) }()
@@ -36,9 +36,10 @@ type Options struct {
36 36
37 // Channels carries client notifications and commands. Events may be unbuffered.37 // Channels carries client notifications and commands. Events may be unbuffered.
38 type Channels struct {38 type Channels struct {
39- Events chan<- ports.ClientEvent39+ Events chan<- ports.ClientEvent
40- Contents chan<- ports.SurfaceContent40+ Contents chan<- ports.SurfaceContent
41- Commands <-chan ports.ClientCommand41+ Commands <-chan ports.ClientCommand
42+ Workspaces <-chan ports.Workspaces
42 // Cursors receives the cursor the client under the pointer asks for.43 // Cursors receives the cursor the client under the pointer asks for.
43 Cursors chan<- ports.CursorChange44 Cursors chan<- ports.CursorChange
44 // Presented paces frame callbacks on the outputs' page flips; outputs45 // Presented paces frame callbacks on the outputs' page flips; outputs
@@ -152,20 +153,22 @@ type Server struct {
152 shortcutWindows, idleWindows map[ports.WindowID]bool153 shortcutWindows, idleWindows map[ports.WindowID]bool
153 // idleNotes and powers are the idle notifications and output power154 // idleNotes and powers are the idle notifications and output power
154 // objects (idle.go); outputsOff the outputs core turned off.155 // objects (idle.go); outputsOff the outputs core turned off.
155- idleNotes []*idleNotification156+ idleNotes []*idleNotification
156- powers []*outputPower157+ powers []*outputPower
157- outputsOff map[string]bool158+ outputsOff map[string]bool
158- contentNotify chan struct{}159+ contentNotify chan struct{}
159- contentReady chan struct{}160+ contentReady chan struct{}
160- outputs []*output161+ outputs []*output
161- outputManagers []*outputManager162+ outputManagers []*outputManager
162- outputHeads ports.OutputHeads163+ workspaceManagers []*workspaceManager
163- outputPlaces ports.Layout164+ workspaceSnapshot ports.Workspaces
164- managementSerial uint32165+ outputHeads ports.OutputHeads
165- nextOutputApply uint64166+ outputPlaces ports.Layout
166- outputReplies map[uint64]*outputConfiguration167+ managementSerial uint32
167- focusedOutput string168+ nextOutputApply uint64
168- fractions map[*surface]*fractionalscale.WpFractionalScaleV1169+ outputReplies map[uint64]*outputConfiguration
170+ focusedOutput string
171+ fractions map[*surface]*fractionalscale.WpFractionalScaleV1
169 // cursorSurface is the wl_pointer.set_cursor surface in use.172 // cursorSurface is the wl_pointer.set_cursor surface in use.
170 cursorSurface *surface173 cursorSurface *surface
171 // cursorMu guards only the latest cursor change for forwardCursors.174 // cursorMu guards only the latest cursor change for forwardCursors.
@@ -289,7 +292,8 @@ func (s *Server) Run(ctx context.Context) error {
289 s.started = time.Now()292 s.started = time.Now()
290 s.ctx = ctx293 s.ctx = ctx
291 var wg sync.WaitGroup294 var wg sync.WaitGroup
292- wg.Add(9)295+ wg.Add(10)
296+ go func() { defer wg.Done(); s.forwardWorkspaces(ctx) }()
293 go func() { defer wg.Done(); s.forward(ctx) }()297 go func() { defer wg.Done(); s.forward(ctx) }()
294 go func() { defer wg.Done(); s.forwardCursors(ctx) }()298 go func() { defer wg.Done(); s.forwardCursors(ctx) }()
295 go func() { defer wg.Done(); s.forwardContents(ctx) }()299 go func() { defer wg.Done(); s.forwardContents(ctx) }()
A internal/adapters/wayland/workspaces.go +247 -0
@@ -0,0 +1,247 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ ext "github.com/bnema/purego-libwayland/protocol/extworkspace"
9+ "github.com/bnema/purego-libwayland/protocol/wayland"
10+ "github.com/bnema/purego-libwayland/server"
11+)
12+
13+type workspaceManager struct {
14+ s *Server
15+ res *ext.ExtWorkspaceManagerV1
16+ groups map[string]*workspaceGroup
17+ handles map[uint64]*workspaceHandle
18+ pending []uint64
19+}
20+type workspaceGroup struct {
21+ res *ext.ExtWorkspaceGroupHandleV1
22+ outputs map[*server.Resource]bool // output_enter sent for each live wl_output
23+}
24+type workspaceHandle struct {
25+ res *ext.ExtWorkspaceHandleV1
26+ info ports.WorkspaceInfo
27+ group string
28+}
29+
30+func registerWorkspaces(d *server.Display, s *Server) error {
31+ return ext.NewExtWorkspaceManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
32+ m := &workspaceManager{s: s, groups: map[string]*workspaceGroup{}, handles: map[uint64]*workspaceHandle{}}
33+ r, err := ext.NewExtWorkspaceManagerV1(c, int32(v), id, m)
34+ if err != nil {
35+ return
36+ }
37+ m.res = r
38+ s.workspaceManagers = append(s.workspaceManagers, m)
39+ r.OnDestroy = func() { s.workspaceManagers = removeItem(s.workspaceManagers, m) }
40+ m.update(s.workspaceSnapshot)
41+ })
42+}
43+func (m *workspaceManager) Commit(*ext.ExtWorkspaceManagerV1) {
44+ ids := make([]uint64, 0, len(m.pending))
45+ for _, id := range m.pending {
46+ if h := m.handles[id]; h != nil && h.res.Alive() {
47+ ids = append(ids, id)
48+ }
49+ }
50+ m.pending = nil
51+ if len(ids) > 0 {
52+ m.s.emit(ports.WorkspaceActivate{IDs: ids})
53+ }
54+}
55+func (m *workspaceManager) Stop(*ext.ExtWorkspaceManagerV1) {
56+ m.res.SendFinished()
57+ m.res.Destroy()
58+}
59+func (*workspaceManager) CreateWorkspace(*ext.ExtWorkspaceGroupHandleV1, string) {}
60+func (*workspaceManager) Destroy(*ext.ExtWorkspaceGroupHandleV1) {}
61+func (m *workspaceManager) Activate(r *ext.ExtWorkspaceHandleV1) {
62+ for id, h := range m.handles {
63+ if h.res.Resource == r.Resource {
64+ m.pending = append(m.pending, id)
65+ return
66+ }
67+ }
68+}
69+
70+// workspaceRequests adapts the workspace destroy request separately from group destroy.
71+type workspaceRequests struct{ m *workspaceManager }
72+
73+func (*workspaceRequests) Destroy(*ext.ExtWorkspaceHandleV1) {}
74+func (w *workspaceRequests) Activate(r *ext.ExtWorkspaceHandleV1) { w.m.Activate(r) }
75+func (*workspaceRequests) Deactivate(*ext.ExtWorkspaceHandleV1) {}
76+func (*workspaceRequests) Assign(*ext.ExtWorkspaceHandleV1, *ext.ExtWorkspaceGroupHandleV1) {}
77+func (*workspaceRequests) Remove(*ext.ExtWorkspaceHandleV1) {}
78+
79+// outputBound announces every wl_output resource bound by this client for an
80+// assigned output. A client may bind the same global more than once.
81+func (m *workspaceManager) outputBound(name string) bool {
82+ g := m.groups[name]
83+ o := m.s.outputByNameExact(name)
84+ if g == nil || !g.res.Alive() || o == nil {
85+ return false
86+ }
87+ if g.outputs == nil {
88+ g.outputs = make(map[*server.Resource]bool)
89+ }
90+ changed := false
91+ for _, r := range o.resources {
92+ if r.Client() != m.res.Client() || !r.Alive() || g.outputs[r.Resource] {
93+ continue
94+ }
95+ g.outputs[r.Resource] = true
96+ g.res.SendOutputEnter(r)
97+ changed = true
98+ }
99+ return changed
100+}
101+
102+func (m *workspaceManager) leaveOutputs(g *workspaceGroup) {
103+ if !g.res.Alive() {
104+ return
105+ }
106+ for res := range g.outputs {
107+ if res.Alive() {
108+ g.res.SendOutputLeave(wayland.WrapOutput(res))
109+ }
110+ }
111+}
112+func (s *Server) forwardWorkspaces(ctx context.Context) {
113+ for {
114+ select {
115+ case <-ctx.Done():
116+ return
117+ case <-s.display.Stopped():
118+ return
119+ case snapshot, ok := <-s.channels.Workspaces:
120+ if !ok {
121+ return
122+ }
123+ if !s.display.Do(func() { s.updateWorkspaces(snapshot) }) {
124+ return
125+ }
126+ }
127+ }
128+}
129+func (s *Server) updateWorkspaces(snapshot ports.Workspaces) {
130+ s.workspaceSnapshot = snapshot
131+ for _, m := range s.workspaceManagers {
132+ m.update(snapshot)
133+ }
134+}
135+func workspaceState(w ports.WorkspaceInfo) uint32 {
136+ var state uint32
137+ if w.Active {
138+ state |= uint32(ext.ExtWorkspaceHandleV1StateActive)
139+ }
140+ if w.Hidden {
141+ state |= uint32(ext.ExtWorkspaceHandleV1StateHidden)
142+ }
143+ return state
144+}
145+func (m *workspaceManager) sendCoordinates(r *ext.ExtWorkspaceHandleV1, index int) {
146+ var b [4]byte
147+ binary.NativeEndian.PutUint32(b[:], uint32(index))
148+ r.SendCoordinates(b[:])
149+}
150+func (m *workspaceManager) update(snapshot ports.Workspaces) {
151+ if !m.res.Alive() {
152+ return
153+ }
154+ wanted := map[string]bool{}
155+ infos := map[uint64]ports.WorkspaceInfo{}
156+ for _, out := range snapshot.Outputs {
157+ wanted[out.Name] = true
158+ for _, w := range out.Workspaces {
159+ infos[w.ID] = w
160+ }
161+ }
162+ // Move existing workspaces before retiring their old output group.
163+ for id, h := range m.handles {
164+ info, exists := infos[id]
165+ if !exists || info.Configured != h.info.Configured {
166+ if g := m.groups[h.group]; g != nil && g.res.Alive() && h.res.Alive() {
167+ g.res.SendWorkspaceLeave(h.res)
168+ }
169+ if h.res.Alive() {
170+ h.res.SendRemoved()
171+ }
172+ delete(m.handles, id)
173+ }
174+ }
175+ for _, out := range snapshot.Outputs {
176+ g := m.groups[out.Name]
177+ if g == nil {
178+ r, err := ext.NewExtWorkspaceGroupHandleV1(m.res.Client(), 1, 0, m)
179+ if err != nil {
180+ continue
181+ }
182+ g = &workspaceGroup{res: r, outputs: map[*server.Resource]bool{}}
183+ m.groups[out.Name] = g
184+ m.res.SendWorkspaceGroup(r)
185+ r.SendCapabilities(0)
186+ }
187+ m.outputBound(out.Name)
188+ for _, w := range out.Workspaces {
189+ if !g.res.Alive() {
190+ continue
191+ }
192+ h := m.handles[w.ID]
193+ if h == nil {
194+ r, err := ext.NewExtWorkspaceHandleV1(m.res.Client(), 1, 0, &workspaceRequests{m})
195+ if err != nil {
196+ continue
197+ }
198+ h = &workspaceHandle{res: r}
199+ m.handles[w.ID] = h
200+ m.res.SendWorkspace(r)
201+ if w.Configured != "" {
202+ r.SendId(w.Configured)
203+ }
204+ r.SendName(w.Name)
205+ m.sendCoordinates(r, w.Index)
206+ r.SendState(workspaceState(w))
207+ r.SendCapabilities(uint32(ext.ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate))
208+ h.info = w
209+ }
210+ if !h.res.Alive() {
211+ continue
212+ }
213+ if h.info.ID != w.ID {
214+ continue
215+ }
216+ if h.info != w {
217+ if h.info.Name != w.Name {
218+ h.res.SendName(w.Name)
219+ }
220+ if h.info.Index != w.Index {
221+ m.sendCoordinates(h.res, w.Index)
222+ }
223+ if h.info.Active != w.Active || h.info.Hidden != w.Hidden {
224+ h.res.SendState(workspaceState(w))
225+ }
226+ h.info = w
227+ }
228+ if h.group != out.Name {
229+ if old := m.groups[h.group]; old != nil && old.res.Alive() && h.res.Alive() {
230+ old.res.SendWorkspaceLeave(h.res)
231+ }
232+ g.res.SendWorkspaceEnter(h.res)
233+ h.group = out.Name
234+ }
235+ }
236+ }
237+ for name, g := range m.groups {
238+ if !wanted[name] {
239+ m.leaveOutputs(g)
240+ if g.res.Alive() {
241+ g.res.SendRemoved()
242+ }
243+ delete(m.groups, name)
244+ }
245+ }
246+ m.res.SendDone()
247+}
@@ -0,0 +1,247 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ ext "github.com/bnema/purego-libwayland/protocol/extworkspace"
9+ "github.com/bnema/purego-libwayland/protocol/wayland"
10+ "github.com/bnema/purego-libwayland/server"
11+)
12+
13+type workspaceManager struct {
14+ s *Server
15+ res *ext.ExtWorkspaceManagerV1
16+ groups map[string]*workspaceGroup
17+ handles map[uint64]*workspaceHandle
18+ pending []uint64
19+}
20+type workspaceGroup struct {
21+ res *ext.ExtWorkspaceGroupHandleV1
22+ outputs map[*server.Resource]bool // output_enter sent for each live wl_output
23+}
24+type workspaceHandle struct {
25+ res *ext.ExtWorkspaceHandleV1
26+ info ports.WorkspaceInfo
27+ group string
28+}
29+
30+func registerWorkspaces(d *server.Display, s *Server) error {
31+ return ext.NewExtWorkspaceManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
32+ m := &workspaceManager{s: s, groups: map[string]*workspaceGroup{}, handles: map[uint64]*workspaceHandle{}}
33+ r, err := ext.NewExtWorkspaceManagerV1(c, int32(v), id, m)
34+ if err != nil {
35+ return
36+ }
37+ m.res = r
38+ s.workspaceManagers = append(s.workspaceManagers, m)
39+ r.OnDestroy = func() { s.workspaceManagers = removeItem(s.workspaceManagers, m) }
40+ m.update(s.workspaceSnapshot)
41+ })
42+}
43+func (m *workspaceManager) Commit(*ext.ExtWorkspaceManagerV1) {
44+ ids := make([]uint64, 0, len(m.pending))
45+ for _, id := range m.pending {
46+ if h := m.handles[id]; h != nil && h.res.Alive() {
47+ ids = append(ids, id)
48+ }
49+ }
50+ m.pending = nil
51+ if len(ids) > 0 {
52+ m.s.emit(ports.WorkspaceActivate{IDs: ids})
53+ }
54+}
55+func (m *workspaceManager) Stop(*ext.ExtWorkspaceManagerV1) {
56+ m.res.SendFinished()
57+ m.res.Destroy()
58+}
59+func (*workspaceManager) CreateWorkspace(*ext.ExtWorkspaceGroupHandleV1, string) {}
60+func (*workspaceManager) Destroy(*ext.ExtWorkspaceGroupHandleV1) {}
61+func (m *workspaceManager) Activate(r *ext.ExtWorkspaceHandleV1) {
62+ for id, h := range m.handles {
63+ if h.res.Resource == r.Resource {
64+ m.pending = append(m.pending, id)
65+ return
66+ }
67+ }
68+}
69+
70+// workspaceRequests adapts the workspace destroy request separately from group destroy.
71+type workspaceRequests struct{ m *workspaceManager }
72+
73+func (*workspaceRequests) Destroy(*ext.ExtWorkspaceHandleV1) {}
74+func (w *workspaceRequests) Activate(r *ext.ExtWorkspaceHandleV1) { w.m.Activate(r) }
75+func (*workspaceRequests) Deactivate(*ext.ExtWorkspaceHandleV1) {}
76+func (*workspaceRequests) Assign(*ext.ExtWorkspaceHandleV1, *ext.ExtWorkspaceGroupHandleV1) {}
77+func (*workspaceRequests) Remove(*ext.ExtWorkspaceHandleV1) {}
78+
79+// outputBound announces every wl_output resource bound by this client for an
80+// assigned output. A client may bind the same global more than once.
81+func (m *workspaceManager) outputBound(name string) bool {
82+ g := m.groups[name]
83+ o := m.s.outputByNameExact(name)
84+ if g == nil || !g.res.Alive() || o == nil {
85+ return false
86+ }
87+ if g.outputs == nil {
88+ g.outputs = make(map[*server.Resource]bool)
89+ }
90+ changed := false
91+ for _, r := range o.resources {
92+ if r.Client() != m.res.Client() || !r.Alive() || g.outputs[r.Resource] {
93+ continue
94+ }
95+ g.outputs[r.Resource] = true
96+ g.res.SendOutputEnter(r)
97+ changed = true
98+ }
99+ return changed
100+}
101+
102+func (m *workspaceManager) leaveOutputs(g *workspaceGroup) {
103+ if !g.res.Alive() {
104+ return
105+ }
106+ for res := range g.outputs {
107+ if res.Alive() {
108+ g.res.SendOutputLeave(wayland.WrapOutput(res))
109+ }
110+ }
111+}
112+func (s *Server) forwardWorkspaces(ctx context.Context) {
113+ for {
114+ select {
115+ case <-ctx.Done():
116+ return
117+ case <-s.display.Stopped():
118+ return
119+ case snapshot, ok := <-s.channels.Workspaces:
120+ if !ok {
121+ return
122+ }
123+ if !s.display.Do(func() { s.updateWorkspaces(snapshot) }) {
124+ return
125+ }
126+ }
127+ }
128+}
129+func (s *Server) updateWorkspaces(snapshot ports.Workspaces) {
130+ s.workspaceSnapshot = snapshot
131+ for _, m := range s.workspaceManagers {
132+ m.update(snapshot)
133+ }
134+}
135+func workspaceState(w ports.WorkspaceInfo) uint32 {
136+ var state uint32
137+ if w.Active {
138+ state |= uint32(ext.ExtWorkspaceHandleV1StateActive)
139+ }
140+ if w.Hidden {
141+ state |= uint32(ext.ExtWorkspaceHandleV1StateHidden)
142+ }
143+ return state
144+}
145+func (m *workspaceManager) sendCoordinates(r *ext.ExtWorkspaceHandleV1, index int) {
146+ var b [4]byte
147+ binary.NativeEndian.PutUint32(b[:], uint32(index))
148+ r.SendCoordinates(b[:])
149+}
150+func (m *workspaceManager) update(snapshot ports.Workspaces) {
151+ if !m.res.Alive() {
152+ return
153+ }
154+ wanted := map[string]bool{}
155+ infos := map[uint64]ports.WorkspaceInfo{}
156+ for _, out := range snapshot.Outputs {
157+ wanted[out.Name] = true
158+ for _, w := range out.Workspaces {
159+ infos[w.ID] = w
160+ }
161+ }
162+ // Move existing workspaces before retiring their old output group.
163+ for id, h := range m.handles {
164+ info, exists := infos[id]
165+ if !exists || info.Configured != h.info.Configured {
166+ if g := m.groups[h.group]; g != nil && g.res.Alive() && h.res.Alive() {
167+ g.res.SendWorkspaceLeave(h.res)
168+ }
169+ if h.res.Alive() {
170+ h.res.SendRemoved()
171+ }
172+ delete(m.handles, id)
173+ }
174+ }
175+ for _, out := range snapshot.Outputs {
176+ g := m.groups[out.Name]
177+ if g == nil {
178+ r, err := ext.NewExtWorkspaceGroupHandleV1(m.res.Client(), 1, 0, m)
179+ if err != nil {
180+ continue
181+ }
182+ g = &workspaceGroup{res: r, outputs: map[*server.Resource]bool{}}
183+ m.groups[out.Name] = g
184+ m.res.SendWorkspaceGroup(r)
185+ r.SendCapabilities(0)
186+ }
187+ m.outputBound(out.Name)
188+ for _, w := range out.Workspaces {
189+ if !g.res.Alive() {
190+ continue
191+ }
192+ h := m.handles[w.ID]
193+ if h == nil {
194+ r, err := ext.NewExtWorkspaceHandleV1(m.res.Client(), 1, 0, &workspaceRequests{m})
195+ if err != nil {
196+ continue
197+ }
198+ h = &workspaceHandle{res: r}
199+ m.handles[w.ID] = h
200+ m.res.SendWorkspace(r)
201+ if w.Configured != "" {
202+ r.SendId(w.Configured)
203+ }
204+ r.SendName(w.Name)
205+ m.sendCoordinates(r, w.Index)
206+ r.SendState(workspaceState(w))
207+ r.SendCapabilities(uint32(ext.ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate))
208+ h.info = w
209+ }
210+ if !h.res.Alive() {
211+ continue
212+ }
213+ if h.info.ID != w.ID {
214+ continue
215+ }
216+ if h.info != w {
217+ if h.info.Name != w.Name {
218+ h.res.SendName(w.Name)
219+ }
220+ if h.info.Index != w.Index {
221+ m.sendCoordinates(h.res, w.Index)
222+ }
223+ if h.info.Active != w.Active || h.info.Hidden != w.Hidden {
224+ h.res.SendState(workspaceState(w))
225+ }
226+ h.info = w
227+ }
228+ if h.group != out.Name {
229+ if old := m.groups[h.group]; old != nil && old.res.Alive() && h.res.Alive() {
230+ old.res.SendWorkspaceLeave(h.res)
231+ }
232+ g.res.SendWorkspaceEnter(h.res)
233+ h.group = out.Name
234+ }
235+ }
236+ }
237+ for name, g := range m.groups {
238+ if !wanted[name] {
239+ m.leaveOutputs(g)
240+ if g.res.Alive() {
241+ g.res.SendRemoved()
242+ }
243+ delete(m.groups, name)
244+ }
245+ }
246+ m.res.SendDone()
247+}
A internal/adapters/wayland/workspaces_test.go +445 -0
@@ -0,0 +1,445 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "slices"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/logging"
11+ "github.com/bnema/neferwl/internal/ports"
12+ ext "github.com/bnema/purego-libwayland/protocol/extworkspace"
13+ "github.com/bnema/wlturbo"
14+)
15+
16+// workspaceEventOrder records dispatch order across manager and child objects.
17+type workspaceEventOrder struct {
18+ mu sync.Mutex
19+ events []uint32
20+}
21+
22+func (o *workspaceEventOrder) add(v uint32) {
23+ if o != nil {
24+ o.mu.Lock()
25+ o.events = append(o.events, v)
26+ o.mu.Unlock()
27+ }
28+}
29+func (o *workspaceEventOrder) take() []uint32 {
30+ o.mu.Lock()
31+ defer o.mu.Unlock()
32+ v := slices.Clone(o.events)
33+ o.events = nil
34+ return v
35+}
36+
37+type workspaceEvents struct {
38+ wlturbo.BaseProxy
39+ client *wlturbo.Display
40+ events chan [2]uint32
41+ idEvents chan string
42+ order *workspaceEventOrder
43+}
44+
45+func (p *workspaceEvents) Dispatch(e *wlturbo.Event) {
46+ v := [2]uint32{uint32(e.Opcode), 0}
47+ if e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup) || e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspace) {
48+ v[1] = e.Uint32()
49+ child := &workspaceChildEvents{events: p.events, idEvents: p.idEvents, order: p.order, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)}
50+ child.SetID(v[1])
51+ p.client.Context().Register(child)
52+ }
53+ p.order.add(v[0])
54+ p.events <- v
55+}
56+
57+type workspaceChildEvents struct {
58+ wlturbo.BaseProxy
59+ events chan [2]uint32
60+ group bool
61+ idEvents chan string
62+ order *workspaceEventOrder
63+}
64+
65+func (p *workspaceChildEvents) Dispatch(e *wlturbo.Event) {
66+ // Encode the interface in the upper byte to distinguish manager, group and handle.
67+ kind := uint32(2)
68+ if p.group {
69+ kind = 1
70+ }
71+ v := [2]uint32{kind<<8 | uint32(e.Opcode), 0}
72+ switch {
73+ case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputLeave)):
74+ v[1] = e.Uint32()
75+ case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceLeave)):
76+ v[1] = e.Uint32()
77+ case !p.group && (e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventState) || e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventCapabilities)):
78+ v[1] = e.Uint32()
79+ case !p.group && e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventId):
80+ p.idEvents <- e.String()
81+ }
82+ p.order.add(v[0])
83+ p.events <- v
84+}
85+func TestWorkspaceProtocol(t *testing.T) {
86+ dir := t.TempDir()
87+ events := make(chan ports.ClientEvent, 16)
88+ snapshots := make(chan ports.Workspaces, 1)
89+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ ctx, cancel := context.WithCancel(context.Background())
94+ done := make(chan error, 1)
95+ go func() { done <- s.Run(ctx) }()
96+ t.Cleanup(func() {
97+ cancel()
98+ if err := <-done; err != nil {
99+ t.Error(err)
100+ }
101+ })
102+ c := protocolClient(t, s, dir)
103+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}, {ID: 43, Name: "2", Index: 1}}}}}
104+ if !s.display.Do(func() {}) {
105+ t.Fatal("display stopped")
106+ }
107+ managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
108+ p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16)}
109+ p.SetID(managerID)
110+ c.Context().Register(p)
111+ var handle, secondHandle uint32
112+ for i := 0; i < 8 && secondHandle == 0; i++ {
113+ if err := c.Roundtrip(); err != nil {
114+ t.Fatal(err)
115+ }
116+ for len(p.events) > 0 {
117+ v := <-p.events
118+ if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace {
119+ if handle == 0 {
120+ handle = v[1]
121+ } else {
122+ secondHandle = v[1]
123+ }
124+ }
125+ }
126+ }
127+ if handle == 0 {
128+ t.Fatal("workspace not announced")
129+ }
130+ select {
131+ case id := <-p.idEvents:
132+ t.Fatalf("dynamic workspace sent stable id %q", id)
133+ default:
134+ }
135+ out := bindProtocol(t, c, "wl_output")
136+ registerProtocol(t, c, out)
137+ entered := false
138+ doneCount := 0
139+ for i := 0; i < 8 && !entered; i++ {
140+ if err := c.Roundtrip(); err != nil {
141+ t.Fatal(err)
142+ }
143+ for len(p.events) > 0 {
144+ v := <-p.events
145+ if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} {
146+ entered = true
147+ }
148+ if v[0] == ext.ExtWorkspaceManagerV1EventDone {
149+ doneCount++
150+ }
151+ }
152+ }
153+ if err := c.Roundtrip(); err != nil {
154+ t.Fatal(err)
155+ }
156+ for len(p.events) > 0 {
157+ if (<-p.events)[0] == ext.ExtWorkspaceManagerV1EventDone {
158+ doneCount++
159+ }
160+ }
161+ if !entered || doneCount != 1 {
162+ t.Fatalf("late output enter=%v, done=%d", entered, doneCount)
163+ }
164+ if len(p.idEvents) != 0 {
165+ t.Fatal("dynamic workspace sent id")
166+ }
167+ requestProtocol(t, c, handle, ext.ExtWorkspaceHandleV1RequestActivate)
168+ requestProtocol(t, c, secondHandle, ext.ExtWorkspaceHandleV1RequestActivate)
169+ if err := c.Roundtrip(); err != nil {
170+ t.Fatal(err)
171+ }
172+ select {
173+ case v := <-events:
174+ t.Fatalf("activation before commit: %v", v)
175+ default:
176+ }
177+ requestProtocol(t, c, managerID, ext.ExtWorkspaceManagerV1RequestCommit)
178+ if err := c.Roundtrip(); err != nil {
179+ t.Fatal(err)
180+ }
181+ select {
182+ case v := <-events:
183+ if got, ok := v.(ports.WorkspaceActivate); !ok || !slices.Equal(got.IDs, []uint64{42, 43}) {
184+ t.Fatal(v)
185+ }
186+ case <-time.After(time.Second):
187+ t.Fatal("no activation")
188+ }
189+ select {
190+ case v := <-events:
191+ t.Fatalf("extra activation message: %v", v)
192+ default:
193+ }
194+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Hidden: true}, {ID: 43, Name: "2", Index: 1}}}}}
195+ found := false
196+ for i := 0; i < 8 && !found; i++ {
197+ if err := c.Roundtrip(); err != nil {
198+ t.Fatal(err)
199+ }
200+ for len(p.events) > 0 {
201+ v := <-p.events
202+ if v == [2]uint32{2<<8 | ext.ExtWorkspaceHandleV1EventState, uint32(ext.ExtWorkspaceHandleV1StateHidden)} {
203+ found = true
204+ }
205+ }
206+ }
207+ if !found {
208+ t.Fatal("no state change")
209+ }
210+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
211+ found = false
212+ for i := 0; i < 8 && !found; i++ {
213+ if err := c.Roundtrip(); err != nil {
214+ t.Fatal(err)
215+ }
216+ for len(p.events) > 0 {
217+ v := <-p.events
218+ if v[0] == 1<<8|ext.ExtWorkspaceGroupHandleV1EventRemoved {
219+ found = true
220+ }
221+ }
222+ }
223+ if !found {
224+ t.Fatal("group not removed")
225+ }
226+}
227+
228+func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) {
229+ dir := t.TempDir()
230+ snapshots := make(chan ports.Workspaces, 1)
231+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
232+ if err != nil {
233+ t.Fatal(err)
234+ }
235+ ctx, cancel := context.WithCancel(context.Background())
236+ done := make(chan error, 1)
237+ go func() { done <- s.Run(ctx) }()
238+ t.Cleanup(func() {
239+ cancel()
240+ if err := <-done; err != nil {
241+ t.Error(err)
242+ }
243+ })
244+ c := protocolClient(t, s, dir)
245+ // Bind manager first; the initial two-output batch has exactly one done.
246+ managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
247+ p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16), order: &workspaceEventOrder{}}
248+ p.SetID(managerID)
249+ c.Context().Register(p)
250+ if err := c.Roundtrip(); err != nil {
251+ t.Fatal(err)
252+ }
253+ for len(p.events) > 0 {
254+ <-p.events
255+ }
256+ p.order.take()
257+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{
258+ {Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}, {ID: 43, Name: "dev", Configured: "dev", Index: 1, Hidden: true}}},
259+ {Name: "HEADLESS-2", Workspaces: []ports.WorkspaceInfo{{ID: 44, Name: "1", Active: true}}},
260+ }}
261+ // Synchronize with the display owner's processing of the channel snapshot.
262+ for i := 0; i < 8; i++ {
263+ if !s.display.Do(func() {}) {
264+ t.Fatal("display stopped")
265+ }
266+ if err := c.Roundtrip(); err != nil {
267+ t.Fatal(err)
268+ }
269+ if len(p.events) > 0 {
270+ break
271+ }
272+ }
273+ doneCount := 0
274+ for len(p.events) > 0 {
275+ if (<-p.events)[0] == ext.ExtWorkspaceManagerV1EventDone {
276+ doneCount++
277+ }
278+ }
279+ if doneCount != 1 {
280+ t.Fatalf("snapshot done=%d, want 1", doneCount)
281+ }
282+ order := p.order.take()
283+ // The second group begins after the first group's workspaces are fully
284+ // initialized; workspace_enter follows all initial workspace details.
285+ wantOrder := []uint32{
286+ ext.ExtWorkspaceManagerV1EventWorkspaceGroup, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventCapabilities,
287+ ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
288+ ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventId, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
289+ ext.ExtWorkspaceManagerV1EventWorkspaceGroup, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventCapabilities,
290+ ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
291+ ext.ExtWorkspaceManagerV1EventDone,
292+ }
293+ if !slices.Equal(order, wantOrder) {
294+ t.Fatalf("creation order = %v, want %v", order, wantOrder)
295+ }
296+ select {
297+ case id := <-p.idEvents:
298+ if id != "dev" {
299+ t.Fatalf("id=%q", id)
300+ }
301+ default:
302+ t.Fatal("configured workspace has no id")
303+ }
304+ if len(p.idEvents) != 0 {
305+ t.Fatal("dynamic workspace received id")
306+ }
307+ out := bindProtocol(t, c, "wl_output")
308+ registerProtocol(t, c, out)
309+ entered, outputDone := 0, 0
310+ for i := 0; i < 8 && outputDone == 0; i++ {
311+ if err := c.Roundtrip(); err != nil {
312+ t.Fatal(err)
313+ }
314+ for len(p.events) > 0 {
315+ v := <-p.events
316+ if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} {
317+ entered++
318+ }
319+ if v[0] == ext.ExtWorkspaceManagerV1EventDone {
320+ outputDone++
321+ }
322+ }
323+ }
324+ if entered != 1 || outputDone != 1 {
325+ t.Fatalf("late output enter=%d done=%d", entered, outputDone)
326+ }
327+ second := bindProtocol(t, c, "wl_output")
328+ registerProtocol(t, c, second)
329+ entered, outputDone = 0, 0
330+ for i := 0; i < 8 && outputDone == 0; i++ {
331+ if err := c.Roundtrip(); err != nil {
332+ t.Fatal(err)
333+ }
334+ for len(p.events) > 0 {
335+ v := <-p.events
336+ if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, second} {
337+ entered++
338+ }
339+ if v[0] == ext.ExtWorkspaceManagerV1EventDone {
340+ outputDone++
341+ }
342+ }
343+ }
344+ if entered != 1 || outputDone != 1 {
345+ t.Fatalf("second output enter=%d done=%d", entered, outputDone)
346+ }
347+ // A client may destroy a handle without removing the underlying workspace.
348+ // A subsequent snapshot must not recreate the destroyed protocol object.
349+ var group, workspace uint32
350+ // Inspect server resources on the display owner's goroutine.
351+ if !s.display.Do(func() {
352+ m := s.workspaceManagers[0]
353+ group = m.groups["HEADLESS-1"].res.ID()
354+ workspace = m.handles[42].res.ID()
355+ }) {
356+ t.Fatal("display stopped")
357+ }
358+ requestProtocol(t, c, workspace, ext.ExtWorkspaceHandleV1RequestDestroy)
359+ requestProtocol(t, c, group, ext.ExtWorkspaceGroupHandleV1RequestDestroy)
360+ if err := c.Roundtrip(); err != nil {
361+ t.Fatal(err)
362+ }
363+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "one", Active: true}, {ID: 43, Name: "dev", Configured: "dev", Index: 1, Hidden: true}}}, {Name: "HEADLESS-2", Workspaces: []ports.WorkspaceInfo{{ID: 44, Name: "1", Active: true}}}}}
364+ for i := 0; i < 8; i++ {
365+ if !s.display.Do(func() {}) {
366+ t.Fatal("display stopped")
367+ }
368+ if err := c.Roundtrip(); err != nil {
369+ t.Fatal(err)
370+ }
371+ }
372+ for len(p.events) > 0 {
373+ v := <-p.events
374+ if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace || v[0] == ext.ExtWorkspaceManagerV1EventWorkspaceGroup {
375+ t.Fatalf("recreated client-destroyed handle: %v", v)
376+ }
377+ }
378+ // A configured rename replaces the handle on the still-live second group.
379+ // The destroyed first group remains a tombstone until it disappears.
380+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "one", Active: true}, {ID: 43, Name: "dev", Configured: "dev", Index: 1, Hidden: true}}}, {Name: "HEADLESS-2", Workspaces: []ports.WorkspaceInfo{{ID: 44, Name: "code", Configured: "code", Active: true}}}}}
381+ replaced, removed := 0, 0
382+ for i := 0; i < 8 && replaced == 0; i++ {
383+ if !s.display.Do(func() {}) {
384+ t.Fatal("display stopped")
385+ }
386+ if err := c.Roundtrip(); err != nil {
387+ t.Fatal(err)
388+ }
389+ for len(p.events) > 0 {
390+ v := <-p.events
391+ if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace {
392+ replaced++
393+ }
394+ if v[0] == 2<<8|ext.ExtWorkspaceHandleV1EventRemoved {
395+ removed++
396+ }
397+ }
398+ }
399+ if replaced != 1 || removed != 1 {
400+ t.Fatalf("rename replaced=%d removed=%d", replaced, removed)
401+ }
402+ select {
403+ case id := <-p.idEvents:
404+ if id != "code" {
405+ t.Fatalf("renamed id=%q", id)
406+ }
407+ default:
408+ t.Fatal("missing renamed id")
409+ }
410+ if len(p.idEvents) != 0 {
411+ t.Fatal("extra id")
412+ }
413+ // Removing the group clears its tombstone, allowing a new handle on return.
414+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
415+ for i := 0; i < 8; i++ {
416+ if !s.display.Do(func() {}) {
417+ t.Fatal("display stopped")
418+ }
419+ if err := c.Roundtrip(); err != nil {
420+ t.Fatal(err)
421+ }
422+ }
423+ for len(p.events) > 0 {
424+ <-p.events
425+ }
426+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}}}}}
427+ recreated := 0
428+ for i := 0; i < 8 && recreated < 2; i++ {
429+ if !s.display.Do(func() {}) {
430+ t.Fatal("display stopped")
431+ }
432+ if err := c.Roundtrip(); err != nil {
433+ t.Fatal(err)
434+ }
435+ for len(p.events) > 0 {
436+ v := <-p.events
437+ if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace || v[0] == ext.ExtWorkspaceManagerV1EventWorkspaceGroup {
438+ recreated++
439+ }
440+ }
441+ }
442+ if recreated != 2 {
443+ t.Fatalf("new group and workspace after disappearance: %d", recreated)
444+ }
445+}
@@ -0,0 +1,445 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "slices"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/logging"
11+ "github.com/bnema/neferwl/internal/ports"
12+ ext "github.com/bnema/purego-libwayland/protocol/extworkspace"
13+ "github.com/bnema/wlturbo"
14+)
15+
16+// workspaceEventOrder records dispatch order across manager and child objects.
17+type workspaceEventOrder struct {
18+ mu sync.Mutex
19+ events []uint32
20+}
21+
22+func (o *workspaceEventOrder) add(v uint32) {
23+ if o != nil {
24+ o.mu.Lock()
25+ o.events = append(o.events, v)
26+ o.mu.Unlock()
27+ }
28+}
29+func (o *workspaceEventOrder) take() []uint32 {
30+ o.mu.Lock()
31+ defer o.mu.Unlock()
32+ v := slices.Clone(o.events)
33+ o.events = nil
34+ return v
35+}
36+
37+type workspaceEvents struct {
38+ wlturbo.BaseProxy
39+ client *wlturbo.Display
40+ events chan [2]uint32
41+ idEvents chan string
42+ order *workspaceEventOrder
43+}
44+
45+func (p *workspaceEvents) Dispatch(e *wlturbo.Event) {
46+ v := [2]uint32{uint32(e.Opcode), 0}
47+ if e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup) || e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspace) {
48+ v[1] = e.Uint32()
49+ child := &workspaceChildEvents{events: p.events, idEvents: p.idEvents, order: p.order, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)}
50+ child.SetID(v[1])
51+ p.client.Context().Register(child)
52+ }
53+ p.order.add(v[0])
54+ p.events <- v
55+}
56+
57+type workspaceChildEvents struct {
58+ wlturbo.BaseProxy
59+ events chan [2]uint32
60+ group bool
61+ idEvents chan string
62+ order *workspaceEventOrder
63+}
64+
65+func (p *workspaceChildEvents) Dispatch(e *wlturbo.Event) {
66+ // Encode the interface in the upper byte to distinguish manager, group and handle.
67+ kind := uint32(2)
68+ if p.group {
69+ kind = 1
70+ }
71+ v := [2]uint32{kind<<8 | uint32(e.Opcode), 0}
72+ switch {
73+ case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputLeave)):
74+ v[1] = e.Uint32()
75+ case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceLeave)):
76+ v[1] = e.Uint32()
77+ case !p.group && (e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventState) || e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventCapabilities)):
78+ v[1] = e.Uint32()
79+ case !p.group && e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventId):
80+ p.idEvents <- e.String()
81+ }
82+ p.order.add(v[0])
83+ p.events <- v
84+}
85+func TestWorkspaceProtocol(t *testing.T) {
86+ dir := t.TempDir()
87+ events := make(chan ports.ClientEvent, 16)
88+ snapshots := make(chan ports.Workspaces, 1)
89+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ ctx, cancel := context.WithCancel(context.Background())
94+ done := make(chan error, 1)
95+ go func() { done <- s.Run(ctx) }()
96+ t.Cleanup(func() {
97+ cancel()
98+ if err := <-done; err != nil {
99+ t.Error(err)
100+ }
101+ })
102+ c := protocolClient(t, s, dir)
103+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}, {ID: 43, Name: "2", Index: 1}}}}}
104+ if !s.display.Do(func() {}) {
105+ t.Fatal("display stopped")
106+ }
107+ managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
108+ p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16)}
109+ p.SetID(managerID)
110+ c.Context().Register(p)
111+ var handle, secondHandle uint32
112+ for i := 0; i < 8 && secondHandle == 0; i++ {
113+ if err := c.Roundtrip(); err != nil {
114+ t.Fatal(err)
115+ }
116+ for len(p.events) > 0 {
117+ v := <-p.events
118+ if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace {
119+ if handle == 0 {
120+ handle = v[1]
121+ } else {
122+ secondHandle = v[1]
123+ }
124+ }
125+ }
126+ }
127+ if handle == 0 {
128+ t.Fatal("workspace not announced")
129+ }
130+ select {
131+ case id := <-p.idEvents:
132+ t.Fatalf("dynamic workspace sent stable id %q", id)
133+ default:
134+ }
135+ out := bindProtocol(t, c, "wl_output")
136+ registerProtocol(t, c, out)
137+ entered := false
138+ doneCount := 0
139+ for i := 0; i < 8 && !entered; i++ {
140+ if err := c.Roundtrip(); err != nil {
141+ t.Fatal(err)
142+ }
143+ for len(p.events) > 0 {
144+ v := <-p.events
145+ if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} {
146+ entered = true
147+ }
148+ if v[0] == ext.ExtWorkspaceManagerV1EventDone {
149+ doneCount++
150+ }
151+ }
152+ }
153+ if err := c.Roundtrip(); err != nil {
154+ t.Fatal(err)
155+ }
156+ for len(p.events) > 0 {
157+ if (<-p.events)[0] == ext.ExtWorkspaceManagerV1EventDone {
158+ doneCount++
159+ }
160+ }
161+ if !entered || doneCount != 1 {
162+ t.Fatalf("late output enter=%v, done=%d", entered, doneCount)
163+ }
164+ if len(p.idEvents) != 0 {
165+ t.Fatal("dynamic workspace sent id")
166+ }
167+ requestProtocol(t, c, handle, ext.ExtWorkspaceHandleV1RequestActivate)
168+ requestProtocol(t, c, secondHandle, ext.ExtWorkspaceHandleV1RequestActivate)
169+ if err := c.Roundtrip(); err != nil {
170+ t.Fatal(err)
171+ }
172+ select {
173+ case v := <-events:
174+ t.Fatalf("activation before commit: %v", v)
175+ default:
176+ }
177+ requestProtocol(t, c, managerID, ext.ExtWorkspaceManagerV1RequestCommit)
178+ if err := c.Roundtrip(); err != nil {
179+ t.Fatal(err)
180+ }
181+ select {
182+ case v := <-events:
183+ if got, ok := v.(ports.WorkspaceActivate); !ok || !slices.Equal(got.IDs, []uint64{42, 43}) {
184+ t.Fatal(v)
185+ }
186+ case <-time.After(time.Second):
187+ t.Fatal("no activation")
188+ }
189+ select {
190+ case v := <-events:
191+ t.Fatalf("extra activation message: %v", v)
192+ default:
193+ }
194+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Hidden: true}, {ID: 43, Name: "2", Index: 1}}}}}
195+ found := false
196+ for i := 0; i < 8 && !found; i++ {
197+ if err := c.Roundtrip(); err != nil {
198+ t.Fatal(err)
199+ }
200+ for len(p.events) > 0 {
201+ v := <-p.events
202+ if v == [2]uint32{2<<8 | ext.ExtWorkspaceHandleV1EventState, uint32(ext.ExtWorkspaceHandleV1StateHidden)} {
203+ found = true
204+ }
205+ }
206+ }
207+ if !found {
208+ t.Fatal("no state change")
209+ }
210+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
211+ found = false
212+ for i := 0; i < 8 && !found; i++ {
213+ if err := c.Roundtrip(); err != nil {
214+ t.Fatal(err)
215+ }
216+ for len(p.events) > 0 {
217+ v := <-p.events
218+ if v[0] == 1<<8|ext.ExtWorkspaceGroupHandleV1EventRemoved {
219+ found = true
220+ }
221+ }
222+ }
223+ if !found {
224+ t.Fatal("group not removed")
225+ }
226+}
227+
228+func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) {
229+ dir := t.TempDir()
230+ snapshots := make(chan ports.Workspaces, 1)
231+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
232+ if err != nil {
233+ t.Fatal(err)
234+ }
235+ ctx, cancel := context.WithCancel(context.Background())
236+ done := make(chan error, 1)
237+ go func() { done <- s.Run(ctx) }()
238+ t.Cleanup(func() {
239+ cancel()
240+ if err := <-done; err != nil {
241+ t.Error(err)
242+ }
243+ })
244+ c := protocolClient(t, s, dir)
245+ // Bind manager first; the initial two-output batch has exactly one done.
246+ managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
247+ p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16), order: &workspaceEventOrder{}}
248+ p.SetID(managerID)
249+ c.Context().Register(p)
250+ if err := c.Roundtrip(); err != nil {
251+ t.Fatal(err)
252+ }
253+ for len(p.events) > 0 {
254+ <-p.events
255+ }
256+ p.order.take()
257+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{
258+ {Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}, {ID: 43, Name: "dev", Configured: "dev", Index: 1, Hidden: true}}},
259+ {Name: "HEADLESS-2", Workspaces: []ports.WorkspaceInfo{{ID: 44, Name: "1", Active: true}}},
260+ }}
261+ // Synchronize with the display owner's processing of the channel snapshot.
262+ for i := 0; i < 8; i++ {
263+ if !s.display.Do(func() {}) {
264+ t.Fatal("display stopped")
265+ }
266+ if err := c.Roundtrip(); err != nil {
267+ t.Fatal(err)
268+ }
269+ if len(p.events) > 0 {
270+ break
271+ }
272+ }
273+ doneCount := 0
274+ for len(p.events) > 0 {
275+ if (<-p.events)[0] == ext.ExtWorkspaceManagerV1EventDone {
276+ doneCount++
277+ }
278+ }
279+ if doneCount != 1 {
280+ t.Fatalf("snapshot done=%d, want 1", doneCount)
281+ }
282+ order := p.order.take()
283+ // The second group begins after the first group's workspaces are fully
284+ // initialized; workspace_enter follows all initial workspace details.
285+ wantOrder := []uint32{
286+ ext.ExtWorkspaceManagerV1EventWorkspaceGroup, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventCapabilities,
287+ ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
288+ ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventId, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
289+ ext.ExtWorkspaceManagerV1EventWorkspaceGroup, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventCapabilities,
290+ ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
291+ ext.ExtWorkspaceManagerV1EventDone,
292+ }
293+ if !slices.Equal(order, wantOrder) {
294+ t.Fatalf("creation order = %v, want %v", order, wantOrder)
295+ }
296+ select {
297+ case id := <-p.idEvents:
298+ if id != "dev" {
299+ t.Fatalf("id=%q", id)
300+ }
301+ default:
302+ t.Fatal("configured workspace has no id")
303+ }
304+ if len(p.idEvents) != 0 {
305+ t.Fatal("dynamic workspace received id")
306+ }
307+ out := bindProtocol(t, c, "wl_output")
308+ registerProtocol(t, c, out)
309+ entered, outputDone := 0, 0
310+ for i := 0; i < 8 && outputDone == 0; i++ {
311+ if err := c.Roundtrip(); err != nil {
312+ t.Fatal(err)
313+ }
314+ for len(p.events) > 0 {
315+ v := <-p.events
316+ if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} {
317+ entered++
318+ }
319+ if v[0] == ext.ExtWorkspaceManagerV1EventDone {
320+ outputDone++
321+ }
322+ }
323+ }
324+ if entered != 1 || outputDone != 1 {
325+ t.Fatalf("late output enter=%d done=%d", entered, outputDone)
326+ }
327+ second := bindProtocol(t, c, "wl_output")
328+ registerProtocol(t, c, second)
329+ entered, outputDone = 0, 0
330+ for i := 0; i < 8 && outputDone == 0; i++ {
331+ if err := c.Roundtrip(); err != nil {
332+ t.Fatal(err)
333+ }
334+ for len(p.events) > 0 {
335+ v := <-p.events
336+ if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, second} {
337+ entered++
338+ }
339+ if v[0] == ext.ExtWorkspaceManagerV1EventDone {
340+ outputDone++
341+ }
342+ }
343+ }
344+ if entered != 1 || outputDone != 1 {
345+ t.Fatalf("second output enter=%d done=%d", entered, outputDone)
346+ }
347+ // A client may destroy a handle without removing the underlying workspace.
348+ // A subsequent snapshot must not recreate the destroyed protocol object.
349+ var group, workspace uint32
350+ // Inspect server resources on the display owner's goroutine.
351+ if !s.display.Do(func() {
352+ m := s.workspaceManagers[0]
353+ group = m.groups["HEADLESS-1"].res.ID()
354+ workspace = m.handles[42].res.ID()
355+ }) {
356+ t.Fatal("display stopped")
357+ }
358+ requestProtocol(t, c, workspace, ext.ExtWorkspaceHandleV1RequestDestroy)
359+ requestProtocol(t, c, group, ext.ExtWorkspaceGroupHandleV1RequestDestroy)
360+ if err := c.Roundtrip(); err != nil {
361+ t.Fatal(err)
362+ }
363+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "one", Active: true}, {ID: 43, Name: "dev", Configured: "dev", Index: 1, Hidden: true}}}, {Name: "HEADLESS-2", Workspaces: []ports.WorkspaceInfo{{ID: 44, Name: "1", Active: true}}}}}
364+ for i := 0; i < 8; i++ {
365+ if !s.display.Do(func() {}) {
366+ t.Fatal("display stopped")
367+ }
368+ if err := c.Roundtrip(); err != nil {
369+ t.Fatal(err)
370+ }
371+ }
372+ for len(p.events) > 0 {
373+ v := <-p.events
374+ if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace || v[0] == ext.ExtWorkspaceManagerV1EventWorkspaceGroup {
375+ t.Fatalf("recreated client-destroyed handle: %v", v)
376+ }
377+ }
378+ // A configured rename replaces the handle on the still-live second group.
379+ // The destroyed first group remains a tombstone until it disappears.
380+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "one", Active: true}, {ID: 43, Name: "dev", Configured: "dev", Index: 1, Hidden: true}}}, {Name: "HEADLESS-2", Workspaces: []ports.WorkspaceInfo{{ID: 44, Name: "code", Configured: "code", Active: true}}}}}
381+ replaced, removed := 0, 0
382+ for i := 0; i < 8 && replaced == 0; i++ {
383+ if !s.display.Do(func() {}) {
384+ t.Fatal("display stopped")
385+ }
386+ if err := c.Roundtrip(); err != nil {
387+ t.Fatal(err)
388+ }
389+ for len(p.events) > 0 {
390+ v := <-p.events
391+ if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace {
392+ replaced++
393+ }
394+ if v[0] == 2<<8|ext.ExtWorkspaceHandleV1EventRemoved {
395+ removed++
396+ }
397+ }
398+ }
399+ if replaced != 1 || removed != 1 {
400+ t.Fatalf("rename replaced=%d removed=%d", replaced, removed)
401+ }
402+ select {
403+ case id := <-p.idEvents:
404+ if id != "code" {
405+ t.Fatalf("renamed id=%q", id)
406+ }
407+ default:
408+ t.Fatal("missing renamed id")
409+ }
410+ if len(p.idEvents) != 0 {
411+ t.Fatal("extra id")
412+ }
413+ // Removing the group clears its tombstone, allowing a new handle on return.
414+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
415+ for i := 0; i < 8; i++ {
416+ if !s.display.Do(func() {}) {
417+ t.Fatal("display stopped")
418+ }
419+ if err := c.Roundtrip(); err != nil {
420+ t.Fatal(err)
421+ }
422+ }
423+ for len(p.events) > 0 {
424+ <-p.events
425+ }
426+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}}}}}
427+ recreated := 0
428+ for i := 0; i < 8 && recreated < 2; i++ {
429+ if !s.display.Do(func() {}) {
430+ t.Fatal("display stopped")
431+ }
432+ if err := c.Roundtrip(); err != nil {
433+ t.Fatal(err)
434+ }
435+ for len(p.events) > 0 {
436+ v := <-p.events
437+ if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace || v[0] == ext.ExtWorkspaceManagerV1EventWorkspaceGroup {
438+ recreated++
439+ }
440+ }
441+ }
442+ if recreated != 2 {
443+ t.Fatalf("new group and workspace after disappearance: %d", recreated)
444+ }
445+}
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 +28 -7
@@ -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 enables automatic terminal opening according to terminal.auto-open.
4142 Terminal bool
4243 }
@@ -45,6 +46,7 @@ type binding struct {
4546 key string
4647 }
4748 type Core struct {
49+ nextWorkspaceID uint64
4850 ch Channels
4951 cfg ports.Config
5052 screens []*screen
@@ -77,9 +79,10 @@ type Core struct {
7779 terms map[string]*termSpawn
7880 firstTerminalResolved bool
7981 // clients holds the app ID and PID of mapped windows, for State.
80- clients map[WindowID]ports.WindowMapped
81- inputRegions map[WindowID]ports.InputRegionChanged
82- sentState ports.State
82+ clients map[WindowID]ports.WindowMapped
83+ inputRegions map[WindowID]ports.InputRegionChanged
84+ sentState ports.State
85+ sentWorkspaces ports.Workspaces
8386 // popups are the placed xdg_popups; popupOrder stacks them, oldest first.
8487 popups map[WindowID]*popupState
8588 popupOrder []WindowID
@@ -285,11 +288,12 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
285288 return nil
286289 }
287290 func New(cfg ports.Config, ch Channels) (*Core, error) {
288- if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.Constraints != nil && cap(ch.Constraints) != 1) || (ch.State != nil && cap(ch.State) != 1) {
289- return nil, fmt.Errorf("scenes, layouts, constraints and state must have capacity 1")
291+ if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.Constraints != nil && cap(ch.Constraints) != 1) || (ch.State != nil && cap(ch.State) != 1) || (ch.Workspaces != nil && cap(ch.Workspaces) != 1) {
292+ return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
290293 }
291294 // A placeholder screen holds windows until the first output arrives.
292- c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, inputRegions: map[WindowID]ports.InputRegionChanged{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}, inhibitors: map[WindowID]bool{}, idle: map[WindowID]bool{}}
295+ c := &Core{slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, inputRegions: map[WindowID]ports.InputRegionChanged{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}, inhibitors: map[WindowID]bool{}, idle: map[WindowID]bool{}}
296+ c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
293297 if err := c.apply(cfg); err != nil {
294298 return nil, err
295299 }
@@ -481,6 +485,7 @@ func (c *Core) publish(ctx context.Context) error {
481485 c.resolveConstraint()
482486 latest(c.ch.Scenes, scenes)
483487 c.publishState()
488+ c.publishWorkspaces()
484489 return nil
485490 }
486491
@@ -649,6 +654,7 @@ func (c *Core) Run(ctx context.Context) error {
649654 return nil
650655 }
651656 c.publishState()
657+ c.publishWorkspaces()
652658 for {
653659 select {
654660 case <-ctx.Done():
@@ -743,6 +749,21 @@ func (c *Core) Run(ctx context.Context) error {
743749 if s, _ := c.screenOf(v.ID); s != nil {
744750 s.mon.SetFullscreen(v.ID, v.Fullscreen)
745751 }
752+ case ports.WorkspaceActivate:
753+ for _, id := range v.IDs {
754+ for i, sc := range c.screens {
755+ if sc.name() == "" {
756+ continue
757+ }
758+ for _, w := range sc.mon.all() {
759+ if w.ID == id {
760+ c.focusScreen = i
761+ sc.mon.show(w)
762+ break
763+ }
764+ }
765+ }
766+ }
746767 case ports.WindowActivate:
747768 if c.activate(ctx, v.ID) != nil {
748769 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 enables automatic terminal opening according to terminal.auto-open.41 // Terminal enables automatic terminal opening according to terminal.auto-open.
41 Terminal bool42 Terminal bool
42 }43 }
@@ -45,6 +46,7 @@ type binding struct {
45 key string46 key string
46 }47 }
47 type Core struct {48 type Core struct {
49+ nextWorkspaceID uint64
48 ch Channels50 ch Channels
49 cfg ports.Config51 cfg ports.Config
50 screens []*screen52 screens []*screen
@@ -77,9 +79,10 @@ type Core struct {
77 terms map[string]*termSpawn79 terms map[string]*termSpawn
78 firstTerminalResolved bool80 firstTerminalResolved bool
79 // clients holds the app ID and PID of mapped windows, for State.81 // clients holds the app ID and PID of mapped windows, for State.
80- clients map[WindowID]ports.WindowMapped82+ clients map[WindowID]ports.WindowMapped
81- inputRegions map[WindowID]ports.InputRegionChanged83+ inputRegions map[WindowID]ports.InputRegionChanged
82- sentState ports.State84+ sentState ports.State
85+ sentWorkspaces ports.Workspaces
83 // popups are the placed xdg_popups; popupOrder stacks them, oldest first.86 // popups are the placed xdg_popups; popupOrder stacks them, oldest first.
84 popups map[WindowID]*popupState87 popups map[WindowID]*popupState
85 popupOrder []WindowID88 popupOrder []WindowID
@@ -285,11 +288,12 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
285 return nil288 return nil
286 }289 }
287 func New(cfg ports.Config, ch Channels) (*Core, error) {290 func New(cfg ports.Config, ch Channels) (*Core, error) {
288- if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.Constraints != nil && cap(ch.Constraints) != 1) || (ch.State != nil && cap(ch.State) != 1) {291+ if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.Constraints != nil && cap(ch.Constraints) != 1) || (ch.State != nil && cap(ch.State) != 1) || (ch.Workspaces != nil && cap(ch.Workspaces) != 1) {
289- return nil, fmt.Errorf("scenes, layouts, constraints and state must have capacity 1")292+ return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
290 }293 }
291 // A placeholder screen holds windows until the first output arrives.294 // A placeholder screen holds windows until the first output arrives.
292- c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, inputRegions: map[WindowID]ports.InputRegionChanged{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}, inhibitors: map[WindowID]bool{}, idle: map[WindowID]bool{}}295+ c := &Core{slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, inputRegions: map[WindowID]ports.InputRegionChanged{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}, inhibitors: map[WindowID]bool{}, idle: map[WindowID]bool{}}
296+ c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
293 if err := c.apply(cfg); err != nil {297 if err := c.apply(cfg); err != nil {
294 return nil, err298 return nil, err
295 }299 }
@@ -481,6 +485,7 @@ func (c *Core) publish(ctx context.Context) error {
481 c.resolveConstraint()485 c.resolveConstraint()
482 latest(c.ch.Scenes, scenes)486 latest(c.ch.Scenes, scenes)
483 c.publishState()487 c.publishState()
488+ c.publishWorkspaces()
484 return nil489 return nil
485 }490 }
486 491
@@ -649,6 +654,7 @@ func (c *Core) Run(ctx context.Context) error {
649 return nil654 return nil
650 }655 }
651 c.publishState()656 c.publishState()
657+ c.publishWorkspaces()
652 for {658 for {
653 select {659 select {
654 case <-ctx.Done():660 case <-ctx.Done():
@@ -743,6 +749,21 @@ func (c *Core) Run(ctx context.Context) error {
743 if s, _ := c.screenOf(v.ID); s != nil {749 if s, _ := c.screenOf(v.ID); s != nil {
744 s.mon.SetFullscreen(v.ID, v.Fullscreen)750 s.mon.SetFullscreen(v.ID, v.Fullscreen)
745 }751 }
752+ case ports.WorkspaceActivate:
753+ for _, id := range v.IDs {
754+ for i, sc := range c.screens {
755+ if sc.name() == "" {
756+ continue
757+ }
758+ for _, w := range sc.mon.all() {
759+ if w.ID == id {
760+ c.focusScreen = i
761+ sc.mon.show(w)
762+ break
763+ }
764+ }
765+ }
766+ }
746 case ports.WindowActivate:767 case ports.WindowActivate:
747 if c.activate(ctx, v.ID) != nil {768 if c.activate(ctx, v.ID) != nil {
748 return nil769 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 +8 -1
@@ -21,6 +21,7 @@ type Monitor struct {
2121 shown *Workspace // hidden workspace on screen, nil for Workspaces[Active]
2222 back *Workspace // where a named workspace toggle returns to
2323 template Workspace
24+ nextID *uint64 // shared by the monitors of one Core; owner goroutine only
2425 named []NamedWorkspace
2526 // followMove shows the target workspace after a move to it.
2627 followMove bool
@@ -39,7 +40,11 @@ type NamedWorkspace struct {
3940 func NewMonitor() *Monitor { return newMonitor("", "") }
4041
4142 func newMonitor(name, key string) *Monitor {
42- m := &Monitor{Name: name, Key: key}
43+ return newMonitorWithIDs(name, key, new(uint64))
44+}
45+
46+func newMonitorWithIDs(name, key string, nextID *uint64) *Monitor {
47+ m := &Monitor{Name: name, Key: key, nextID: nextID}
4348 m.normalize()
4449 return m
4550 }
@@ -119,6 +124,8 @@ func (m *Monitor) newWorkspace() *Workspace {
119124 w := m.template
120125 w.presets = append([]Width(nil), m.template.presets...)
121126 w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{}
127+ (*m.nextID)++
128+ w.ID = *m.nextID
122129 return &w
123130 }
124131
@@ -21,6 +21,7 @@ type Monitor struct {
21 shown *Workspace // hidden workspace on screen, nil for Workspaces[Active]21 shown *Workspace // hidden workspace on screen, nil for Workspaces[Active]
22 back *Workspace // where a named workspace toggle returns to22 back *Workspace // where a named workspace toggle returns to
23 template Workspace23 template Workspace
24+ nextID *uint64 // shared by the monitors of one Core; owner goroutine only
24 named []NamedWorkspace25 named []NamedWorkspace
25 // followMove shows the target workspace after a move to it.26 // followMove shows the target workspace after a move to it.
26 followMove bool27 followMove bool
@@ -39,7 +40,11 @@ type NamedWorkspace struct {
39 func NewMonitor() *Monitor { return newMonitor("", "") }40 func NewMonitor() *Monitor { return newMonitor("", "") }
40 41
41 func newMonitor(name, key string) *Monitor {42 func newMonitor(name, key string) *Monitor {
42- m := &Monitor{Name: name, Key: key}43+ return newMonitorWithIDs(name, key, new(uint64))
44+}
45+
46+func newMonitorWithIDs(name, key string, nextID *uint64) *Monitor {
47+ m := &Monitor{Name: name, Key: key, nextID: nextID}
43 m.normalize()48 m.normalize()
44 return m49 return m
45 }50 }
@@ -119,6 +124,8 @@ func (m *Monitor) newWorkspace() *Workspace {
119 w := m.template124 w := m.template
120 w.presets = append([]Width(nil), m.template.presets...)125 w.presets = append([]Width(nil), m.template.presets...)
121 w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{}126 w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{}
127+ (*m.nextID)++
128+ w.ID = *m.nextID
122 return &w129 return &w
123 }130 }
124 131
M internal/core/screens.go +1 -1
@@ -186,7 +186,7 @@ func (c *Core) addScreen(info ports.OutputInfo) {
186186 s = c.screens[0]
187187 s.info = info
188188 } else {
189- s = &screen{info: info, mon: newMonitor(info.Name, info.Key())}
189+ s = &screen{info: info, mon: newMonitorWithIDs(info.Name, info.Key(), &c.nextWorkspaceID)}
190190 c.screens = append(c.screens, s)
191191 }
192192 s.mon.Name, s.mon.Key = info.Name, info.Key()
@@ -186,7 +186,7 @@ func (c *Core) addScreen(info ports.OutputInfo) {
186 s = c.screens[0]186 s = c.screens[0]
187 s.info = info187 s.info = info
188 } else {188 } else {
189- s = &screen{info: info, mon: newMonitor(info.Name, info.Key())}189+ s = &screen{info: info, mon: newMonitorWithIDs(info.Name, info.Key(), &c.nextWorkspaceID)}
190 c.screens = append(c.screens, s)190 c.screens = append(c.screens, s)
191 }191 }
192 s.mon.Name, s.mon.Key = info.Name, info.Key()192 s.mon.Name, s.mon.Key = info.Name, info.Key()
A internal/core/workspaces.go +38 -0
@@ -0,0 +1,38 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "strconv"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// publishWorkspaces describes the numbered vertical stack and configured hidden
11+// workspaces. Config-hidden named workspaces and empty numbered spares are
12+// hidden unless shown; unlike State, hidden named workspaces remain addressable.
13+func (c *Core) publishWorkspaces() {
14+ if c.ch.Workspaces == nil {
15+ return
16+ }
17+ snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
18+ for _, sc := range c.screens {
19+ if sc.name() == "" {
20+ continue
21+ }
22+ m := sc.mon
23+ out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: []ports.WorkspaceInfo{}}
24+ for i, w := range m.all() {
25+ name := w.Name
26+ if name == "" {
27+ name = strconv.Itoa(i + 1)
28+ }
29+ out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: m.Current() != w && (m.isHidden(w) || w.empty()), Configured: w.Name})
30+ }
31+ snapshot.Outputs = append(snapshot.Outputs, out)
32+ }
33+ if reflect.DeepEqual(snapshot, c.sentWorkspaces) {
34+ return
35+ }
36+ c.sentWorkspaces = snapshot
37+ latest(c.ch.Workspaces, snapshot)
38+}
@@ -0,0 +1,38 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "strconv"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// publishWorkspaces describes the numbered vertical stack and configured hidden
11+// workspaces. Config-hidden named workspaces and empty numbered spares are
12+// hidden unless shown; unlike State, hidden named workspaces remain addressable.
13+func (c *Core) publishWorkspaces() {
14+ if c.ch.Workspaces == nil {
15+ return
16+ }
17+ snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
18+ for _, sc := range c.screens {
19+ if sc.name() == "" {
20+ continue
21+ }
22+ m := sc.mon
23+ out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: []ports.WorkspaceInfo{}}
24+ for i, w := range m.all() {
25+ name := w.Name
26+ if name == "" {
27+ name = strconv.Itoa(i + 1)
28+ }
29+ out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: m.Current() != w && (m.isHidden(w) || w.empty()), Configured: w.Name})
30+ }
31+ snapshot.Outputs = append(snapshot.Outputs, out)
32+ }
33+ if reflect.DeepEqual(snapshot, c.sentWorkspaces) {
34+ return
35+ }
36+ c.sentWorkspaces = snapshot
37+ latest(c.ch.Workspaces, snapshot)
38+}
A internal/core/workspaces_test.go +106 -0
@@ -0,0 +1,106 @@
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 TestWorkspaceChannelCapacity(t *testing.T) {
13+ _, err := core.New(config.Defaults(), core.Channels{Scenes: make(chan []ports.Scene, 1), Workspaces: make(chan ports.Workspaces, 2)})
14+ if err == nil {
15+ t.Fatal("expected capacity-1 validation")
16+ }
17+}
18+
19+func TestWorkspaceSnapshotsAndActivation(t *testing.T) {
20+ cfg := config.Defaults()
21+ cfg.Workspaces = append(cfg.Workspaces, ports.WorkspaceConfig{Name: "dev"})
22+ client := make(chan ports.ClientEvent, 16)
23+ input := make(chan ports.InputEvent, 8)
24+ output := make(chan ports.OutputEvent, 8)
25+ commands := make(chan ports.ClientCommand, 32)
26+ snapshots := make(chan ports.Workspaces, 1)
27+ scenes := make(chan []ports.Scene, 1)
28+ state := make(chan ports.State, 1)
29+ spawn := make(chan ports.SpawnRequest, 8)
30+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, State: state, Scenes: scenes, Spawn: spawn})
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ ctx, cancel := context.WithCancel(context.Background())
35+ done := make(chan error, 1)
36+ go func() { done <- c.Run(ctx) }()
37+ t.Cleanup(func() {
38+ cancel()
39+ if err := <-done; err != nil {
40+ t.Error(err)
41+ }
42+ })
43+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 100, Height: 80}}
44+ first := receive(t, snapshots)
45+ for len(first.Outputs) == 0 {
46+ first = receive(t, snapshots)
47+ }
48+ if len(first.Outputs) != 1 || len(first.Outputs[0].Workspaces) < 2 || !first.Outputs[0].Workspaces[0].Active {
49+ t.Fatal(first)
50+ }
51+ hidden := first.Outputs[0].Workspaces[len(first.Outputs[0].Workspaces)-1]
52+ if hidden.Configured != "dev" || !hidden.Hidden {
53+ t.Fatalf("configured hidden workspace: %+v", hidden)
54+ }
55+ id := first.Outputs[0].Workspaces[0].ID
56+ client <- ports.WindowMapped{ID: 1}
57+ added := receive(t, snapshots)
58+ if len(added.Outputs[0].Workspaces) < 2 || added.Outputs[0].Workspaces[0].ID != id || !added.Outputs[0].Workspaces[1].Hidden {
59+ t.Fatal(added)
60+ }
61+ spare := added.Outputs[0].Workspaces[1].ID
62+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "B", Width: 100, Height: 80}}
63+ two := receive(t, snapshots)
64+ if len(two.Outputs) != 2 || two.Outputs[0].Workspaces[0].ID != id {
65+ t.Fatal(two)
66+ }
67+ // Activation on B also focuses B, without disturbing A's active workspace.
68+ bID := two.Outputs[1].Workspaces[0].ID
69+ client <- ports.WorkspaceActivate{IDs: []uint64{bID}}
70+ for receive(t, state).Output != "B" {
71+ }
72+ // Both requests are applied in order in one event: A wins final focus.
73+ client <- ports.WorkspaceActivate{IDs: []uint64{bID, spare}}
74+ for receive(t, state).Output != "A" {
75+ }
76+ active := receive(t, snapshots)
77+ if !active.Outputs[0].Workspaces[1].Active || active.Outputs[1].Workspaces[0].Active != true {
78+ t.Fatal(active)
79+ }
80+ client <- ports.WorkspaceActivate{IDs: []uint64{999999999}}
81+ scene(t, scenes)
82+ select {
83+ case v := <-snapshots:
84+ t.Fatalf("unknown ID changed snapshot: %v", v)
85+ default:
86+ }
87+ // Move the populated workspace right; its ID follows it.
88+ client <- ports.WorkspaceActivate{IDs: []uint64{id}}
89+ receive(t, snapshots)
90+ input <- ports.KeyEvent{Keysym: "Right", Mods: ports.ModSuper | ports.ModCtrl | ports.ModShift, Pressed: true}
91+ move := receive(t, snapshots)
92+ found := false
93+ for _, w := range move.Outputs[1].Workspaces {
94+ if w.ID == id {
95+ found = true
96+ }
97+ }
98+ if !found {
99+ t.Fatalf("workspace ID lost on move: %+v", move)
100+ }
101+ output <- ports.OutputRemoved{Name: "B"}
102+ moved := receive(t, snapshots)
103+ if len(moved.Outputs) != 1 || moved.Outputs[0].Name != "A" {
104+ t.Fatal(moved)
105+ }
106+}
@@ -0,0 +1,106 @@
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 TestWorkspaceChannelCapacity(t *testing.T) {
13+ _, err := core.New(config.Defaults(), core.Channels{Scenes: make(chan []ports.Scene, 1), Workspaces: make(chan ports.Workspaces, 2)})
14+ if err == nil {
15+ t.Fatal("expected capacity-1 validation")
16+ }
17+}
18+
19+func TestWorkspaceSnapshotsAndActivation(t *testing.T) {
20+ cfg := config.Defaults()
21+ cfg.Workspaces = append(cfg.Workspaces, ports.WorkspaceConfig{Name: "dev"})
22+ client := make(chan ports.ClientEvent, 16)
23+ input := make(chan ports.InputEvent, 8)
24+ output := make(chan ports.OutputEvent, 8)
25+ commands := make(chan ports.ClientCommand, 32)
26+ snapshots := make(chan ports.Workspaces, 1)
27+ scenes := make(chan []ports.Scene, 1)
28+ state := make(chan ports.State, 1)
29+ spawn := make(chan ports.SpawnRequest, 8)
30+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, State: state, Scenes: scenes, Spawn: spawn})
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ ctx, cancel := context.WithCancel(context.Background())
35+ done := make(chan error, 1)
36+ go func() { done <- c.Run(ctx) }()
37+ t.Cleanup(func() {
38+ cancel()
39+ if err := <-done; err != nil {
40+ t.Error(err)
41+ }
42+ })
43+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 100, Height: 80}}
44+ first := receive(t, snapshots)
45+ for len(first.Outputs) == 0 {
46+ first = receive(t, snapshots)
47+ }
48+ if len(first.Outputs) != 1 || len(first.Outputs[0].Workspaces) < 2 || !first.Outputs[0].Workspaces[0].Active {
49+ t.Fatal(first)
50+ }
51+ hidden := first.Outputs[0].Workspaces[len(first.Outputs[0].Workspaces)-1]
52+ if hidden.Configured != "dev" || !hidden.Hidden {
53+ t.Fatalf("configured hidden workspace: %+v", hidden)
54+ }
55+ id := first.Outputs[0].Workspaces[0].ID
56+ client <- ports.WindowMapped{ID: 1}
57+ added := receive(t, snapshots)
58+ if len(added.Outputs[0].Workspaces) < 2 || added.Outputs[0].Workspaces[0].ID != id || !added.Outputs[0].Workspaces[1].Hidden {
59+ t.Fatal(added)
60+ }
61+ spare := added.Outputs[0].Workspaces[1].ID
62+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "B", Width: 100, Height: 80}}
63+ two := receive(t, snapshots)
64+ if len(two.Outputs) != 2 || two.Outputs[0].Workspaces[0].ID != id {
65+ t.Fatal(two)
66+ }
67+ // Activation on B also focuses B, without disturbing A's active workspace.
68+ bID := two.Outputs[1].Workspaces[0].ID
69+ client <- ports.WorkspaceActivate{IDs: []uint64{bID}}
70+ for receive(t, state).Output != "B" {
71+ }
72+ // Both requests are applied in order in one event: A wins final focus.
73+ client <- ports.WorkspaceActivate{IDs: []uint64{bID, spare}}
74+ for receive(t, state).Output != "A" {
75+ }
76+ active := receive(t, snapshots)
77+ if !active.Outputs[0].Workspaces[1].Active || active.Outputs[1].Workspaces[0].Active != true {
78+ t.Fatal(active)
79+ }
80+ client <- ports.WorkspaceActivate{IDs: []uint64{999999999}}
81+ scene(t, scenes)
82+ select {
83+ case v := <-snapshots:
84+ t.Fatalf("unknown ID changed snapshot: %v", v)
85+ default:
86+ }
87+ // Move the populated workspace right; its ID follows it.
88+ client <- ports.WorkspaceActivate{IDs: []uint64{id}}
89+ receive(t, snapshots)
90+ input <- ports.KeyEvent{Keysym: "Right", Mods: ports.ModSuper | ports.ModCtrl | ports.ModShift, Pressed: true}
91+ move := receive(t, snapshots)
92+ found := false
93+ for _, w := range move.Outputs[1].Workspaces {
94+ if w.ID == id {
95+ found = true
96+ }
97+ }
98+ if !found {
99+ t.Fatalf("workspace ID lost on move: %+v", move)
100+ }
101+ output <- ports.OutputRemoved{Name: "B"}
102+ moved := receive(t, snapshots)
103+ if len(moved.Outputs) != 1 || moved.Outputs[0].Name != "A" {
104+ t.Fatal(moved)
105+ }
106+}
M internal/ports/messages.go +20 -0
@@ -107,6 +107,26 @@ type WindowActivate struct{ ID WindowID }
107107
108108 func (WindowActivate) clientEvent() {}
109109
110+// WorkspaceActivate requests showing workspaces by stable ID, in commit order.
111+// Core applies the entire batch before publishing a new state.
112+type WorkspaceActivate struct{ IDs []uint64 }
113+
114+func (WorkspaceActivate) clientEvent() {}
115+
116+// Workspaces is an immutable, latest-only inventory of output workspaces.
117+type Workspaces struct{ Outputs []WorkspaceOutput }
118+type WorkspaceOutput struct {
119+ Name string
120+ Workspaces []WorkspaceInfo
121+}
122+type WorkspaceInfo struct {
123+ ID uint64
124+ Configured string // stable configured name; empty for dynamic workspaces
125+ Name string
126+ Index int // zero-based vertical coordinate
127+ Active, Hidden bool
128+}
129+
110130 // WindowAppID carries wayland → core an app ID set after the window mapped.
111131 type WindowAppID struct {
112132 ID WindowID
@@ -107,6 +107,26 @@ type WindowActivate struct{ ID WindowID }
107 107
108 func (WindowActivate) clientEvent() {}108 func (WindowActivate) clientEvent() {}
109 109
110+// WorkspaceActivate requests showing workspaces by stable ID, in commit order.
111+// Core applies the entire batch before publishing a new state.
112+type WorkspaceActivate struct{ IDs []uint64 }
113+
114+func (WorkspaceActivate) clientEvent() {}
115+
116+// Workspaces is an immutable, latest-only inventory of output workspaces.
117+type Workspaces struct{ Outputs []WorkspaceOutput }
118+type WorkspaceOutput struct {
119+ Name string
120+ Workspaces []WorkspaceInfo
121+}
122+type WorkspaceInfo struct {
123+ ID uint64
124+ Configured string // stable configured name; empty for dynamic workspaces
125+ Name string
126+ Index int // zero-based vertical coordinate
127+ Active, Hidden bool
128+}
129+
110 // WindowAppID carries wayland → core an app ID set after the window mapped.130 // WindowAppID carries wayland → core an app ID set after the window mapped.
111 type WindowAppID struct {131 type WindowAppID struct {
112 ID WindowID132 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